Skip to content

Commit 005a42f

Browse files
authored
Merge pull request #1372 from assemblycom/OUT-3928-send-task-marked-as-done-emails-to-ius
OUT-3928 | Send task-marked-as-done email notifications to IUs
2 parents 85ddb6f + 1734717 commit 005a42f

8 files changed

Lines changed: 270 additions & 57 deletions

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

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NotificationTaskActions } from '@api/core/types/tasks'
22
import { WorkspaceResponse } from '@/types/common'
33
import { getEmailDetails, getReminderEmailDetails } from './notification.helpers'
4-
import { TaskReminderType } from '@prisma/client'
4+
import { Task, TaskReminderType } from '@prisma/client'
55

66
const workspace: WorkspaceResponse = {
77
id: 'ws_1',
@@ -81,13 +81,30 @@ describe('getReminderEmailDetails', () => {
8181
describe('getEmailDetails', () => {
8282
// Actions that email an IU recipient must have a template here, or the grouped
8383
// buffer silently skips them (in-product fires but no email is ever flushed).
84-
it.each([NotificationTaskActions.Assigned, NotificationTaskActions.ReassignedToIU])(
85-
'defines an email template for IU-recipient action %s',
86-
(action) => {
87-
const details = getEmailDetails(workspace, 'Arpan Two')[action]
88-
expect(details).toBeDefined()
89-
expect(details?.subject).toBeTruthy()
90-
expect(details?.body).toBeTruthy()
91-
},
92-
)
84+
it.each([
85+
NotificationTaskActions.Assigned,
86+
NotificationTaskActions.ReassignedToIU,
87+
NotificationTaskActions.Completed,
88+
NotificationTaskActions.CompletedByIU,
89+
NotificationTaskActions.CompletedByCompanyMember,
90+
NotificationTaskActions.CompletedForCompanyByIU,
91+
])('defines an email template for IU-recipient action %s', (action) => {
92+
const details = getEmailDetails(workspace, 'Arpan Two')[action]
93+
expect(details).toBeDefined()
94+
expect(details?.subject).toBeTruthy()
95+
expect(details?.body).toBeTruthy()
96+
})
97+
98+
it.each([
99+
NotificationTaskActions.Completed,
100+
NotificationTaskActions.CompletedByIU,
101+
NotificationTaskActions.CompletedByCompanyMember,
102+
NotificationTaskActions.CompletedForCompanyByIU,
103+
])('uses the task-marked-as-done copy for completion action %s', (action) => {
104+
const details = getEmailDetails(workspace, 'Casey Client', task as unknown as Task)[action]
105+
expect(details?.subject).toBe('Task marked as done')
106+
expect(details?.header).toBe('A task has been completed')
107+
expect(details?.body).toContain('has been marked as done by Casey Client')
108+
expect(details?.title).toBe('View task')
109+
})
93110
})

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,14 @@ export const getEmailDetails = (
166166
}
167167
: undefined
168168

169+
const completedDetail = {
170+
subject: 'Task marked as done',
171+
header: 'A task has been completed',
172+
title: 'View task',
173+
body: `The task ‘${task?.title}’ has been marked as done by ${actionUser}.\n\nTo see details about the task, open it below.`,
174+
ctaParams,
175+
}
176+
169177
return {
170178
[NotificationTaskActions.Assigned]: {
171179
subject: 'A task was assigned to you',
@@ -181,13 +189,10 @@ export const getEmailDetails = (
181189
title: 'View task',
182190
ctaParams,
183191
},
184-
//! Currently disable all IU email notifications
185-
// [NotificationTaskActions.Completed]: {
186-
// title: 'A client completed a task',
187-
// subject: 'A client completed a task',
188-
// header: 'A client completed a task',
189-
// body: `A new task was completed by ${actionUser}. You are receiving this notification because you have access to the client.`,
190-
// },
192+
[NotificationTaskActions.Completed]: completedDetail,
193+
[NotificationTaskActions.CompletedByIU]: completedDetail,
194+
[NotificationTaskActions.CompletedByCompanyMember]: completedDetail,
195+
[NotificationTaskActions.CompletedForCompanyByIU]: completedDetail,
191196
[NotificationTaskActions.Commented]: {
192197
subject: 'Comment was added',
193198
header: 'Comment was added',

src/app/api/notification/notification.service.test.ts

Lines changed: 100 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,13 @@ describe('guard: CU wiring boundaries', () => {
292292
expect(svc.groupedEventTypeFor(NotificationTaskActions.Shared)).toBe(GroupedEmailEventType.SHARED)
293293
expect(svc.groupedEventTypeFor(NotificationTaskActions.SharedToCompany)).toBe(GroupedEmailEventType.SHARED)
294294
expect(svc.groupedEventTypeFor(NotificationTaskActions.Commented)).toBe(GroupedEmailEventType.COMMENT)
295+
expect(svc.groupedEventTypeFor(NotificationTaskActions.Completed)).toBe(GroupedEmailEventType.COMPLETED)
296+
expect(svc.groupedEventTypeFor(NotificationTaskActions.CompletedByIU)).toBe(GroupedEmailEventType.COMPLETED)
297+
expect(svc.groupedEventTypeFor(NotificationTaskActions.CompletedByCompanyMember)).toBe(GroupedEmailEventType.COMPLETED)
298+
expect(svc.groupedEventTypeFor(NotificationTaskActions.CompletedForCompanyByIU)).toBe(GroupedEmailEventType.COMPLETED)
295299
})
296300

297-
it('returns null for every action that must not be buffered', () => {
301+
it('returns null for every action that must not be buffered (incl. shared-CU completion emails)', () => {
298302
const svc = buildService() as unknown as {
299303
groupedEventTypeFor: (a: NotificationTaskActions) => GroupedEmailEventType | null
300304
}
@@ -305,23 +309,17 @@ describe('guard: CU wiring boundaries', () => {
305309
NotificationTaskActions.Shared,
306310
NotificationTaskActions.SharedToCompany,
307311
NotificationTaskActions.Commented,
312+
NotificationTaskActions.Completed,
313+
NotificationTaskActions.CompletedByIU,
314+
NotificationTaskActions.CompletedByCompanyMember,
315+
NotificationTaskActions.CompletedForCompanyByIU,
308316
]
309317
const unmapped = Object.values(NotificationTaskActions).filter((a) => !mapped.includes(a))
310318
for (const action of unmapped) {
311319
expect(svc.groupedEventTypeFor(action)).toBeNull()
312320
}
313321
})
314322

315-
it('never returns COMPLETED — that type is reserved for the deferred IU milestone', () => {
316-
const svc = buildService() as unknown as {
317-
groupedEventTypeFor: (a: NotificationTaskActions) => GroupedEmailEventType | null
318-
}
319-
const allActions = Object.values(NotificationTaskActions)
320-
for (const action of allActions) {
321-
expect(svc.groupedEventTypeFor(action)).not.toBe(GroupedEmailEventType.COMPLETED)
322-
}
323-
})
324-
325323
it('never writes recipientIuId in a CU grouped event row', async () => {
326324
await buildService().create(NotificationTaskActions.Assigned, makeTask())
327325
const row = mockGroupedCreateMany.mock.calls[0][0].data[0]
@@ -372,3 +370,94 @@ describe('guard: IU wiring boundaries', () => {
372370
expect(mockEnqueueFlush).not.toHaveBeenCalled()
373371
})
374372
})
373+
374+
describe('guard: IU completion emails', () => {
375+
// Every completion action routes to an IU; create() must buffer them as IU rows regardless of
376+
// which one is passed, so the guard stays consistent with groupedEventTypeFor.
377+
it.each([
378+
NotificationTaskActions.CompletedByIU,
379+
NotificationTaskActions.CompletedForCompanyByIU,
380+
NotificationTaskActions.Completed,
381+
NotificationTaskActions.CompletedByCompanyMember,
382+
])('buffers a %s email as a COMPLETED IU event and keeps the in-product notification immediate', async (action) => {
383+
await buildService().create(action, makeTask({ assigneeType: AssigneeType.internalUser, clientId: null }))
384+
385+
expect(mockGroupedCreateMany).toHaveBeenCalledTimes(1)
386+
const row = mockGroupedCreateMany.mock.calls[0][0].data[0]
387+
expect(row).toMatchObject({
388+
recipientIuId: '33333333-3333-3333-3333-333333333333',
389+
recipientClientId: null,
390+
recipientCompanyId: null,
391+
eventType: GroupedEmailEventType.COMPLETED,
392+
})
393+
expect(row.individualEmail.recipientInternalUserId).toBe('33333333-3333-3333-3333-333333333333')
394+
395+
expect(mockCreateNotification).toHaveBeenCalledTimes(1)
396+
const sent = mockCreateNotification.mock.calls[0][0]
397+
expect(sent.recipientInternalUserId).toBe('33333333-3333-3333-3333-333333333333')
398+
expect(sent.recipientClientId).toBeUndefined()
399+
expect(deliveryTargetsOf(0).inProduct).toBeDefined()
400+
expect(deliveryTargetsOf(0).email).toBeUndefined()
401+
})
402+
403+
it('does not buffer and still routes the in-product CompletedByIU notification to the IU when email is disabled', async () => {
404+
const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
405+
await buildService().create(NotificationTaskActions.CompletedByIU, task, { disableEmail: true })
406+
407+
expect(mockGroupedCreateMany).not.toHaveBeenCalled()
408+
const sent = mockCreateNotification.mock.calls[0][0]
409+
expect(sent.recipientInternalUserId).toBe('33333333-3333-3333-3333-333333333333')
410+
expect(sent.recipientClientId).toBeUndefined()
411+
expect(deliveryTargetsOf(0).email).toBeUndefined()
412+
})
413+
414+
it('is not blocked by the client-notification dedup guard on a client-assigned task', async () => {
415+
// A client-assigned task still has an unread ClientNotification from its assignment when the
416+
// IU completes it; the CompletedByIU recipient is the creator IU, so the guard must not fire.
417+
mockFindFirst.mockResolvedValue({ id: 'existing-client-notif' })
418+
const task = makeTask({ assigneeType: AssigneeType.client, clientId: '33333333-3333-3333-3333-333333333333' })
419+
420+
await buildService().create(NotificationTaskActions.CompletedByIU, task, { disableEmail: false })
421+
422+
// guard skipped: the completion email buffers and the IU notification dispatches
423+
expect(mockFindFirst).not.toHaveBeenCalled()
424+
expect(mockGroupedCreateMany).toHaveBeenCalledTimes(1)
425+
expect(mockGroupedCreateMany.mock.calls[0][0].data[0].recipientIuId).toBe('33333333-3333-3333-3333-333333333333')
426+
expect(mockCreateNotification).toHaveBeenCalledTimes(1)
427+
expect(mockCreateNotification.mock.calls[0][0].recipientInternalUserId).toBe('33333333-3333-3333-3333-333333333333')
428+
})
429+
430+
it('bulk Completed buffers one COMPLETED IU row per recipient and strips the email from dispatch', async () => {
431+
await buildService().createBulkNotification(NotificationTaskActions.Completed, makeTask(), ['iu_a', 'iu_b'], {
432+
email: true,
433+
})
434+
435+
expect(mockGroupedCreateMany).toHaveBeenCalledTimes(2)
436+
const rows = mockGroupedCreateMany.mock.calls.map((c) => c[0].data[0])
437+
expect(rows.map((r) => r.recipientIuId)).toEqual(['iu_a', 'iu_b'])
438+
for (const row of rows) {
439+
expect(row.eventType).toBe(GroupedEmailEventType.COMPLETED)
440+
expect(row.recipientClientId).toBeNull()
441+
expect(row.individualEmail.recipientInternalUserId).toBeDefined()
442+
}
443+
444+
expect(mockCreateNotification).toHaveBeenCalledTimes(2)
445+
const sent = mockCreateNotification.mock.calls.map((c) => c[0])
446+
expect(sent.map((s) => s.recipientInternalUserId)).toEqual(['iu_a', 'iu_b'])
447+
for (const s of sent) {
448+
expect(s.recipientClientId).toBeUndefined()
449+
expect(s.deliveryTargets.email).toBeUndefined()
450+
}
451+
})
452+
453+
it('bulk CompletedByCompanyMember neither buffers nor emails when the flag is off (email opt falsy)', async () => {
454+
await buildService().createBulkNotification(NotificationTaskActions.CompletedByCompanyMember, makeTask(), ['iu_a'], {
455+
email: false,
456+
})
457+
458+
expect(mockGroupedCreateMany).not.toHaveBeenCalled()
459+
expect(mockCreateNotification).toHaveBeenCalledTimes(1)
460+
expect(deliveryTargetsOf(0).email).toBeUndefined()
461+
expect(mockCreateNotification.mock.calls[0][0].recipientInternalUserId).toBe('iu_a')
462+
})
463+
})

src/app/api/notification/notification.service.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,22 @@ export class NotificationService extends BaseService {
3636
} = { disableEmail: false },
3737
) {
3838
try {
39-
// 1.Check for existing notification. Skip if duplicate
40-
const existingNotification = task.clientId
41-
? await this.db.clientNotification.findFirst({
42-
where: { taskId: task.id, clientId: task.clientId, companyId: task.companyId },
43-
})
44-
: null
45-
if (task.clientId && existingNotification && !opts.commentId) {
39+
const isAssignedToIu =
40+
task.assigneeType === AssigneeType.internalUser &&
41+
(action === NotificationTaskActions.Assigned || action === NotificationTaskActions.ReassignedToIU)
42+
// Completion notifications always go to IUs (task creator, or IUs with access)
43+
const isRecipientIu = isAssignedToIu || this.isCompletionAction(action)
44+
45+
// 1. Check for existing notification. Skip if duplicate. This dedup is keyed on the client
46+
// assignee, so it must not gate IU-recipient notifications (e.g. CompletedByIU on a
47+
// client-assigned task, whose recipient is the creator IU, not the client).
48+
const existingNotification =
49+
task.clientId && !isRecipientIu
50+
? await this.db.clientNotification.findFirst({
51+
where: { taskId: task.id, clientId: task.clientId, companyId: task.companyId },
52+
})
53+
: null
54+
if (existingNotification && !opts.commentId) {
4655
console.error(`NotificationService#create | Found existing notification for ${task.clientId}`, existingNotification)
4756
return
4857
}
@@ -55,10 +64,6 @@ export class NotificationService extends BaseService {
5564
action,
5665
)
5766

58-
const isAssignedToIu =
59-
task.assigneeType === AssigneeType.internalUser &&
60-
(action === NotificationTaskActions.Assigned || action === NotificationTaskActions.ReassignedToIU)
61-
6267
const inProduct = opts.disableInProduct
6368
? undefined
6469
: getInProductNotificationDetails(workspace, actionUser, task, { companyName, commentId: opts?.commentId })[action]
@@ -74,7 +79,7 @@ export class NotificationService extends BaseService {
7479
task,
7580
recipientId,
7681
companyId: task.companyId ?? association?.companyId ?? undefined,
77-
isRecipientIu: isAssignedToIu,
82+
isRecipientIu,
7883
eventType: groupedType,
7984
commentId: opts.commentId,
8085
individualEmail: this.buildNotificationDetails(
@@ -83,7 +88,7 @@ export class NotificationService extends BaseService {
8388
recipientId,
8489
{ email },
8590
senderCompanyId,
86-
isAssignedToIu,
91+
isRecipientIu,
8792
),
8893
})
8994
}
@@ -96,7 +101,7 @@ export class NotificationService extends BaseService {
96101
recipientId,
97102
{ inProduct, email },
98103
senderCompanyId,
99-
isAssignedToIu,
104+
isRecipientIu,
100105
)
101106
if (groupedType) notificationDetails.deliveryTargets = { inProduct }
102107
if (!inProduct && !notificationDetails.deliveryTargets?.email) return
@@ -188,6 +193,12 @@ export class NotificationService extends BaseService {
188193
const association = AssociationsSchema.parse(task.associations)?.[0]
189194
// Non-null only when these CU emails should be diverted into the grouped buffer.
190195
const groupedType = email ? this.groupedEventTypeFor(action) : null
196+
// Completion recipients are IUs; undefined (not false) elsewhere so paths like the
197+
// Commented-to-IU job keep the absence-of-email inference in buildNotificationDetails
198+
const isRecipientIu =
199+
action === NotificationTaskActions.Completed || action === NotificationTaskActions.CompletedByCompanyMember
200+
? true
201+
: undefined
191202

192203
// NOTE: The reason we are skipping using NotificationService#create and implementing notification dispatch + save manually is because
193204
// we can just do one `createMany` DB call instead of one per notification, saving a ton of DB calls
@@ -208,9 +219,17 @@ export class NotificationService extends BaseService {
208219
task,
209220
recipientId,
210221
companyId: task.companyId ?? association?.companyId ?? undefined,
222+
isRecipientIu,
211223
eventType: groupedType,
212224
commentId: opts?.commentId,
213-
individualEmail: this.buildNotificationDetails(task, senderId, recipientId, { email }, opts?.senderCompanyId),
225+
individualEmail: this.buildNotificationDetails(
226+
task,
227+
senderId,
228+
recipientId,
229+
{ email },
230+
opts?.senderCompanyId,
231+
isRecipientIu,
232+
),
214233
})
215234
if (!inProduct) continue
216235
}
@@ -223,6 +242,7 @@ export class NotificationService extends BaseService {
223242
recipientId,
224243
{ inProduct, email },
225244
opts?.senderCompanyId,
245+
isRecipientIu,
226246
)
227247
if (groupedType) notificationDetails.deliveryTargets = { inProduct }
228248

@@ -593,7 +613,17 @@ export class NotificationService extends BaseService {
593613
})
594614
}
595615

616+
private isCompletionAction(action: NotificationTaskActions): boolean {
617+
return (
618+
action === NotificationTaskActions.Completed ||
619+
action === NotificationTaskActions.CompletedByIU ||
620+
action === NotificationTaskActions.CompletedByCompanyMember ||
621+
action === NotificationTaskActions.CompletedForCompanyByIU
622+
)
623+
}
624+
596625
private groupedEventTypeFor(action: NotificationTaskActions): GroupedEmailEventType | null {
626+
if (this.isCompletionAction(action)) return GroupedEmailEventType.COMPLETED
597627
switch (action) {
598628
case NotificationTaskActions.Assigned:
599629
case NotificationTaskActions.AssignedToCompany:

src/app/api/tasks/task-notifications.service.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,12 +289,14 @@ export class TaskNotificationsService extends BaseService {
289289
// Don't do this in parallel since this can cause rate-limits, each of them has their own bottlenecks for avoiding ratelimits
290290
shouldCreateNotification &&
291291
(await this.notificationService.create(NotificationTaskActions.CompletedForCompanyByIU, updatedTask, {
292-
disableEmail: true,
292+
disableEmail: !isIuEmailEnabled(),
293293
}))
294294
await this.notificationService.markAsReadForAllRecipients(updatedTask)
295295
} else if (updatedTask.assigneeType === AssigneeType.client) {
296296
shouldCreateNotification &&
297-
(await this.notificationService.create(NotificationTaskActions.CompletedByIU, updatedTask, { disableEmail: true }))
297+
(await this.notificationService.create(NotificationTaskActions.CompletedByIU, updatedTask, {
298+
disableEmail: !isIuEmailEnabled(),
299+
}))
298300
try {
299301
await this.notificationService.markClientNotificationAsRead(updatedTask)
300302
return
@@ -303,7 +305,9 @@ export class TaskNotificationsService extends BaseService {
303305
}
304306
} else if (updatedTask.assigneeType === AssigneeType.internalUser) {
305307
shouldCreateNotification &&
306-
(await this.notificationService.create(NotificationTaskActions.CompletedByIU, updatedTask, { disableEmail: true }))
308+
(await this.notificationService.create(NotificationTaskActions.CompletedByIU, updatedTask, {
309+
disableEmail: !isIuEmailEnabled(),
310+
}))
307311
}
308312
}
309313

@@ -368,7 +372,7 @@ export class TaskNotificationsService extends BaseService {
368372
NotificationTaskActions.CompletedByCompanyMember,
369373
updatedTask,
370374
recipientIds,
371-
{ senderCompanyId },
375+
{ senderCompanyId, email: isIuEmailEnabled() },
372376
)
373377
await this.notificationService.markAsReadForAllRecipients(updatedTask)
374378
} else {
@@ -379,6 +383,7 @@ export class TaskNotificationsService extends BaseService {
379383
)
380384
await this.notificationService.createBulkNotification(NotificationTaskActions.Completed, updatedTask, recipientIds, {
381385
senderCompanyId,
386+
email: isIuEmailEnabled(),
382387
})
383388
await this.notificationService.markClientNotificationAsRead(updatedTask)
384389
}

0 commit comments

Comments
 (0)