Skip to content

Commit d246440

Browse files
authored
Merge pull request #1256 from assemblycom/feature/email-reminders
feature/email-reminders
2 parents 48b1224 + 0525256 commit d246440

32 files changed

Lines changed: 2617 additions & 19 deletions

.github/workflows/lint.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ jobs:
1414
- name: Set up Node.js
1515
uses: actions/setup-node@v3
1616
with:
17-
node-version: 20.18.0
17+
# Read from .nvmrc (20.19.1) so CI matches local dev. The previous hardcoded
18+
# 20.18.0 was below testcontainers' undici requirement (node >=20.18.1).
19+
node-version-file: '.nvmrc'
1820
cache: yarn
1921
cache-dependency-path: './yarn.lock'
2022

jest.config.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,8 @@ const config: Config = {
169169
// "**/?(*.)+(spec|test).[tj]s?(x)"
170170
// ],
171171

172-
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
173-
// testPathIgnorePatterns: [
174-
// "/node_modules/"
175-
// ],
172+
// Integration tests need a real Postgres and run via jest.integration.config.ts, not here.
173+
testPathIgnorePatterns: ['/node_modules/', '\\.integration\\.test\\.ts$'],
176174

177175
// The regexp pattern or array of patterns that Jest uses to detect test files
178176
// testRegex: [],

jest.integration.config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { Config } from 'jest'
2+
import nextJest from 'next/jest.js'
3+
4+
const createJestConfig = nextJest({ dir: './' })
5+
6+
// Real-Postgres integration tests. A testcontainer is booted once in globalSetup, migrated,
7+
// and torn down after. Kept separate from the default `jest` run, which has no DB.
8+
const config: Config = {
9+
testEnvironment: 'node',
10+
testMatch: ['**/*.integration.test.ts'],
11+
globalSetup: '<rootDir>/test/integration/globalSetup.ts',
12+
globalTeardown: '<rootDir>/test/integration/globalTeardown.ts',
13+
setupFilesAfterEnv: ['<rootDir>/test/integration/setup-env.ts'],
14+
moduleNameMapper: {
15+
'^@/(.*)$': '<rootDir>/src/$1',
16+
'^@api/(.*)$': '<rootDir>/src/app/api/$1',
17+
},
18+
collectCoverage: false,
19+
// One Postgres, shared serially: parallel workers would race truncateAll between tests.
20+
maxWorkers: 1,
21+
testTimeout: 30000,
22+
}
23+
24+
export default createJestConfig(config)

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"@faker-js/faker": "^8.4.1",
6464
"@ngrok/ngrok": "^1.4.1",
6565
"@svgr/webpack": "^8.1.0",
66+
"@testcontainers/postgresql": "^12.0.0",
6667
"@trigger.dev/build": "4.3.1",
6768
"@types/file-saver": "^2.0.7",
6869
"@types/jest": "^29.5.12",
@@ -81,6 +82,7 @@
8182
"prettier": "^3.1.1",
8283
"tailwind-merge": "^3.4.0",
8384
"tailwindcss": "^3.3.0",
85+
"testcontainers": "^12.0.0",
8486
"text-table": "^0.2.0",
8587
"ts-node": "^10.9.2",
8688
"tsx": "^4.16.5",
@@ -144,6 +146,7 @@
144146
"seed:activity-logs": "tsx ./src/cmd/fill-activity-logs",
145147
"start": "next start",
146148
"test": "jest",
149+
"test:integration": "jest --config jest.integration.config.ts --runInBand",
147150
"tsc": "tsc --noEmit",
148151
"trigger": "npx trigger.dev@latest",
149152
"trigger:deploy-staging": "yarn trigger deploy -e staging",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- CreateEnum
2+
CREATE TYPE "TaskReminderType" AS ENUM ('NO_DUE_DATE_3D', 'NO_DUE_DATE_7D', 'DUE_DATE_BEFORE_3D', 'DUE_DATE_TODAY', 'DUE_DATE_OVERDUE_3D', 'DUE_DATE_OVERDUE_7D');
3+
4+
-- CreateTable
5+
CREATE TABLE "TaskReminderSents" (
6+
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
7+
"taskId" UUID NOT NULL,
8+
"workspaceId" VARCHAR(32) NOT NULL,
9+
"recipientId" UUID NOT NULL,
10+
"reminderType" "TaskReminderType" NOT NULL,
11+
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
12+
13+
CONSTRAINT "TaskReminderSents_pkey" PRIMARY KEY ("id")
14+
);
15+
16+
-- CreateIndex
17+
CREATE UNIQUE INDEX "TaskReminderSents_taskId_recipientId_reminderType_key" ON "TaskReminderSents"("taskId", "recipientId", "reminderType");
18+
19+
-- AddForeignKey
20+
ALTER TABLE "TaskReminderSents" ADD CONSTRAINT "TaskReminderSents_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;

prisma/schema/task.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ model Task {
5757
deletedBy String? @db.Uuid
5858
5959
taskUpdateBacklogs TaskUpdateBacklog[]
60+
taskReminderSents TaskReminderSent[]
6061
6162
associations Json @db.JsonB @default("[]")
6263
isShared Boolean @default(false)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
enum TaskReminderType {
2+
NO_DUE_DATE_3D
3+
NO_DUE_DATE_7D
4+
DUE_DATE_BEFORE_3D
5+
DUE_DATE_TODAY
6+
DUE_DATE_OVERDUE_3D
7+
DUE_DATE_OVERDUE_7D
8+
}
9+
10+
model TaskReminderSent {
11+
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
12+
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
13+
taskId String @db.Uuid
14+
workspaceId String @db.VarChar(32)
15+
recipientId String @db.Uuid
16+
reminderType TaskReminderType
17+
sentAt DateTime @default(now())
18+
19+
@@unique([taskId, recipientId, reminderType])
20+
@@map("TaskReminderSents")
21+
}

src/app/api/core/types/tasks.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export enum NotificationTaskActions {
88
CompletedForCompanyByIU = 'completedForCompanyByIu',
99
Completed = 'completed',
1010
CompletedByIU = 'completedByIu',
11+
// Completion notifications for client users a task is *shared* with (viewers), not assignees
12+
CompletedToSharedCU = 'completedToSharedCU',
13+
CompletedToSharedCompany = 'completedToSharedCompany',
1114
Commented = 'commented',
1215
// these two comment actions below are sub actions of Commented.
1316
// Its used to handle the cases for CU vs IU being notified of comments appropriately
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`getReminderEmailDetails matches snapshot for company recipient 1`] = `
4+
{
5+
"DUE_DATE_BEFORE_3D": {
6+
"body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due in 3 days.
7+
8+
Please make sure to complete this task by the due date.",
9+
"ctaParams": {
10+
"taskId": "task_1",
11+
},
12+
"header": "A task was assigned to your company",
13+
"subject": "[Due Soon] Task due in 3 days",
14+
"title": "View task",
15+
},
16+
"DUE_DATE_OVERDUE_3D": {
17+
"body": "This is a friendly reminder that the task ‘Submit timesheet’ is now overdue. It was due 3 days ago and is still pending completion.",
18+
"ctaParams": {
19+
"taskId": "task_1",
20+
},
21+
"header": "A task was assigned to your company",
22+
"subject": "[Overdue] Task was due 3 days ago",
23+
"title": "View task",
24+
},
25+
"DUE_DATE_OVERDUE_7D": {
26+
"body": "This is a friendly reminder that the task ‘Submit timesheet’ is now one week overdue.
27+
28+
Please complete this task as soon as possible.",
29+
"ctaParams": {
30+
"taskId": "task_1",
31+
},
32+
"header": "A task was assigned to your company",
33+
"subject": "[Overdue] Task overdue by one week",
34+
"title": "View task",
35+
},
36+
"DUE_DATE_TODAY": {
37+
"body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due today.
38+
39+
Please complete this task as soon as possible.",
40+
"ctaParams": {
41+
"taskId": "task_1",
42+
},
43+
"header": "A task was assigned to your company",
44+
"subject": "[Due Soon] Task due today",
45+
"title": "View task",
46+
},
47+
"NO_DUE_DATE_3D": {
48+
"body": "This is a friendly reminder that you have a task ‘Submit timesheet’ assigned to you that's still pending completion.
49+
50+
If you've already completed this task, please mark it as done in the portal.",
51+
"ctaParams": {
52+
"taskId": "task_1",
53+
},
54+
"header": "A task was assigned to your company",
55+
"subject": "[Reminder] You have a task to complete",
56+
"title": "View task",
57+
},
58+
"NO_DUE_DATE_7D": {
59+
"body": "This is a friendly reminder that you have a task ‘Submit timesheet’ that was assigned to you a week ago and is still pending.
60+
61+
If you've already completed this task, please mark it as done in the portal.",
62+
"ctaParams": {
63+
"taskId": "task_1",
64+
},
65+
"header": "A task was assigned to your company",
66+
"subject": "[Reminder] Task still pending",
67+
"title": "View task",
68+
},
69+
}
70+
`;
71+
72+
exports[`getReminderEmailDetails matches snapshot for individual recipient 1`] = `
73+
{
74+
"DUE_DATE_BEFORE_3D": {
75+
"body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due in 3 days.
76+
77+
Please make sure to complete this task by the due date.",
78+
"ctaParams": {
79+
"taskId": "task_1",
80+
},
81+
"header": "A task was assigned to you",
82+
"subject": "[Due Soon] Task due in 3 days",
83+
"title": "View task",
84+
},
85+
"DUE_DATE_OVERDUE_3D": {
86+
"body": "This is a friendly reminder that the task ‘Submit timesheet’ is now overdue. It was due 3 days ago and is still pending completion.",
87+
"ctaParams": {
88+
"taskId": "task_1",
89+
},
90+
"header": "A task was assigned to you",
91+
"subject": "[Overdue] Task was due 3 days ago",
92+
"title": "View task",
93+
},
94+
"DUE_DATE_OVERDUE_7D": {
95+
"body": "This is a friendly reminder that the task ‘Submit timesheet’ is now one week overdue.
96+
97+
Please complete this task as soon as possible.",
98+
"ctaParams": {
99+
"taskId": "task_1",
100+
},
101+
"header": "A task was assigned to you",
102+
"subject": "[Overdue] Task overdue by one week",
103+
"title": "View task",
104+
},
105+
"DUE_DATE_TODAY": {
106+
"body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due today.
107+
108+
Please complete this task as soon as possible.",
109+
"ctaParams": {
110+
"taskId": "task_1",
111+
},
112+
"header": "A task was assigned to you",
113+
"subject": "[Due Soon] Task due today",
114+
"title": "View task",
115+
},
116+
"NO_DUE_DATE_3D": {
117+
"body": "This is a friendly reminder that you have a task ‘Submit timesheet’ assigned to you that's still pending completion.
118+
119+
If you've already completed this task, please mark it as done in the portal.",
120+
"ctaParams": {
121+
"taskId": "task_1",
122+
},
123+
"header": "A task was assigned to you",
124+
"subject": "[Reminder] You have a task to complete",
125+
"title": "View task",
126+
},
127+
"NO_DUE_DATE_7D": {
128+
"body": "This is a friendly reminder that you have a task ‘Submit timesheet’ that was assigned to you a week ago and is still pending.
129+
130+
If you've already completed this task, please mark it as done in the portal.",
131+
"ctaParams": {
132+
"taskId": "task_1",
133+
},
134+
"header": "A task was assigned to you",
135+
"subject": "[Reminder] Task still pending",
136+
"title": "View task",
137+
},
138+
}
139+
`;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { WorkspaceResponse } from '@/types/common'
2+
import { getReminderEmailDetails } from './notification.helpers'
3+
import { TaskReminderType } from '@prisma/client'
4+
5+
const workspace: WorkspaceResponse = {
6+
id: 'ws_1',
7+
brandName: 'Acme',
8+
labels: {
9+
individualTerm: 'client',
10+
individualTermPlural: 'clients',
11+
groupTerm: 'company',
12+
groupTermPlural: 'companies',
13+
},
14+
}
15+
16+
const task = { id: 'task_1', title: 'Submit timesheet' }
17+
18+
describe('getReminderEmailDetails', () => {
19+
it('returns a value for every TaskReminderType', () => {
20+
const result = getReminderEmailDetails(workspace, task, false)
21+
const expectedKeys = Object.values(TaskReminderType).sort()
22+
expect(Object.keys(result).sort()).toEqual(expectedKeys)
23+
})
24+
25+
it('matches snapshot for individual recipient', () => {
26+
expect(getReminderEmailDetails(workspace, task, false)).toMatchSnapshot()
27+
})
28+
29+
it('matches snapshot for company recipient', () => {
30+
expect(getReminderEmailDetails(workspace, task, true)).toMatchSnapshot()
31+
})
32+
33+
it('uses custom group term from workspace labels for company recipient', () => {
34+
const customWorkspace: WorkspaceResponse = {
35+
...workspace,
36+
labels: { ...workspace.labels, groupTerm: 'team' },
37+
}
38+
const result = getReminderEmailDetails(customWorkspace, task, true)
39+
expect(result[TaskReminderType.NO_DUE_DATE_3D].header).toBe('A task was assigned to your team')
40+
})
41+
42+
it('omits any `<brand> portal:` prefix from subjects (Copilot prepends it server-side)', () => {
43+
const result = getReminderEmailDetails(workspace, task, false)
44+
for (const variant of Object.values(TaskReminderType)) {
45+
expect(result[variant].subject).not.toMatch(/portal:/i)
46+
}
47+
})
48+
49+
it('emits ctaParams with the task id for every variant', () => {
50+
const result = getReminderEmailDetails(workspace, task, false)
51+
for (const variant of Object.values(TaskReminderType)) {
52+
expect(result[variant].ctaParams).toEqual({ taskId: 'task_1' })
53+
}
54+
})
55+
})

0 commit comments

Comments
 (0)