diff --git a/src/app/api/notification/notification.helpers.test.ts b/src/app/api/notification/notification.helpers.test.ts
index 60863d08d..6149c7904 100644
--- a/src/app/api/notification/notification.helpers.test.ts
+++ b/src/app/api/notification/notification.helpers.test.ts
@@ -1,6 +1,7 @@
+import { NotificationTaskActions } from '@api/core/types/tasks'
import { WorkspaceResponse } from '@/types/common'
-import { getReminderEmailDetails } from './notification.helpers'
-import { TaskReminderType } from '@prisma/client'
+import { getEmailDetails, getReminderEmailDetails } from './notification.helpers'
+import { Task, TaskReminderType } from '@prisma/client'
const workspace: WorkspaceResponse = {
id: 'ws_1',
@@ -76,3 +77,34 @@ describe('getReminderEmailDetails', () => {
expect(htmlBody).not.toContain('
{
+ // Actions that email an IU recipient must have a template here, or the grouped
+ // buffer silently skips them (in-product fires but no email is ever flushed).
+ it.each([
+ NotificationTaskActions.Assigned,
+ NotificationTaskActions.ReassignedToIU,
+ NotificationTaskActions.Completed,
+ NotificationTaskActions.CompletedByIU,
+ NotificationTaskActions.CompletedByCompanyMember,
+ NotificationTaskActions.CompletedForCompanyByIU,
+ ])('defines an email template for IU-recipient action %s', (action) => {
+ const details = getEmailDetails(workspace, 'Arpan Two')[action]
+ expect(details).toBeDefined()
+ expect(details?.subject).toBeTruthy()
+ expect(details?.body).toBeTruthy()
+ })
+
+ it.each([
+ NotificationTaskActions.Completed,
+ NotificationTaskActions.CompletedByIU,
+ NotificationTaskActions.CompletedByCompanyMember,
+ NotificationTaskActions.CompletedForCompanyByIU,
+ ])('uses the task-marked-as-done copy for completion action %s', (action) => {
+ const details = getEmailDetails(workspace, 'Casey Client', task as unknown as Task)[action]
+ expect(details?.subject).toBe('Task marked as done')
+ expect(details?.header).toBe('A task has been completed')
+ expect(details?.body).toContain('has been marked as done by Casey Client')
+ expect(details?.title).toBe('View task')
+ })
+})
diff --git a/src/app/api/notification/notification.helpers.ts b/src/app/api/notification/notification.helpers.ts
index 505afc770..0bc39546a 100644
--- a/src/app/api/notification/notification.helpers.ts
+++ b/src/app/api/notification/notification.helpers.ts
@@ -166,6 +166,14 @@ export const getEmailDetails = (
}
: undefined
+ const completedDetail = {
+ subject: 'Task marked as done',
+ header: 'A task has been completed',
+ title: 'View task',
+ body: `The task ‘${task?.title}’ has been marked as done by ${actionUser}.\n\nTo see details about the task, open it below.`,
+ ctaParams,
+ }
+
return {
[NotificationTaskActions.Assigned]: {
subject: 'A task was assigned to you',
@@ -181,13 +189,10 @@ export const getEmailDetails = (
title: 'View task',
ctaParams,
},
- //! Currently disable all IU email notifications
- // [NotificationTaskActions.Completed]: {
- // title: 'A client completed a task',
- // subject: 'A client completed a task',
- // header: 'A client completed a task',
- // body: `A new task was completed by ${actionUser}. You are receiving this notification because you have access to the client.`,
- // },
+ [NotificationTaskActions.Completed]: completedDetail,
+ [NotificationTaskActions.CompletedByIU]: completedDetail,
+ [NotificationTaskActions.CompletedByCompanyMember]: completedDetail,
+ [NotificationTaskActions.CompletedForCompanyByIU]: completedDetail,
[NotificationTaskActions.Commented]: {
subject: 'Comment was added',
header: 'Comment was added',
@@ -202,6 +207,13 @@ export const getEmailDetails = (
title: 'View task',
ctaParams,
},
+ [NotificationTaskActions.ReassignedToIU]: {
+ subject: 'A task was reassigned to you',
+ header: 'A task was reassigned to you',
+ title: 'View task',
+ body: `The task ‘${task?.title}’ was reassigned to you by ${actionUser}. To see details about the task open it below.`,
+ ctaParams,
+ },
[NotificationTaskActions.ReassignedToClient]: {
subject: 'A task was reassigned to you',
header: 'A task was reassigned to you',
diff --git a/src/app/api/notification/notification.service.test.ts b/src/app/api/notification/notification.service.test.ts
index ce7c59d75..acda3543b 100644
--- a/src/app/api/notification/notification.service.test.ts
+++ b/src/app/api/notification/notification.service.test.ts
@@ -8,12 +8,14 @@ const mockFindFirst = jest.fn()
const mockFindMany = jest.fn()
const mockClientNotifCreate = jest.fn()
const mockClientNotifCreateMany = jest.fn()
+const mockInternalUserNotifCreate = jest.fn()
const mockQueryRaw = jest.fn()
const mockGroupedCreateMany = jest.fn()
const mockGetWorkspace = jest.fn()
const mockMe = jest.fn()
const mockCreateNotification = jest.fn()
+const mockGetNotificationSettings = jest.fn()
jest.mock('@/jobs/notifications/flush-grouped-email', () => ({
enqueueGroupedEmailFlush: (...args: unknown[]) => mockEnqueueFlush(...args),
@@ -30,6 +32,7 @@ jest.mock('@/lib/db', () => ({
createMany: (...args: unknown[]) => mockClientNotifCreateMany(...args),
},
groupedEmailEvent: { createMany: (...args: unknown[]) => mockGroupedCreateMany(...args) },
+ internalUserNotification: { create: (...args: unknown[]) => mockInternalUserNotifCreate(...args) },
$queryRaw: (...args: unknown[]) => mockQueryRaw(...args),
}),
},
@@ -40,10 +43,12 @@ jest.mock('@/utils/CopilotAPI', () => ({
getWorkspace: (...args: unknown[]) => mockGetWorkspace(...args),
me: (...args: unknown[]) => mockMe(...args),
createNotification: (...args: unknown[]) => mockCreateNotification(...args),
+ getNotificationSettings: (...args: unknown[]) => mockGetNotificationSettings(...args),
})),
}))
import { NotificationService } from './notification.service'
+import { __clearNotificationSettingCache } from './resolveNotificationSettingId'
const user = {
token: 'tok',
@@ -82,6 +87,16 @@ const buildService = () => {
beforeEach(() => {
jest.clearAllMocks()
+ __clearNotificationSettingCache()
+ // Default: all IU categories declared with the email surface enabled, so IU emails buffer and
+ // carry the resolved setting id. Individual cases override to exercise the email-surface gate.
+ mockGetNotificationSettings.mockResolvedValue({
+ notifications: [
+ { id: 'setting_assigned', label: 'New task assigned', surfaces: ['product', 'email'] },
+ { id: 'setting_comment', label: 'New comment on a task', surfaces: ['product', 'email'] },
+ { id: 'setting_completed', label: 'Task completed', surfaces: ['product', 'email'] },
+ ],
+ })
mockGetWorkspace.mockResolvedValue({ labels: {} })
mockMe.mockResolvedValue({ id: 'creator_1', givenName: 'Jane', familyName: 'IU' })
mockFindFirst.mockResolvedValue(null)
@@ -90,6 +105,7 @@ beforeEach(() => {
mockGroupedCreateMany.mockResolvedValue({ count: 1 })
mockClientNotifCreate.mockResolvedValue({})
mockClientNotifCreateMany.mockResolvedValue({ count: 1 })
+ mockInternalUserNotifCreate.mockResolvedValue({})
mockCreateNotification.mockResolvedValue({
id: 'notif_1',
createdAt: '2026-06-15T10:00:00.000Z',
@@ -98,6 +114,8 @@ beforeEach(() => {
})
const deliveryTargetsOf = (call: number) => mockCreateNotification.mock.calls[call][0].deliveryTargets
+const queryRawParamsOf = (call: number) =>
+ mockQueryRaw.mock.calls[call].flatMap((arg: { values?: unknown[] }) => arg?.values ?? arg)
describe('NotificationService grouped-email interception', () => {
describe('create()', () => {
@@ -118,7 +136,7 @@ describe('NotificationService grouped-email interception', () => {
})
// window is scoped to the (clientId, companyId) pair, not the client alone
expect(row.windowKey).toMatch(new RegExp(`^${task.assigneeId}:${task.companyId}:`))
- expect(mockQueryRaw.mock.calls[0]).toContain(task.companyId)
+ expect(queryRawParamsOf(0)).toContain(task.companyId)
expect(mockEnqueueFlush).toHaveBeenCalledWith({ workspaceId: 'ws_1', windowKey: row.windowKey })
// the row snapshots the exact individual email to replay for a single-event window
@@ -153,7 +171,7 @@ describe('NotificationService grouped-email interception', () => {
const row = mockGroupedCreateMany.mock.calls[0][0].data[0]
expect(row.recipientCompanyId).toBe(companyB)
expect(row.windowKey).toMatch(new RegExp(`^33333333-3333-3333-3333-333333333333:${companyB}:`))
- expect(mockQueryRaw.mock.calls[0]).toContain(companyB)
+ expect(queryRawParamsOf(0)).toContain(companyB)
})
it('does not buffer or strip email for a non-target action (byte-for-byte)', async () => {
@@ -223,6 +241,7 @@ describe('NotificationService grouped-email interception', () => {
email: true,
disableInProduct: true,
commentId: '44444444-4444-4444-4444-444444444444',
+ isRecipientIu: false,
})
expect(mockGroupedCreateMany).toHaveBeenCalledTimes(2)
@@ -243,6 +262,7 @@ describe('NotificationService grouped-email interception', () => {
['cu_a', 'cu_b', 'cu_c'],
{
email: true,
+ isRecipientIu: false,
},
)
@@ -258,7 +278,10 @@ describe('NotificationService grouped-email interception', () => {
associations: [{ companyId: assocCompany }] as unknown as Task['associations'],
})
- await buildService().createBulkNotification(NotificationTaskActions.SharedToCompany, task, ['cu_a'], { email: true })
+ await buildService().createBulkNotification(NotificationTaskActions.SharedToCompany, task, ['cu_a'], {
+ email: true,
+ isRecipientIu: false,
+ })
expect(mockGroupedCreateMany.mock.calls[0][0].data[0].recipientCompanyId).toBe(assocCompany)
})
@@ -268,36 +291,89 @@ describe('NotificationService grouped-email interception', () => {
email: false,
disableInProduct: false,
commentId: '44444444-4444-4444-4444-444444444444',
+ isRecipientIu: false,
})
expect(mockGroupedCreateMany).not.toHaveBeenCalled()
expect(mockCreateNotification).toHaveBeenCalledTimes(1)
})
+
+ it('buffers a Commented IU email as an IU row and dispatches the in-product notification to the IU', async () => {
+ await buildService().createBulkNotification(NotificationTaskActions.Commented, makeTask(), ['iu_a', 'iu_b'], {
+ email: true,
+ disableInProduct: false,
+ commentId: '44444444-4444-4444-4444-444444444444',
+ isRecipientIu: true,
+ })
+
+ expect(mockGroupedCreateMany).toHaveBeenCalledTimes(2)
+ const rows = mockGroupedCreateMany.mock.calls.map((c) => c[0].data[0])
+ expect(rows.map((r) => r.recipientIuId)).toEqual(['iu_a', 'iu_b'])
+ for (const row of rows) {
+ expect(row.eventType).toBe(GroupedEmailEventType.COMMENT)
+ expect(row.recipientClientId).toBeNull()
+ expect(row.individualEmail.recipientInternalUserId).toBeDefined()
+ expect(row.individualEmail.recipientClientId).toBeUndefined()
+ }
+
+ // in-product still fires immediately, routed to the IU with the email stripped
+ const sent = mockCreateNotification.mock.calls.map((c) => c[0])
+ expect(sent.map((s) => s.recipientInternalUserId)).toEqual(['iu_a', 'iu_b'])
+ for (const s of sent) {
+ expect(s.recipientClientId).toBeUndefined()
+ expect(s.deliveryTargets.email).toBeUndefined()
+ }
+ })
+
+ it('routes an email-enabled Commented email to the client when isRecipientIu is false (no email-absence inference)', async () => {
+ await buildService().createBulkNotification(NotificationTaskActions.Commented, makeTask(), ['cu_a'], {
+ email: true,
+ disableInProduct: true,
+ commentId: '44444444-4444-4444-4444-444444444444',
+ isRecipientIu: false,
+ })
+
+ const row = mockGroupedCreateMany.mock.calls[0][0].data[0]
+ expect(row.recipientClientId).toBe('cu_a')
+ expect(row.recipientIuId).toBeNull()
+ expect(row.individualEmail.recipientClientId).toBe('cu_a')
+ expect(row.individualEmail.recipientInternalUserId).toBeUndefined()
+ })
})
})
describe('guard: CU wiring boundaries', () => {
- it('maps every CU-targeted action to the correct GroupedEmailEventType', () => {
+ it('maps every buffered action to the correct GroupedEmailEventType', () => {
const svc = buildService() as unknown as {
groupedEventTypeFor: (a: NotificationTaskActions) => GroupedEmailEventType | null
}
expect(svc.groupedEventTypeFor(NotificationTaskActions.Assigned)).toBe(GroupedEmailEventType.ASSIGNED)
expect(svc.groupedEventTypeFor(NotificationTaskActions.AssignedToCompany)).toBe(GroupedEmailEventType.ASSIGNED)
+ expect(svc.groupedEventTypeFor(NotificationTaskActions.ReassignedToIU)).toBe(GroupedEmailEventType.ASSIGNED)
expect(svc.groupedEventTypeFor(NotificationTaskActions.Shared)).toBe(GroupedEmailEventType.SHARED)
expect(svc.groupedEventTypeFor(NotificationTaskActions.SharedToCompany)).toBe(GroupedEmailEventType.SHARED)
expect(svc.groupedEventTypeFor(NotificationTaskActions.Commented)).toBe(GroupedEmailEventType.COMMENT)
+ expect(svc.groupedEventTypeFor(NotificationTaskActions.Completed)).toBe(GroupedEmailEventType.COMPLETED)
+ expect(svc.groupedEventTypeFor(NotificationTaskActions.CompletedByIU)).toBe(GroupedEmailEventType.COMPLETED)
+ expect(svc.groupedEventTypeFor(NotificationTaskActions.CompletedByCompanyMember)).toBe(GroupedEmailEventType.COMPLETED)
+ expect(svc.groupedEventTypeFor(NotificationTaskActions.CompletedForCompanyByIU)).toBe(GroupedEmailEventType.COMPLETED)
})
- it('returns null for every action that must not be buffered', () => {
+ it('returns null for every action that must not be buffered (incl. shared-CU completion emails)', () => {
const svc = buildService() as unknown as {
groupedEventTypeFor: (a: NotificationTaskActions) => GroupedEmailEventType | null
}
const mapped = [
NotificationTaskActions.Assigned,
NotificationTaskActions.AssignedToCompany,
+ NotificationTaskActions.ReassignedToIU,
NotificationTaskActions.Shared,
NotificationTaskActions.SharedToCompany,
NotificationTaskActions.Commented,
+ NotificationTaskActions.Completed,
+ NotificationTaskActions.CompletedByIU,
+ NotificationTaskActions.CompletedByCompanyMember,
+ NotificationTaskActions.CompletedForCompanyByIU,
]
const unmapped = Object.values(NotificationTaskActions).filter((a) => !mapped.includes(a))
for (const action of unmapped) {
@@ -305,19 +381,182 @@ describe('guard: CU wiring boundaries', () => {
}
})
- it('never returns COMPLETED — that type is reserved for the deferred IU milestone', () => {
- const svc = buildService() as unknown as {
- groupedEventTypeFor: (a: NotificationTaskActions) => GroupedEmailEventType | null
+ it('never writes recipientIuId in a CU grouped event row', async () => {
+ await buildService().create(NotificationTaskActions.Assigned, makeTask())
+ const row = mockGroupedCreateMany.mock.calls[0][0].data[0]
+ expect(row.recipientIuId).toBeNull()
+ })
+})
+
+describe('guard: IU wiring boundaries', () => {
+ it('buffers an Assigned IU email with recipientIuId set and all client fields null', async () => {
+ const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
+ await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false })
+
+ expect(mockGroupedCreateMany).toHaveBeenCalledTimes(1)
+ const row = mockGroupedCreateMany.mock.calls[0][0].data[0]
+ expect(row).toMatchObject({
+ workspaceId: 'ws_1',
+ recipientIuId: task.assigneeId,
+ recipientClientId: null,
+ recipientCompanyId: null,
+ eventType: GroupedEmailEventType.ASSIGNED,
+ taskId: task.id,
+ })
+ expect(row.windowKey).toMatch(/^33333333-3333-3333-3333-333333333333:iu:/)
+ expect(mockEnqueueFlush).toHaveBeenCalledWith({ workspaceId: 'ws_1', windowKey: row.windowKey })
+
+ // individual email snapshot routes to the IU, not a client
+ expect(row.individualEmail.recipientInternalUserId).toBe(task.assigneeId)
+ expect(row.individualEmail.recipientClientId).toBeUndefined()
+ })
+
+ it('sends the in-product notification to recipientInternalUserId and strips the email target', async () => {
+ const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
+ await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false })
+
+ expect(mockCreateNotification).toHaveBeenCalledTimes(1)
+ const sent = mockCreateNotification.mock.calls[0][0]
+ expect(sent.recipientInternalUserId).toBe(task.assigneeId)
+ expect(sent.recipientClientId).toBeUndefined()
+ expect(deliveryTargetsOf(0).inProduct).toBeDefined()
+ expect(deliveryTargetsOf(0).email).toBeUndefined()
+ })
+
+ it('does not buffer when disableEmail is true for an IU task', async () => {
+ const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
+ await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: true })
+
+ expect(mockGroupedCreateMany).not.toHaveBeenCalled()
+ expect(mockEnqueueFlush).not.toHaveBeenCalled()
+ })
+})
+
+describe('guard: IU notifications ship ungated (settingId gating disabled)', () => {
+ it('does not attach notificationSettingId to the in-product dispatch or the buffered email', async () => {
+ const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
+ await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false })
+
+ expect(mockCreateNotification.mock.calls[0][0].notificationSettingId).toBeUndefined()
+ expect(mockGroupedCreateMany.mock.calls[0][0].data[0].individualEmail.notificationSettingId).toBeUndefined()
+ })
+
+ it('still buffers the IU email and fires the in-product notification', async () => {
+ const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
+ await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false })
+
+ expect(mockGroupedCreateMany).toHaveBeenCalledTimes(1)
+ expect(deliveryTargetsOf(0).inProduct).toBeDefined()
+ })
+
+ it('keeps IU grouped windows cross-category (window key is not scoped by event type)', async () => {
+ const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
+ await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false })
+
+ expect(mockGroupedCreateMany.mock.calls[0][0].data[0].windowKey).toMatch(new RegExp(`^${task.assigneeId}:iu:[^:]+$`))
+ })
+
+ it('treats a suppressed (null) createNotification response as a no-op — no throw, no save', async () => {
+ // Platform dropped the only requested surface for this IU (preference off) → no created object.
+ mockCreateNotification.mockResolvedValue(null)
+ const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
+
+ const result = await buildService().create(NotificationTaskActions.Assigned, task, { disableEmail: false })
+
+ expect(mockCreateNotification).toHaveBeenCalledTimes(1)
+ expect(result).toBeUndefined()
+ })
+})
+
+describe('guard: IU completion emails', () => {
+ // Every completion action routes to an IU; create() must buffer them as IU rows regardless of
+ // which one is passed, so the guard stays consistent with groupedEventTypeFor.
+ it.each([
+ NotificationTaskActions.CompletedByIU,
+ NotificationTaskActions.CompletedForCompanyByIU,
+ NotificationTaskActions.Completed,
+ NotificationTaskActions.CompletedByCompanyMember,
+ ])('buffers a %s email as a COMPLETED IU event and keeps the in-product notification immediate', async (action) => {
+ await buildService().create(action, makeTask({ assigneeType: AssigneeType.internalUser, clientId: null }))
+
+ expect(mockGroupedCreateMany).toHaveBeenCalledTimes(1)
+ const row = mockGroupedCreateMany.mock.calls[0][0].data[0]
+ expect(row).toMatchObject({
+ recipientIuId: '33333333-3333-3333-3333-333333333333',
+ recipientClientId: null,
+ recipientCompanyId: null,
+ eventType: GroupedEmailEventType.COMPLETED,
+ })
+ expect(row.individualEmail.recipientInternalUserId).toBe('33333333-3333-3333-3333-333333333333')
+
+ expect(mockCreateNotification).toHaveBeenCalledTimes(1)
+ const sent = mockCreateNotification.mock.calls[0][0]
+ expect(sent.recipientInternalUserId).toBe('33333333-3333-3333-3333-333333333333')
+ expect(sent.recipientClientId).toBeUndefined()
+ expect(deliveryTargetsOf(0).inProduct).toBeDefined()
+ expect(deliveryTargetsOf(0).email).toBeUndefined()
+ })
+
+ it('does not buffer and still routes the in-product CompletedByIU notification to the IU when email is disabled', async () => {
+ const task = makeTask({ assigneeType: AssigneeType.internalUser, clientId: null })
+ await buildService().create(NotificationTaskActions.CompletedByIU, task, { disableEmail: true })
+
+ expect(mockGroupedCreateMany).not.toHaveBeenCalled()
+ const sent = mockCreateNotification.mock.calls[0][0]
+ expect(sent.recipientInternalUserId).toBe('33333333-3333-3333-3333-333333333333')
+ expect(sent.recipientClientId).toBeUndefined()
+ expect(deliveryTargetsOf(0).email).toBeUndefined()
+ })
+
+ it('is not blocked by the client-notification dedup guard on a client-assigned task', async () => {
+ // A client-assigned task still has an unread ClientNotification from its assignment when the
+ // IU completes it; the CompletedByIU recipient is the creator IU, so the guard must not fire.
+ mockFindFirst.mockResolvedValue({ id: 'existing-client-notif' })
+ const task = makeTask({ assigneeType: AssigneeType.client, clientId: '33333333-3333-3333-3333-333333333333' })
+
+ await buildService().create(NotificationTaskActions.CompletedByIU, task, { disableEmail: false })
+
+ // guard skipped: the completion email buffers and the IU notification dispatches
+ expect(mockFindFirst).not.toHaveBeenCalled()
+ expect(mockGroupedCreateMany).toHaveBeenCalledTimes(1)
+ expect(mockGroupedCreateMany.mock.calls[0][0].data[0].recipientIuId).toBe('33333333-3333-3333-3333-333333333333')
+ expect(mockCreateNotification).toHaveBeenCalledTimes(1)
+ expect(mockCreateNotification.mock.calls[0][0].recipientInternalUserId).toBe('33333333-3333-3333-3333-333333333333')
+ })
+
+ it('bulk Completed buffers one COMPLETED IU row per recipient and strips the email from dispatch', async () => {
+ await buildService().createBulkNotification(NotificationTaskActions.Completed, makeTask(), ['iu_a', 'iu_b'], {
+ email: true,
+ isRecipientIu: true,
+ })
+
+ expect(mockGroupedCreateMany).toHaveBeenCalledTimes(2)
+ const rows = mockGroupedCreateMany.mock.calls.map((c) => c[0].data[0])
+ expect(rows.map((r) => r.recipientIuId)).toEqual(['iu_a', 'iu_b'])
+ for (const row of rows) {
+ expect(row.eventType).toBe(GroupedEmailEventType.COMPLETED)
+ expect(row.recipientClientId).toBeNull()
+ expect(row.individualEmail.recipientInternalUserId).toBeDefined()
}
- const allActions = Object.values(NotificationTaskActions)
- for (const action of allActions) {
- expect(svc.groupedEventTypeFor(action)).not.toBe(GroupedEmailEventType.COMPLETED)
+
+ expect(mockCreateNotification).toHaveBeenCalledTimes(2)
+ const sent = mockCreateNotification.mock.calls.map((c) => c[0])
+ expect(sent.map((s) => s.recipientInternalUserId)).toEqual(['iu_a', 'iu_b'])
+ for (const s of sent) {
+ expect(s.recipientClientId).toBeUndefined()
+ expect(s.deliveryTargets.email).toBeUndefined()
}
})
- it('never writes recipientIuId in a CU grouped event row', async () => {
- await buildService().create(NotificationTaskActions.Assigned, makeTask())
- const row = mockGroupedCreateMany.mock.calls[0][0].data[0]
- expect(row.recipientIuId).toBeUndefined()
+ it('bulk CompletedByCompanyMember neither buffers nor emails when the flag is off (email opt falsy)', async () => {
+ await buildService().createBulkNotification(NotificationTaskActions.CompletedByCompanyMember, makeTask(), ['iu_a'], {
+ email: false,
+ isRecipientIu: true,
+ })
+
+ expect(mockGroupedCreateMany).not.toHaveBeenCalled()
+ expect(mockCreateNotification).toHaveBeenCalledTimes(1)
+ expect(deliveryTargetsOf(0).email).toBeUndefined()
+ expect(mockCreateNotification.mock.calls[0][0].recipientInternalUserId).toBe('iu_a')
})
})
diff --git a/src/app/api/notification/notification.service.ts b/src/app/api/notification/notification.service.ts
index e37a311e7..f65b28211 100644
--- a/src/app/api/notification/notification.service.ts
+++ b/src/app/api/notification/notification.service.ts
@@ -14,6 +14,7 @@ import APIError from '@api/core/exceptions/api'
import { BaseService } from '@api/core/services/base.service'
import { NotificationTaskActions } from '@api/core/types/tasks'
import { getEmailDetails, getInProductNotificationDetails, mergeEmailOverride } from '@api/notification/notification.helpers'
+// import { resolveIuNotificationSettingId } from '@api/notification/resolveNotificationSettingId'
import { AssigneeType, ClientNotification, GroupedEmailEventType, Prisma, Task } from '@prisma/client'
import { randomUUID } from 'crypto'
import { enqueueGroupedEmailFlush } from '@/jobs/notifications/flush-grouped-email'
@@ -28,21 +29,30 @@ export class NotificationService extends BaseService {
action: NotificationTaskActions,
task: Task,
opts: {
- disableEmail: boolean
+ disableEmail?: boolean
disableInProduct?: boolean
commentId?: string
senderCompanyId?: string
emailOverride?: EmailNotificationDetails
- } = { disableEmail: false },
+ } = {},
) {
try {
- // 1.Check for existing notification. Skip if duplicate
- const existingNotification = task.clientId
- ? await this.db.clientNotification.findFirst({
- where: { taskId: task.id, clientId: task.clientId, companyId: task.companyId },
- })
- : null
- if (task.clientId && existingNotification && !opts.commentId) {
+ const isAssignedToIu =
+ task.assigneeType === AssigneeType.internalUser &&
+ (action === NotificationTaskActions.Assigned || action === NotificationTaskActions.ReassignedToIU)
+ // Completion notifications always go to IUs (task creator, or IUs with access)
+ const isRecipientIu = isAssignedToIu || this.isCompletionAction(action)
+
+ // 1. Check for existing notification. Skip if duplicate. This dedup is keyed on the client
+ // assignee, so it must not gate IU-recipient notifications (e.g. CompletedByIU on a
+ // client-assigned task, whose recipient is the creator IU, not the client).
+ const existingNotification =
+ task.clientId && !isRecipientIu
+ ? await this.db.clientNotification.findFirst({
+ where: { taskId: task.id, clientId: task.clientId, companyId: task.companyId },
+ })
+ : null
+ if (existingNotification && !opts.commentId) {
console.error(`NotificationService#create | Found existing notification for ${task.clientId}`, existingNotification)
return
}
@@ -63,21 +73,34 @@ export class NotificationService extends BaseService {
: getEmailDetails(workspace, actionUser, task, { commentId: opts?.commentId })[action]
const email = baseEmail ? mergeEmailOverride({ base: baseEmail, override: opts.emailOverride }) : baseEmail
- // Non-null only when this CU email should be diverted into the grouped buffer.
- const groupedType = email && recipientId ? this.groupedEventTypeFor(action) : null
+ const category = this.groupedEventTypeFor(action)
+ // TODO(OUT-3929): re-enable per-IU gating once Copilot exposes a preference-read endpoint — ship IUs ungated for now.
+ const notificationSettingId = undefined
+ // const notificationSettingId = isRecipientIu && category ? await resolveIuNotificationSettingId({ copilot: this.copilot, workspaceId: task.workspaceId, category }) : undefined
+
+ const groupedType = email && recipientId ? category : null
if (groupedType) {
const association = AssociationsSchema.parse(task.associations)?.[0]
await this.bufferGroupedEmailEvent({
task,
- recipientClientId: recipientId,
- recipientCompanyId: task.companyId ?? association?.companyId ?? null,
+ recipientId,
+ companyId: task.companyId ?? association?.companyId ?? undefined,
+ isRecipientIu,
eventType: groupedType,
commentId: opts.commentId,
- individualEmail: this.buildNotificationDetails(task, senderId, recipientId, { email }, senderCompanyId),
+ individualEmail: this.buildNotificationDetails(
+ task,
+ senderId,
+ recipientId,
+ { email },
+ senderCompanyId,
+ isRecipientIu,
+ notificationSettingId,
+ ),
})
}
- // Build with the email so the recipient is routed as a client, then drop the email target
+ // Build with the email so the recipient is routed correctly, then drop the email target
// once it has been diverted to the buffer (the in-product notification still fires now).
const notificationDetails = this.buildNotificationDetails(
task,
@@ -85,19 +108,18 @@ export class NotificationService extends BaseService {
recipientId,
{ inProduct, email },
senderCompanyId,
+ isRecipientIu,
+ notificationSettingId,
)
if (groupedType) notificationDetails.deliveryTargets = { inProduct }
if (!inProduct && !notificationDetails.deliveryTargets?.email) return
console.info('NotificationService#create | Creating single notification:', notificationDetails)
- let notification: NotificationCreatedResponse
- try {
- notification = await this.copilot.createNotification(notificationDetails)
- } catch (e: unknown) {
- notification = await this.handleIfSenderCompanyIdError(e, notificationDetails)
- }
+ const notification = await this.dispatchNotification(notificationDetails)
console.info('NotificationService#create | Created single notification:', notification)
+ // Suppressed by the recipient IU's preference — nothing was created, so there's nothing to save.
+ if (!notification) return
// 3. Save notification to ClientNotification or InternalUserNotification table. Check for notification.recipientClientId too
if (task.assigneeType === AssigneeType.client && !!notification.recipientClientId && !opts.disableInProduct) {
@@ -106,10 +128,7 @@ export class NotificationService extends BaseService {
// NOTE: There are cases where task.assigneeType does not account for IU notification!
// E.g. When receiving notifications from others completing task that IU created.
// For now we don't have to store these so this hasn't been accounted for
- const shouldSendIUNotification =
- task.assigneeType === AssigneeType.internalUser &&
- (action === NotificationTaskActions.Assigned || action === NotificationTaskActions.ReassignedToIU)
- if (shouldSendIUNotification) {
+ if (isAssignedToIu) {
// Notification recipient is IU in this case
await this.db.internalUserNotification.create({
data: {
@@ -130,7 +149,10 @@ export class NotificationService extends BaseService {
action: NotificationTaskActions,
task: Task,
recipientIds: string[],
- opts?: {
+ // isRecipientIu is required: the same action (e.g. Commented) fans out to both CU and IU
+ // recipient lists, so routing must be declared by the caller, never inferred.
+ opts: {
+ isRecipientIu: boolean
email?: boolean
disableInProduct?: boolean
commentId?: string
@@ -177,8 +199,13 @@ export class NotificationService extends BaseService {
const iuNotifications = []
const association = AssociationsSchema.parse(task.associations)?.[0]
- // Non-null only when these CU emails should be diverted into the grouped buffer.
- const groupedType = email ? this.groupedEventTypeFor(action) : null
+ const category = this.groupedEventTypeFor(action)
+ const isRecipientIu = opts.isRecipientIu
+ // TODO(OUT-3929): re-enable per-IU gating once Copilot exposes a preference-read endpoint — ship IUs ungated for now.
+ const notificationSettingId = undefined
+ // const notificationSettingId = isRecipientIu && category ? await resolveIuNotificationSettingId({ copilot: this.copilot, workspaceId: task.workspaceId, category }) : undefined
+ // Non-null only when these emails should be diverted into the grouped buffer.
+ const groupedType = email ? category : null
// NOTE: The reason we are skipping using NotificationService#create and implementing notification dispatch + save manually is because
// we can just do one `createMany` DB call instead of one per notification, saving a ton of DB calls
@@ -197,11 +224,20 @@ export class NotificationService extends BaseService {
if (groupedType) {
await this.bufferGroupedEmailEvent({
task,
- recipientClientId: recipientId,
- recipientCompanyId: task.companyId ?? association?.companyId ?? null,
+ recipientId,
+ companyId: task.companyId ?? association?.companyId ?? undefined,
+ isRecipientIu,
eventType: groupedType,
commentId: opts?.commentId,
- individualEmail: this.buildNotificationDetails(task, senderId, recipientId, { email }, opts?.senderCompanyId),
+ individualEmail: this.buildNotificationDetails(
+ task,
+ senderId,
+ recipientId,
+ { email },
+ opts?.senderCompanyId,
+ isRecipientIu,
+ notificationSettingId,
+ ),
})
if (!inProduct) continue
}
@@ -214,16 +250,13 @@ export class NotificationService extends BaseService {
recipientId,
{ inProduct, email },
opts?.senderCompanyId,
+ isRecipientIu,
+ notificationSettingId,
)
if (groupedType) notificationDetails.deliveryTargets = { inProduct }
console.info('NotificationService#bulkCreate | Creating single notification:', notificationDetails)
- let notification: NotificationCreatedResponse
- try {
- notification = await this.copilot.createNotification(notificationDetails)
- } catch (e: unknown) {
- notification = await this.handleIfSenderCompanyIdError(e, notificationDetails)
- }
+ const notification = await this.dispatchNotification(notificationDetails)
console.info('NotificationService#bulkCreate | Created single notification:', notification)
if (!notification) {
@@ -553,6 +586,7 @@ export class NotificationService extends BaseService {
)
.map((iu) => iu.id)
}
+ break
default:
const userInfo = await this.copilot.me()
senderId = z.string().parse(userInfo?.id)
@@ -584,10 +618,21 @@ export class NotificationService extends BaseService {
})
}
+ private isCompletionAction(action: NotificationTaskActions): boolean {
+ return (
+ action === NotificationTaskActions.Completed ||
+ action === NotificationTaskActions.CompletedByIU ||
+ action === NotificationTaskActions.CompletedByCompanyMember ||
+ action === NotificationTaskActions.CompletedForCompanyByIU
+ )
+ }
+
private groupedEventTypeFor(action: NotificationTaskActions): GroupedEmailEventType | null {
+ if (this.isCompletionAction(action)) return GroupedEmailEventType.COMPLETED
switch (action) {
case NotificationTaskActions.Assigned:
case NotificationTaskActions.AssignedToCompany:
+ case NotificationTaskActions.ReassignedToIU:
return GroupedEmailEventType.ASSIGNED
case NotificationTaskActions.Shared:
case NotificationTaskActions.SharedToCompany:
@@ -599,37 +644,43 @@ export class NotificationService extends BaseService {
}
}
- private async bufferGroupedEmailEvent(args: {
+ async bufferGroupedEmailEvent(args: {
task: Task
- recipientClientId: string
- recipientCompanyId: string | null
+ recipientId: string
+ companyId?: string
+ isRecipientIu?: boolean
eventType: GroupedEmailEventType
commentId?: string
individualEmail: NotificationRequestBody
}): Promise {
- const { task, recipientClientId, recipientCompanyId, eventType, commentId, individualEmail } = args
+ const { task, recipientId, companyId, isRecipientIu, eventType, commentId, individualEmail } = args
+ const recipientFilter = isRecipientIu
+ ? Prisma.sql`"recipientIuId" = ${recipientId}::uuid`
+ : Prisma.sql`"recipientClientId" = ${recipientId}::uuid AND "recipientCompanyId" IS NOT DISTINCT FROM ${companyId ?? null}::uuid`
const activeWindow = await this.db.$queryRaw<{ windowKey: string }[]>`
- SELECT "windowKey" FROM "GroupedEmailEvents"
- WHERE "workspaceId" = ${task.workspaceId}
- AND "recipientClientId" = ${recipientClientId}::uuid
- AND "recipientCompanyId" IS NOT DISTINCT FROM ${recipientCompanyId}::uuid
- AND "sentAt" IS NULL
- AND "createdAt" > now() - interval '5 minutes'
- ORDER BY "createdAt" DESC
- LIMIT 1`
+ SELECT "windowKey" FROM "GroupedEmailEvents"
+ WHERE "workspaceId" = ${task.workspaceId}
+ AND ${recipientFilter}
+ AND "sentAt" IS NULL
+ AND "createdAt" > now() - interval '5 minutes'
+ ORDER BY "createdAt" DESC
+ LIMIT 1`
const isNewWindow = activeWindow.length === 0
const windowKey = isNewWindow
- ? `${recipientClientId}:${recipientCompanyId ?? 'none'}:${randomUUID()}`
+ ? isRecipientIu
+ ? `${recipientId}:iu:${randomUUID()}`
+ : `${recipientId}:${companyId ?? 'none'}:${randomUUID()}`
: activeWindow[0].windowKey
await this.db.groupedEmailEvent.createMany({
data: [
{
workspaceId: task.workspaceId,
- recipientClientId,
- recipientCompanyId,
+ recipientClientId: isRecipientIu ? null : recipientId,
+ recipientCompanyId: isRecipientIu ? null : (companyId ?? null),
+ recipientIuId: isRecipientIu ? recipientId : null,
eventType,
taskId: task.id,
taskTitleSnapshot: task.title,
@@ -646,6 +697,16 @@ export class NotificationService extends BaseService {
}
}
+ private async dispatchNotification(
+ notificationDetails: NotificationRequestBody,
+ ): Promise {
+ try {
+ return await this.copilot.createNotification(notificationDetails)
+ } catch (e: unknown) {
+ return await this.handleIfSenderCompanyIdError(e, notificationDetails)
+ }
+ }
+
private async handleIfSenderCompanyIdError(e: unknown, notificationDetails: NotificationRequestBody) {
// Account for workspaces that don't have multi-companies enabled, thus don't support the senderCompanyId key
// Yes, this is hacky. No, I don't have a choice (I can't find out if workspace has single/multi company at all from the Copilot API)
@@ -670,8 +731,11 @@ export class NotificationService extends BaseService {
recipientId: string,
deliveryTargets: NotificationRequestBody['deliveryTargets'],
senderCompanyId?: string,
+ isRecipientIu?: boolean,
+ // Set for IU payloads so the platform gates each requested surface (in-product and email)
+ // against the IU's per-category preference. Suppression is enforced platform-side.
+ notificationSettingId?: string,
): NotificationRequestBody {
- // Assume client notification then change details body if IU
const associations = AssociationsSchema.parse(task.associations)
const association = associations?.[0]
const notificationDetails: NotificationRequestBody = {
@@ -680,16 +744,13 @@ export class NotificationService extends BaseService {
senderType: this.user.role,
recipientClientId: recipientId ?? undefined,
recipientCompanyId: task.companyId ?? association?.companyId ?? undefined,
- // If any of the given action is not present in details obj, that type of notification is not sent
deliveryTargets: deliveryTargets || {},
}
- //! Since IU's NEVER get email notifications, we send recipientCompanyId only if email is present
- const isIU = !notificationDetails.deliveryTargets?.email
- // In case this logic ever changes, good luck
- if (isIU) {
+ if (isRecipientIu) {
delete notificationDetails.recipientCompanyId
delete notificationDetails.recipientClientId
notificationDetails.recipientInternalUserId = recipientId
+ if (notificationSettingId) notificationDetails.notificationSettingId = notificationSettingId
}
return notificationDetails
}
diff --git a/src/app/api/notification/resolveNotificationSettingId.test.ts b/src/app/api/notification/resolveNotificationSettingId.test.ts
new file mode 100644
index 000000000..9e664b4d0
--- /dev/null
+++ b/src/app/api/notification/resolveNotificationSettingId.test.ts
@@ -0,0 +1,91 @@
+import { CopilotAPI } from '@/utils/CopilotAPI'
+import { GroupedEmailEventType } from '@prisma/client'
+import { __clearNotificationSettingCache, resolveIuNotificationSettingId } from './resolveNotificationSettingId'
+
+const buildCopilot = (getNotificationSettings: jest.Mock) => ({ getNotificationSettings }) as unknown as CopilotAPI
+
+const settings = [
+ { id: 'setting_assigned', label: 'New task assigned', surfaces: ['product', 'email'] },
+ { id: 'setting_comment', label: 'New comment on a task', surfaces: ['product', 'email'] },
+ { id: 'setting_completed', label: 'Task completed', surfaces: ['product', 'email'] },
+]
+
+beforeEach(() => __clearNotificationSettingCache())
+
+describe('resolveIuNotificationSettingId', () => {
+ it('maps each category to its declared setting id by label', async () => {
+ const copilot = buildCopilot(jest.fn().mockResolvedValue({ notifications: settings }))
+
+ expect(
+ await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED }),
+ ).toBe('setting_assigned')
+ expect(
+ await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.COMMENT }),
+ ).toBe('setting_comment')
+ expect(
+ await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.COMPLETED }),
+ ).toBe('setting_completed')
+ })
+
+ it('matches labels case-insensitively and ignoring surrounding whitespace', async () => {
+ const copilot = buildCopilot(
+ jest.fn().mockResolvedValue({ notifications: [{ id: 'x', label: ' NEW task Assigned ', surfaces: ['email'] }] }),
+ )
+
+ expect(
+ await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED }),
+ ).toBe('x')
+ })
+
+ it('returns undefined when the category is not declared', async () => {
+ const copilot = buildCopilot(jest.fn().mockResolvedValue({ notifications: [settings[0]] }))
+
+ expect(
+ await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.COMMENT }),
+ ).toBeUndefined()
+ })
+
+ it('returns undefined for SHARED (no IU setting declared)', async () => {
+ const copilot = buildCopilot(jest.fn().mockResolvedValue({ notifications: settings }))
+
+ expect(
+ await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.SHARED }),
+ ).toBeUndefined()
+ })
+
+ it('caches the label map per workspace and does not refetch within the TTL', async () => {
+ const getNotificationSettings = jest.fn().mockResolvedValue({ notifications: settings })
+ const copilot = buildCopilot(getNotificationSettings)
+
+ await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED })
+ await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.COMMENT })
+
+ expect(getNotificationSettings).toHaveBeenCalledTimes(1)
+ })
+
+ it('returns undefined when the fetch fails, without caching the failure', async () => {
+ const getNotificationSettings = jest
+ .fn()
+ .mockRejectedValueOnce(new Error('copilot 5xx'))
+ .mockResolvedValueOnce({ notifications: settings })
+ const copilot = buildCopilot(getNotificationSettings)
+
+ expect(
+ await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED }),
+ ).toBeUndefined()
+ expect(
+ await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED }),
+ ).toBe('setting_assigned')
+ expect(getNotificationSettings).toHaveBeenCalledTimes(2)
+ })
+
+ it('caches per workspace independently', async () => {
+ const getNotificationSettings = jest.fn().mockResolvedValue({ notifications: settings })
+ const copilot = buildCopilot(getNotificationSettings)
+
+ await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_1', category: GroupedEmailEventType.ASSIGNED })
+ await resolveIuNotificationSettingId({ copilot, workspaceId: 'ws_2', category: GroupedEmailEventType.ASSIGNED })
+
+ expect(getNotificationSettings).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/app/api/notification/resolveNotificationSettingId.ts b/src/app/api/notification/resolveNotificationSettingId.ts
new file mode 100644
index 000000000..b0f8c1018
--- /dev/null
+++ b/src/app/api/notification/resolveNotificationSettingId.ts
@@ -0,0 +1,64 @@
+import { CopilotAPI } from '@/utils/CopilotAPI'
+import { serializeError } from '@/utils/serializeError'
+import { GroupedEmailEventType } from '@prisma/client'
+
+// Canonical labels the Tasks app declares on its Assembly app record (App Setup > Notifications).
+// Must match the declared setting labels exactly. SHARED is absent — shared notifications only ever
+// target clients, never IUs.
+export const IU_NOTIFICATION_LABELS: Partial> = {
+ [GroupedEmailEventType.ASSIGNED]: 'New task assigned',
+ [GroupedEmailEventType.COMMENT]: 'New comment on a task',
+ [GroupedEmailEventType.COMPLETED]: 'Task completed',
+}
+
+// Cache only the stable label -> id map per workspace (ids are stable per the platform docs). We
+// never cache an IU's on/off preference — the platform evaluates that on every send. The TTL only
+// bounds how long a newly declared setting's id takes to be picked up.
+const CACHE_TTL_MS = 5 * 60 * 1000
+const cache = new Map; expiresAt: number }>()
+
+const normalize = (label: string): string => label.trim().toLowerCase()
+
+const getLabelToId = async ({
+ copilot,
+ workspaceId,
+}: {
+ copilot: CopilotAPI
+ workspaceId: string
+}): Promise