Skip to content

Commit 5dd538d

Browse files
committed
Merge branch 'feature/grouped-emails' of https://github.com/assemblycom/tasks-app into staging
2 parents c1e0744 + 20bf74e commit 5dd538d

7 files changed

Lines changed: 102 additions & 24 deletions

File tree

src/app/api/notification/notification.helpers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,16 @@ export const getEmailDetails = (
247247
}
248248
}
249249

250+
// Escalating cadence tag prefixed to the subject line as the due date approaches (OUT-3861).
251+
export const REMINDER_ESCALATION_TAG: Record<TaskReminderType, string> = {
252+
[TaskReminderType.NO_DUE_DATE_3D]: '[Reminder]',
253+
[TaskReminderType.NO_DUE_DATE_7D]: '[Reminder]',
254+
[TaskReminderType.DUE_DATE_BEFORE_3D]: '[Due Soon]',
255+
[TaskReminderType.DUE_DATE_TODAY]: '[Due Soon]',
256+
[TaskReminderType.DUE_DATE_OVERDUE_3D]: '[Overdue]',
257+
[TaskReminderType.DUE_DATE_OVERDUE_7D]: '[Overdue]',
258+
}
259+
250260
// Subjects intentionally omit any `<brandName> portal:` prefix — Copilot's email
251261
// service prepends that itself, and adding it here results in a duplicated prefix.
252262
export const getReminderEmailDetails = (

src/config/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,12 @@ export const showQueries = (() => {
4949
})()
5050

5151
export const assemblyApiDomain = z.string().url().parse(process.env.NEXT_PUBLIC_ASSEMBLY_API_DOMAIN)
52+
53+
// Workspaces whose single reminder emails use the task title as the subject, prefixed with the
54+
// escalating cadence tag (OUT-3861). Comma-separated workspace ids, e.g. C1: us-west-2_lg5zB-Utp.
55+
export const reminderSubjectOverrideWorkspaces = new Set(
56+
(process.env.REMINDER_SUBJECT_OVERRIDE_WORKSPACES || '')
57+
.split(',')
58+
.map((id) => id.trim())
59+
.filter(Boolean),
60+
)

src/jobs/notifications/flush-grouped-email.integration.test.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ const seedEvent = async ({
8181
})
8282
}
8383

84-
const unsentCount = async (windowKey: string): Promise<number> => {
84+
const totalCount = async (windowKey: string): Promise<number> => {
8585
const rows = await getTestDb().$queryRaw<{ count: bigint }[]>`
86-
SELECT count(*) FROM "GroupedEmailEvents" WHERE "windowKey" = ${windowKey} AND "sentAt" IS NULL`
86+
SELECT count(*) FROM "GroupedEmailEvents" WHERE "windowKey" = ${windowKey}`
8787
return Number(rows[0].count)
8888
}
8989

@@ -99,7 +99,7 @@ beforeEach(async () => {
9999
afterAll(disconnectTestDb)
100100

101101
describe('flush-grouped-email idempotency (real DB)', () => {
102-
it('sends one grouped email for multiple events and marks every row as sent', async () => {
102+
it('sends one grouped email for multiple events and deletes all rows on success', async () => {
103103
const window = 'win_multi_event'
104104
const taskA = await seedTask({ workspaceId: WS, assigneeId: CLIENT_A, assigneeType: 'client', companyId: COMPANY })
105105
const taskB = await seedTask({ workspaceId: WS, assigneeId: CLIENT_A, assigneeType: 'client', companyId: COMPANY })
@@ -120,7 +120,7 @@ describe('flush-grouped-email idempotency (real DB)', () => {
120120

121121
expect(mockCreateNotification).toHaveBeenCalledTimes(1)
122122
expect(result).toMatchObject({ recipients: 1, sent: 1, sentGrouped: 1, sentIndividual: 0 })
123-
expect(await unsentCount(window)).toBe(0)
123+
expect(await totalCount(window)).toBe(0)
124124
})
125125

126126
it('is idempotent: re-flushing a fully-sent window is a no-op', async () => {
@@ -142,8 +142,9 @@ describe('flush-grouped-email idempotency (real DB)', () => {
142142

143143
await flushGroupedEmailRun({ workspaceId: WS, windowKey: window })
144144
expect(mockCreateNotification).toHaveBeenCalledTimes(1)
145+
expect(await totalCount(window)).toBe(0)
145146

146-
// Second flush: all rows already have sentAt set — no email, skipped return.
147+
// Second flush: rows were deleted — no email, skipped return.
147148
const result = await flushGroupedEmailRun({ workspaceId: WS, windowKey: window })
148149
expect(mockCreateNotification).toHaveBeenCalledTimes(1)
149150
expect(result).toMatchObject({ skipped: true })
@@ -169,14 +170,14 @@ describe('flush-grouped-email idempotency (real DB)', () => {
169170
mockCreateNotification.mockRejectedValueOnce(new Error('copilot 5xx'))
170171
await expect(flushGroupedEmailRun({ workspaceId: WS, windowKey: window })).rejects.toThrow('copilot 5xx')
171172

172-
// Rows are still unsent — retry can re-attempt.
173-
expect(await unsentCount(window)).toBe(2)
173+
// Failed run: rows are preserved so the retry can re-attempt.
174+
expect(await totalCount(window)).toBe(2)
174175

175-
// Retry succeeds.
176+
// Retry succeeds: rows are deleted.
176177
mockCreateNotification.mockResolvedValueOnce({ id: 'notif_2' })
177178
await flushGroupedEmailRun({ workspaceId: WS, windowKey: window })
178179
expect(mockCreateNotification).toHaveBeenCalledTimes(2)
179-
expect(await unsentCount(window)).toBe(0)
180+
expect(await totalCount(window)).toBe(0)
180181
})
181182

182183
it('sends one email per recipient when the window holds events for multiple clients', async () => {
@@ -217,10 +218,10 @@ describe('flush-grouped-email idempotency (real DB)', () => {
217218
const recipients = mockCreateNotification.mock.calls.map((c) => c[0].recipientClientId).sort()
218219
expect(recipients).toEqual([CLIENT_A, CLIENT_B].sort())
219220
expect(result).toMatchObject({ recipients: 2, sent: 2, sentGrouped: 2, sentIndividual: 0 })
220-
expect(await unsentCount(window)).toBe(0)
221+
expect(await totalCount(window)).toBe(0)
221222
})
222223

223-
it('skips events for archived tasks and marks their rows sent without emailing', async () => {
224+
it('skips events for archived tasks and deletes all rows on success', async () => {
224225
const window = 'win_archived'
225226
const archivedTask = await seedTask({
226227
workspaceId: WS,
@@ -244,11 +245,10 @@ describe('flush-grouped-email idempotency (real DB)', () => {
244245
// Only the live task appears in the grouped email (1 event); individual snapshot path.
245246
expect(mockCreateNotification).toHaveBeenCalledTimes(1)
246247
expect(result).toMatchObject({ sentGrouped: 0, sentIndividual: 1 })
247-
// All rows are marked sent regardless of whether the task was live.
248-
expect(await unsentCount(window)).toBe(0)
248+
expect(await totalCount(window)).toBe(0)
249249
})
250250

251-
it('marks the recipient sent without emailing when every task in the window was archived', async () => {
251+
it('deletes rows without emailing when every task in the window was archived', async () => {
252252
const window = 'win_all_archived'
253253
const archivedTask = await seedTask({
254254
workspaceId: WS,
@@ -263,10 +263,10 @@ describe('flush-grouped-email idempotency (real DB)', () => {
263263

264264
expect(mockCreateNotification).not.toHaveBeenCalled()
265265
expect(result).toMatchObject({ sent: 0 })
266-
expect(await unsentCount(window)).toBe(0)
266+
expect(await totalCount(window)).toBe(0)
267267
})
268268

269-
it('marks the recipient sent without emailing when every task in the window was soft-deleted', async () => {
269+
it('deletes rows without emailing when every task in the window was soft-deleted', async () => {
270270
const window = 'win_all_deleted'
271271
const deletedTask = await seedTask({
272272
workspaceId: WS,
@@ -281,6 +281,6 @@ describe('flush-grouped-email idempotency (real DB)', () => {
281281

282282
expect(mockCreateNotification).not.toHaveBeenCalled()
283283
expect(result).toMatchObject({ sent: 0 })
284-
expect(await unsentCount(window)).toBe(0)
284+
expect(await totalCount(window)).toBe(0)
285285
})
286286
})

src/jobs/notifications/flush-grouped-email.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ describe('flushGroupedEmailRun', () => {
103103
expect(args).toMatchObject({ senderId: 'iu_1', recipientClientId: 'client_1', recipientCompanyId: 'company_1' })
104104
expect(args.content.totalEventCount).toBe(2)
105105
expect(mockCreateNotification).not.toHaveBeenCalled()
106-
expect(mockExecuteRaw).toHaveBeenCalledTimes(1) // markRecipientSent
106+
expect(mockExecuteRaw).toHaveBeenCalledTimes(2) // markRecipientSent + deleteWindowRows
107107
expect(result).toMatchObject({ recipients: 1, sent: 1, sentGrouped: 1, sentIndividual: 0 })
108108
})
109109

@@ -116,7 +116,7 @@ describe('flushGroupedEmailRun', () => {
116116
expect(mockCreateNotification).toHaveBeenCalledWith(expect.objectContaining({ recipientClientId: 'client_1' }))
117117
expect(mockSendGroupedEmail).not.toHaveBeenCalled()
118118
expect(mockGetInternalUsers).not.toHaveBeenCalled() // no workspace IU needed for the individual path
119-
expect(mockExecuteRaw).toHaveBeenCalledTimes(1)
119+
expect(mockExecuteRaw).toHaveBeenCalledTimes(2) // markRecipientSent + deleteWindowRows
120120
expect(result).toMatchObject({ recipients: 1, sent: 1, sentGrouped: 0, sentIndividual: 1 })
121121
})
122122

@@ -166,7 +166,7 @@ describe('flushGroupedEmailRun', () => {
166166

167167
expect(mockSendGroupedEmail).not.toHaveBeenCalled()
168168
expect(mockCreateNotification).not.toHaveBeenCalled()
169-
expect(mockExecuteRaw).toHaveBeenCalledTimes(1)
169+
expect(mockExecuteRaw).toHaveBeenCalledTimes(2) // markRecipientSent + deleteWindowRows
170170
expect(result).toMatchObject({ sent: 0, sentGrouped: 0, sentIndividual: 0 })
171171
})
172172

@@ -177,7 +177,7 @@ describe('flushGroupedEmailRun', () => {
177177

178178
expect(mockCreateNotification).toHaveBeenCalledTimes(2)
179179
expect(mockSendGroupedEmail).not.toHaveBeenCalled()
180-
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
180+
expect(mockExecuteRaw).toHaveBeenCalledTimes(3) // markRecipientSent x2 + deleteWindowRows
181181
expect(result).toMatchObject({ recipients: 2, sent: 2, sentGrouped: 0, sentIndividual: 2 })
182182
})
183183

@@ -191,7 +191,7 @@ describe('flushGroupedEmailRun', () => {
191191

192192
expect(mockCreateNotification).toHaveBeenCalledTimes(2)
193193
expect(mockCreateNotification.mock.calls[1][0]).toMatchObject({ senderCompanyId: undefined })
194-
expect(mockExecuteRaw).toHaveBeenCalledTimes(1)
194+
expect(mockExecuteRaw).toHaveBeenCalledTimes(2) // markRecipientSent + deleteWindowRows
195195
})
196196

197197
it('does not mark a recipient sent when their send fails (so a retry re-sends)', async () => {

src/jobs/notifications/flush-grouped-email.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ const readUnsentWindowEvents = (db: ReturnType<typeof DBClient.getInstance>, win
4040
FROM "GroupedEmailEvents"
4141
WHERE "windowKey" = ${windowKey} AND "sentAt" IS NULL`
4242

43+
const deleteWindowRows = (db: ReturnType<typeof DBClient.getInstance>, windowKey: string) =>
44+
db.$executeRaw`DELETE FROM "GroupedEmailEvents" WHERE "windowKey" = ${windowKey} AND "sentAt" IS NOT NULL`
45+
4346
const markRecipientSent = (
4447
db: ReturnType<typeof DBClient.getInstance>,
4548
windowKey: string,
@@ -169,6 +172,16 @@ export const flushGroupedEmailRun = async (payload: FlushGroupedEmailPayload) =>
169172
})
170173
}
171174

175+
try {
176+
await deleteWindowRows(db, windowKey)
177+
} catch (err) {
178+
logger.error('flush-grouped-email: window cleanup failed, rows left with sentAt set', {
179+
workspaceId,
180+
windowKey,
181+
error: serializeError(err),
182+
})
183+
}
184+
172185
logger.log('flush-grouped-email: run summary', {
173186
workspaceId,
174187
windowKey,

src/jobs/notifications/send-reminder-email.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { CopilotAPI } from '@/utils/CopilotAPI'
33
import { TaskReminderType } from '@prisma/client'
44
import { sendReminderEmail } from './send-reminder-email'
55

6+
jest.mock('@/config', () => ({ reminderSubjectOverrideWorkspaces: new Set(['ws_override']) }))
7+
68
const workspace: WorkspaceResponse = {
79
id: 'ws_1',
810
brandName: 'Acme',
@@ -115,4 +117,41 @@ describe('sendReminderEmail', () => {
115117
}),
116118
).rejects.toThrow('copilot 5xx')
117119
})
120+
121+
it('uses the task title with the escalating tag as subject for override workspaces', async () => {
122+
const createNotification = jest.fn().mockResolvedValue({ id: 'notif_4', createdAt: '2026-05-25T00:00:00Z' })
123+
124+
await sendReminderEmail({
125+
task,
126+
recipientClientId: 'client_1',
127+
recipientCompanyId: 'company_1',
128+
reminderType: TaskReminderType.DUE_DATE_OVERDUE_3D,
129+
isCompanyRecipient: false,
130+
workspace: { ...workspace, id: 'ws_override' },
131+
copilot: buildCopilotMock(createNotification),
132+
})
133+
134+
const payload = createNotification.mock.calls[0][0]
135+
expect(payload.deliveryTargets.email.subject).toBe('[Overdue] Submit timesheet')
136+
// Only the subject is customized — the rest of the reminder copy is unchanged.
137+
expect(payload.deliveryTargets.email.header).toBe('A task was assigned to you')
138+
expect(payload.deliveryTargets.email.title).toBe('View task')
139+
})
140+
141+
it('keeps the generic subject for workspaces not in the override set', async () => {
142+
const createNotification = jest.fn().mockResolvedValue({ id: 'notif_5', createdAt: '2026-05-25T00:00:00Z' })
143+
144+
await sendReminderEmail({
145+
task,
146+
recipientClientId: 'client_1',
147+
recipientCompanyId: 'company_1',
148+
reminderType: TaskReminderType.DUE_DATE_OVERDUE_3D,
149+
isCompanyRecipient: false,
150+
workspace,
151+
copilot: buildCopilotMock(createNotification),
152+
})
153+
154+
const payload = createNotification.mock.calls[0][0]
155+
expect(payload.deliveryTargets.email.subject).toBe('[Overdue] Task was due 3 days ago')
156+
})
118157
})

src/jobs/notifications/send-reminder-email.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'server-only'
22

3-
import { getReminderEmailDetails } from '@/app/api/notification/notification.helpers'
3+
import { getReminderEmailDetails, REMINDER_ESCALATION_TAG } from '@/app/api/notification/notification.helpers'
4+
import { reminderSubjectOverrideWorkspaces } from '@/config'
45
import { NotificationRequestBody, WorkspaceResponse } from '@/types/common'
56
import { CopilotAPI } from '@/utils/CopilotAPI'
67
import { Task, TaskReminderType } from '@prisma/client'
@@ -28,14 +29,20 @@ export const sendReminderEmail = async ({
2829
}: SendReminderEmailArgs): Promise<string> => {
2930
const details = getReminderEmailDetails(workspace, task, isCompanyRecipient)[reminderType]
3031

32+
// For opted-in workspaces, mirror the customized assignment email by using the task title as the
33+
// subject, prefixed with the escalating cadence tag (OUT-3861).
34+
const subject = reminderSubjectOverrideWorkspaces.has(workspace.id)
35+
? `${REMINDER_ESCALATION_TAG[reminderType]} ${task.title}`
36+
: details.subject
37+
3138
const payload: NotificationRequestBody = {
3239
senderId: task.createdById,
3340
senderType: 'internalUser',
3441
recipientClientId,
3542
recipientCompanyId: recipientCompanyId ?? undefined,
3643
deliveryTargets: {
3744
email: {
38-
subject: details.subject,
45+
subject,
3946
header: details.header,
4047
title: details.title,
4148
body: details.body,

0 commit comments

Comments
 (0)