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> => { + const cached = cache.get(workspaceId) + if (cached && cached.expiresAt > Date.now()) return cached.labelToId + + const { notifications } = await copilot.getNotificationSettings() + const labelToId = new Map(notifications.map((setting) => [normalize(setting.label), setting.id])) + cache.set(workspaceId, { labelToId, expiresAt: Date.now() + CACHE_TTL_MS }) + return labelToId +} + +// Resolve the declared setting id for an IU notification category so a send can pass it and let the +// platform gate each requested surface per the IU's preference. Returns undefined when the category +// isn't declared or the fetch fails — callers then send without an id (no per-IU gating on that +// send). Failures are not cached, so the next send retries. +export const resolveIuNotificationSettingId = async ({ + copilot, + workspaceId, + category, +}: { + copilot: CopilotAPI + workspaceId: string + category: GroupedEmailEventType +}): Promise => { + const label = IU_NOTIFICATION_LABELS[category] + if (!label) return undefined + + try { + const labelToId = await getLabelToId({ copilot, workspaceId }) + return labelToId.get(normalize(label)) + } catch (e) { + console.error('resolveIuNotificationSettingId | failed to resolve; sending without gating', serializeError(e)) + return undefined + } +} + +// Test seam: clear the per-workspace cache between cases. +export const __clearNotificationSettingCache = (): void => cache.clear() diff --git a/src/app/api/notification/validate-count/validateCount.service.ts b/src/app/api/notification/validate-count/validateCount.service.ts index 4729a6238..349b989b6 100644 --- a/src/app/api/notification/validate-count/validateCount.service.ts +++ b/src/app/api/notification/validate-count/validateCount.service.ts @@ -171,8 +171,10 @@ export class ValidateCountService extends NotificationService { // Now track those to ClientNotifications table const newClientNotificationData = [] for (const i in newNotifications) { + const notification = newNotifications[i] + if (!notification) continue newClientNotificationData.push({ - notificationId: newNotifications[i].id, + notificationId: notification.id, taskId: tasksWithoutNotifications[i].id, clientId, companyId: tasksWithoutNotifications[i].companyId, diff --git a/src/app/api/tasks/task-notifications.service.ts b/src/app/api/tasks/task-notifications.service.ts index e91aaffe2..bfefd01ff 100644 --- a/src/app/api/tasks/task-notifications.service.ts +++ b/src/app/api/tasks/task-notifications.service.ts @@ -287,13 +287,10 @@ export class TaskNotificationsService extends BaseService { else if (updatedTask.assigneeType === AssigneeType.company) { // Don't do this in parallel since this can cause rate-limits, each of them has their own bottlenecks for avoiding ratelimits shouldCreateNotification && - (await this.notificationService.create(NotificationTaskActions.CompletedForCompanyByIU, updatedTask, { - disableEmail: true, - })) + (await this.notificationService.create(NotificationTaskActions.CompletedForCompanyByIU, updatedTask)) await this.notificationService.markAsReadForAllRecipients(updatedTask) } else if (updatedTask.assigneeType === AssigneeType.client) { - shouldCreateNotification && - (await this.notificationService.create(NotificationTaskActions.CompletedByIU, updatedTask, { disableEmail: true })) + shouldCreateNotification && (await this.notificationService.create(NotificationTaskActions.CompletedByIU, updatedTask)) try { await this.notificationService.markClientNotificationAsRead(updatedTask) return @@ -301,8 +298,7 @@ export class TaskNotificationsService extends BaseService { console.error(`Failed to find ClientNotification for task ${updatedTask.id}`, e) } } else if (updatedTask.assigneeType === AssigneeType.internalUser) { - shouldCreateNotification && - (await this.notificationService.create(NotificationTaskActions.CompletedByIU, updatedTask, { disableEmail: true })) + shouldCreateNotification && (await this.notificationService.create(NotificationTaskActions.CompletedByIU, updatedTask)) } } @@ -367,7 +363,7 @@ export class TaskNotificationsService extends BaseService { NotificationTaskActions.CompletedByCompanyMember, updatedTask, recipientIds, - { senderCompanyId }, + { senderCompanyId, email: true, isRecipientIu: true }, ) await this.notificationService.markAsReadForAllRecipients(updatedTask) } else { @@ -378,6 +374,8 @@ export class TaskNotificationsService extends BaseService { ) await this.notificationService.createBulkNotification(NotificationTaskActions.Completed, updatedTask, recipientIds, { senderCompanyId, + email: true, + isRecipientIu: true, }) await this.notificationService.markClientNotificationAsRead(updatedTask) } @@ -400,7 +398,6 @@ export class TaskNotificationsService extends BaseService { const notification = await this.notificationService.create(notificationType, task, { disableInProduct: true, - disableEmail: false, }) // Create a new entry in ClientNotifications table so we can mark as read on // behalf of client later @@ -418,6 +415,7 @@ export class TaskNotificationsService extends BaseService { await this.notificationService.createBulkNotification(NotificationTaskActions.SharedToCompany, task, recipientIds, { email: true, disableInProduct: true, + isRecipientIu: false, }) } @@ -425,7 +423,6 @@ export class TaskNotificationsService extends BaseService { private sendUserTaskCompletedSharedNotification = async (task: Task) => { const notification = await this.notificationService.create(NotificationTaskActions.CompletedToSharedCU, task, { disableInProduct: true, - disableEmail: false, }) if (!notification) { console.error('Completed-shared notification failed to trigger for task:', task) @@ -442,7 +439,7 @@ export class TaskNotificationsService extends BaseService { NotificationTaskActions.CompletedToSharedCompany, task, recipientIds, - { email: true, disableInProduct: true }, + { email: true, disableInProduct: true, isRecipientIu: false }, ) } @@ -484,7 +481,7 @@ export class TaskNotificationsService extends BaseService { // In future when reassignment is supported, change this logic to support reassigned to client as well notificationType, task, - { disableEmail: task.assigneeType === AssigneeType.internalUser, emailOverride }, + { emailOverride }, ) // Create a new entry in ClientNotifications table so we can mark as read on // behalf of client later @@ -510,7 +507,7 @@ export class TaskNotificationsService extends BaseService { isReassigned ? NotificationTaskActions.ReassignedToCompany : NotificationTaskActions.AssignedToCompany, task, recipientIds, - { email: true, emailOverride }, + { email: true, emailOverride, isRecipientIu: false }, ) } diff --git a/src/app/api/webhook/webhook.service.ts b/src/app/api/webhook/webhook.service.ts index 4dbd35833..a73586890 100644 --- a/src/app/api/webhook/webhook.service.ts +++ b/src/app/api/webhook/webhook.service.ts @@ -121,9 +121,11 @@ class WebhookService extends BaseService { const insertPromises = [] const notificationService = new NotificationService(this.user) for (let i = 0; i < notifications.length; i++) { + const notification = notifications[i] + if (!notification) continue insertPromises.push( // This is assuming a 1:1 map for tasks and notifications - dbBottleneck.schedule(() => notificationService.addToClientNotifications(tasks[i], notifications[i])), + dbBottleneck.schedule(() => notificationService.addToClientNotifications(tasks[i], notification)), ) } await Promise.all(insertPromises) diff --git a/src/cmd/backfill-missed-emails/index.ts b/src/cmd/backfill-missed-emails/index.ts index b9678d985..1c30cba76 100644 --- a/src/cmd/backfill-missed-emails/index.ts +++ b/src/cmd/backfill-missed-emails/index.ts @@ -31,6 +31,7 @@ const dispatchNotification = async (copilot: CopilotAPI, taskId: string, payload // <<- Emails are triggered here. Proceed with caution ->> const notification = await copilot.createNotification(payload) + if (!notification) return await db.clientNotification.create({ data: { clientId: payload.recipientClientId!, diff --git a/src/jobs/notifications/flush-grouped-email.test.ts b/src/jobs/notifications/flush-grouped-email.test.ts index f59f13f2d..ec3bcfafe 100644 --- a/src/jobs/notifications/flush-grouped-email.test.ts +++ b/src/jobs/notifications/flush-grouped-email.test.ts @@ -100,13 +100,67 @@ describe('flushGroupedEmailRun', () => { expect(mockSendGroupedEmail).toHaveBeenCalledTimes(1) const args = mockSendGroupedEmail.mock.calls[0][0] - expect(args).toMatchObject({ senderId: 'iu_1', recipientClientId: 'client_1', recipientCompanyId: 'company_1' }) + // Sender is the real actor from the buffered event, not an arbitrary workspace IU. + expect(args).toMatchObject({ senderId: 'actor_1', recipientClientId: 'client_1', recipientCompanyId: 'company_1' }) + expect(mockGetInternalUsers).not.toHaveBeenCalled() expect(args.content.totalEventCount).toBe(2) expect(mockCreateNotification).not.toHaveBeenCalled() expect(mockExecuteRaw).toHaveBeenCalledTimes(2) // markRecipientSent + deleteWindowRows expect(result).toMatchObject({ recipients: 1, sent: 1, sentGrouped: 1, sentIndividual: 0 }) }) + it('sends an IU grouped summary from the real actor, not an arbitrary workspace IU', async () => { + const iuRow = () => + row({ + recipientClientId: null, + recipientCompanyId: null, + recipientIuId: 'iu_recipient', + individualEmail: { + senderId: 'actor_1', + recipientInternalUserId: 'iu_recipient', + deliveryTargets: { email: { subject: 'A task' } }, + }, + }) + mockQueryRaw.mockResolvedValue([iuRow(), iuRow()]) + + const result = await flushGroupedEmailRun(payload) + + expect(mockSendGroupedEmail).toHaveBeenCalledTimes(1) + const args = mockSendGroupedEmail.mock.calls[0][0] + expect(args).toMatchObject({ senderId: 'actor_1', recipientInternalUserId: 'iu_recipient' }) + expect(mockGetInternalUsers).not.toHaveBeenCalled() + expect(result).toMatchObject({ recipients: 1, sent: 1, sentGrouped: 1 }) + }) + + const iuRow = (settingId?: string) => + row({ + recipientClientId: null, + recipientCompanyId: null, + recipientIuId: 'iu_recipient', + individualEmail: { + senderId: 'actor_1', + recipientInternalUserId: 'iu_recipient', + notificationSettingId: settingId, + deliveryTargets: { email: { subject: 'A task' } }, + }, + }) + + it('gates an IU grouped summary with the setting id when the whole window is one category', async () => { + mockQueryRaw.mockResolvedValue([iuRow('setting_comment'), iuRow('setting_comment')]) + + await flushGroupedEmailRun(payload) + + expect(mockSendGroupedEmail.mock.calls[0][0]).toMatchObject({ notificationSettingId: 'setting_comment' }) + }) + + it('sends an IU grouped summary without an id when the window mixes categories', async () => { + mockQueryRaw.mockResolvedValue([iuRow('setting_assigned'), iuRow('setting_comment')]) + + await flushGroupedEmailRun(payload) + + expect(mockSendGroupedEmail.mock.calls[0][0].notificationSettingId).toBeUndefined() + }) + it('replays the original individual email when the window has a single live event', async () => { mockQueryRaw.mockResolvedValue([row()]) @@ -202,8 +256,9 @@ describe('flushGroupedEmailRun', () => { expect(mockExecuteRaw).not.toHaveBeenCalled() }) - it('throws when a grouped window has no internal user to send as', async () => { - mockQueryRaw.mockResolvedValue([row(), row()]) + it('throws when a grouped window has no buffered sender and no workspace internal user', async () => { + // Pre-migration rows carry no individualEmail, so the sender falls back to a workspace IU. + mockQueryRaw.mockResolvedValue([row({ individualEmail: null }), row({ individualEmail: null })]) mockGetInternalUsers.mockResolvedValue({ data: [] }) await expect(flushGroupedEmailRun(payload)).rejects.toThrow('no internal user') diff --git a/src/jobs/notifications/flush-grouped-email.ts b/src/jobs/notifications/flush-grouped-email.ts index 47ab2a51c..496ebc4c9 100644 --- a/src/jobs/notifications/flush-grouped-email.ts +++ b/src/jobs/notifications/flush-grouped-email.ts @@ -24,26 +24,32 @@ type WindowEvent = GroupedEmailEventInput & { individualEmail: NotificationReque type BufferedRow = WindowEvent & { recipientClientId: string | null recipientCompanyId: string | null + recipientIuId: string | null } -type RecipientGroup = { +type CuRecipientGroup = { recipientClientId: string recipientCompanyId: string | null events: WindowEvent[] } +type IuRecipientGroup = { + recipientIuId: string + events: WindowEvent[] +} + const TASK_ID = 'flush-grouped-email' const readUnsentWindowEvents = (db: ReturnType, windowKey: string) => db.$queryRaw` - SELECT "eventType", "taskId", "taskTitleSnapshot", "createdAt", "recipientClientId", "recipientCompanyId", "individualEmail" + SELECT "eventType", "taskId", "taskTitleSnapshot", "createdAt", "recipientClientId", "recipientCompanyId", "recipientIuId", "individualEmail" FROM "GroupedEmailEvents" WHERE "windowKey" = ${windowKey} AND "sentAt" IS NULL` const deleteWindowRows = (db: ReturnType, windowKey: string) => db.$executeRaw`DELETE FROM "GroupedEmailEvents" WHERE "windowKey" = ${windowKey} AND "sentAt" IS NOT NULL` -const markRecipientSent = ( +const markCuRecipientSent = ( db: ReturnType, windowKey: string, recipientClientId: string, @@ -53,6 +59,16 @@ const markRecipientSent = ( UPDATE "GroupedEmailEvents" SET "sentAt" = now(), "batchId" = ${batchId}::uuid WHERE "windowKey" = ${windowKey} AND "recipientClientId" = ${recipientClientId}::uuid AND "sentAt" IS NULL` +const markIuRecipientSent = ( + db: ReturnType, + windowKey: string, + recipientIuId: string, + batchId: string, +) => + db.$executeRaw` + UPDATE "GroupedEmailEvents" SET "sentAt" = now(), "batchId" = ${batchId}::uuid + WHERE "windowKey" = ${windowKey} AND "recipientIuId" = ${recipientIuId}::uuid AND "sentAt" IS NULL` + const resolveSenderId = async (copilot: CopilotAPI): Promise => { const { data } = await copilot.getInternalUsers({ limit: 1 }) const senderId = data[0]?.id @@ -60,7 +76,34 @@ const resolveSenderId = async (copilot: CopilotAPI): Promise => { return senderId } +// Copilot only emails an IU recipient when the sender is a real participant, so attribute the +// grouped summary to the actual actor from the buffered events, not an arbitrary workspace IU. +// The full sender context (type + company) must ride along, or a client actor produces a +// senderId/senderType mismatch that Copilot rejects (or silently drops). +type EventSender = Pick +const senderFromEvents = (events: WindowEvent[]): EventSender | undefined => { + const email = events.map((e) => e.individualEmail).find((e): e is NotificationRequestBody => Boolean(e?.senderId)) + if (!email) return undefined + return { senderId: email.senderId, senderType: email.senderType, senderCompanyId: email.senderCompanyId } +} + +// Seam for per-IU category filtering. Today it's a pass-through: the platform can't gate a mixed +// grouped summary, and there's no API to read an IU's per-setting preferences. Once Assembly exposes +// that endpoint, fetch the recipient IU's prefs here and drop events for disabled categories so the +// summary only carries allowed ones (an all-disabled recipient then sends nothing). +// TODO(OUT-3929): implement per-IU filtering when the read endpoint lands. +const filterEventsForIuPreferences = (events: WindowEvent[]): WindowEvent[] => events + +// The platform can only gate a send that carries one setting id. A grouped summary gets an id only +// when every live event shares the same one (i.e. a single-category window); a mixed window sends +// without an id and is not gated per-IU until the filter seam above is implemented. +const singleCategorySettingId = (events: WindowEvent[]): string | undefined => { + const ids = events.map((e) => e.individualEmail?.notificationSettingId) + return ids.every((id) => id && id === ids[0]) ? (ids[0] ?? undefined) : undefined +} + const sendIndividualEmail = async (copilot: CopilotAPI, payload: NotificationRequestBody): Promise => { + logger.log('flush-grouped-email: createNotification payload (individual replay)', { payload }) try { await copilot.createNotification(payload) } catch (e: unknown) { @@ -81,29 +124,41 @@ const getLiveTaskIds = async (db: ReturnType, taskI return new Set(live.map((t) => t.id)) } -const groupByRecipient = (rows: BufferedRow[]): RecipientGroup[] => { - const groups = new Map() +const toWindowEvent = (row: BufferedRow): WindowEvent => ({ + eventType: row.eventType, + taskId: row.taskId, + taskTitleSnapshot: row.taskTitleSnapshot, + createdAt: row.createdAt, + individualEmail: row.individualEmail, +}) + +const groupCuRecipients = (rows: BufferedRow[]): CuRecipientGroup[] => { + const groups = new Map() for (const row of rows) { if (!row.recipientClientId) continue const group = groups.get(row.recipientClientId) - const event: WindowEvent = { - eventType: row.eventType, - taskId: row.taskId, - taskTitleSnapshot: row.taskTitleSnapshot, - createdAt: row.createdAt, - individualEmail: row.individualEmail, - } - if (group) group.events.push(event) + if (group) group.events.push(toWindowEvent(row)) else groups.set(row.recipientClientId, { recipientClientId: row.recipientClientId, recipientCompanyId: row.recipientCompanyId, - events: [event], + events: [toWindowEvent(row)], }) } return [...groups.values()] } +const groupIuRecipients = (rows: BufferedRow[]): IuRecipientGroup[] => { + const groups = new Map() + for (const row of rows) { + if (!row.recipientIuId) continue + const group = groups.get(row.recipientIuId) + if (group) group.events.push(toWindowEvent(row)) + else groups.set(row.recipientIuId, { recipientIuId: row.recipientIuId, events: [toWindowEvent(row)] }) + } + return [...groups.values()] +} + export const flushGroupedEmailRun = async (payload: FlushGroupedEmailPayload) => { const { workspaceId, windowKey } = payload const db = DBClient.getInstance() @@ -120,20 +175,22 @@ export const flushGroupedEmailRun = async (payload: FlushGroupedEmailPayload) => const liveTaskIds = await getLiveTaskIds(db, [...new Set(rows.map((r) => r.taskId))]) const skippedDeletedTasks = rows.length - rows.filter((r) => liveTaskIds.has(r.taskId)).length - const groups = groupByRecipient(rows) + const cuGroups = groupCuRecipients(rows) + const iuGroups = groupIuRecipients(rows) logger.log('flush-grouped-email: starting', { workspaceId, windowKey, bufferedEvents: rows.length, skippedDeletedTasks, - recipients: groups.length, + recipients: cuGroups.length + iuGroups.length, }) let sent = 0 let sentGrouped = 0 let sentIndividual = 0 let senderId: string | undefined // resolved lazily — only the grouped branch needs a workspace IU - for (const group of groups) { + + for (const group of cuGroups) { const liveEvents = group.events.filter((e) => liveTaskIds.has(e.taskId)) // A single event reads awkwardly as a "summary" — replay the original individual email verbatim. // Pre-migration rows have no snapshot; fall back to the grouped summary rather than crash. @@ -141,7 +198,7 @@ export const flushGroupedEmailRun = async (payload: FlushGroupedEmailPayload) => Sentry.addBreadcrumb({ category: 'flush-grouped-email', - message: `recipient ${group.recipientClientId}`, + message: `cu recipient ${group.recipientClientId}`, data: { workspaceId, windowKey, recipientClientId: group.recipientClientId, liveEvents: liveEvents.length }, }) @@ -150,10 +207,12 @@ export const flushGroupedEmailRun = async (payload: FlushGroupedEmailPayload) => sent += 1 sentIndividual += 1 } else if (liveEvents.length >= 1) { - senderId ??= await resolveSenderId(copilot) + const sender = senderFromEvents(liveEvents) await sendGroupedEmail({ content: composeGroupedEmail(liveEvents), - senderId, + senderId: sender?.senderId ?? (senderId ??= await resolveSenderId(copilot)), + senderType: sender?.senderType, + senderCompanyId: sender?.senderCompanyId, recipientClientId: group.recipientClientId, recipientCompanyId: group.recipientCompanyId, copilot, @@ -162,7 +221,7 @@ export const flushGroupedEmailRun = async (payload: FlushGroupedEmailPayload) => sentGrouped += 1 } - await markRecipientSent(db, windowKey, group.recipientClientId, batchId) + await markCuRecipientSent(db, windowKey, group.recipientClientId, batchId) logger.log('flush-grouped-email: recipient processed', { workspaceId, windowKey, @@ -172,6 +231,47 @@ export const flushGroupedEmailRun = async (payload: FlushGroupedEmailPayload) => }) } + for (const group of iuGroups) { + const liveEvents = filterEventsForIuPreferences(group.events.filter((e) => liveTaskIds.has(e.taskId))) + // A single event replays its buffered email verbatim (it already carries its own setting id). + const singleEmail = liveEvents.length === 1 ? liveEvents[0].individualEmail : null + + Sentry.addBreadcrumb({ + category: 'flush-grouped-email', + message: `iu recipient ${group.recipientIuId}`, + data: { workspaceId, windowKey, recipientIuId: group.recipientIuId, liveEvents: liveEvents.length }, + }) + + if (singleEmail) { + await sendIndividualEmail(copilot, singleEmail) + sent += 1 + sentIndividual += 1 + } else if (liveEvents.length >= 1) { + const sender = senderFromEvents(liveEvents) + await sendGroupedEmail({ + content: composeGroupedEmail(liveEvents), + senderId: sender?.senderId ?? (senderId ??= await resolveSenderId(copilot)), + senderType: sender?.senderType, + senderCompanyId: sender?.senderCompanyId, + recipientInternalUserId: group.recipientIuId, + // Gate the summary per-IU only when the whole window is one category. + notificationSettingId: singleCategorySettingId(liveEvents), + copilot, + }) + sent += 1 + sentGrouped += 1 + } + + await markIuRecipientSent(db, windowKey, group.recipientIuId, batchId) + logger.log('flush-grouped-email: recipient processed', { + workspaceId, + windowKey, + recipientIuId: group.recipientIuId, + liveEvents: liveEvents.length, + outcome: singleEmail ? ('individual' as const) : liveEvents.length >= 1 ? ('grouped' as const) : ('skipped' as const), + }) + } + try { await deleteWindowRows(db, windowKey) } catch (err) { @@ -185,14 +285,14 @@ export const flushGroupedEmailRun = async (payload: FlushGroupedEmailPayload) => logger.log('flush-grouped-email: run summary', { workspaceId, windowKey, - recipients: groups.length, + recipients: cuGroups.length + iuGroups.length, sent, sentGrouped, sentIndividual, bufferedEvents: rows.length, skippedDeletedTasks, }) - return { windowKey, recipients: groups.length, sent, sentGrouped, sentIndividual } + return { windowKey, recipients: cuGroups.length + iuGroups.length, sent, sentGrouped, sentIndividual } } export const flushGroupedEmailOnFailure = async ({ payload, error }: { payload: unknown; error: unknown }) => { diff --git a/src/jobs/notifications/send-comment-create-notifications.ts b/src/jobs/notifications/send-comment-create-notifications.ts index 4efffa96e..713b8cbab 100644 --- a/src/jobs/notifications/send-comment-create-notifications.ts +++ b/src/jobs/notifications/send-comment-create-notifications.ts @@ -40,6 +40,7 @@ export const sendCommentCreateNotifications = task({ email: true, disableInProduct: true, commentId: comment.id, + isRecipientIu: false, }) const { recipientIds: iuRecipientIds, senderCompanyId } = await commentNotificationService.getNotificationParties( @@ -50,10 +51,11 @@ export const sendCommentCreateNotifications = task({ const filteredIUIds = iuRecipientIds.filter((id: string) => id !== comment.initiatorId) console.info('creating notifications for IUs', filteredIUIds) await commentNotificationService.createBulkNotification(NotificationTaskActions.Commented, task, filteredIUIds, { - email: false, + email: true, disableInProduct: false, commentId: comment.id, senderCompanyId, + isRecipientIu: true, }) }, }) diff --git a/src/jobs/notifications/send-grouped-email.test.ts b/src/jobs/notifications/send-grouped-email.test.ts index c41215706..f02e8927a 100644 --- a/src/jobs/notifications/send-grouped-email.test.ts +++ b/src/jobs/notifications/send-grouped-email.test.ts @@ -87,4 +87,44 @@ describe('sendGroupedEmail', () => { }), ).rejects.toThrow('copilot 5xx') }) + + it('honors an explicit client sender type and company (client actor completing their own task)', async () => { + const createNotification = jest.fn().mockResolvedValue({ id: 'notif_3', createdAt: '2026-06-09T00:00:00Z' }) + + await sendGroupedEmail({ + content, + senderId: 'client_actor', + senderType: 'client', + senderCompanyId: 'company_1', + recipientInternalUserId: 'iu_1', + copilot: buildCopilotMock(createNotification), + }) + + expect(createNotification.mock.calls[0][0]).toMatchObject({ + senderId: 'client_actor', + senderType: 'client', + senderCompanyId: 'company_1', + recipientInternalUserId: 'iu_1', + }) + }) + + it('retries without senderCompanyId when Copilot rejects it (single-company workspace)', async () => { + const createNotification = jest + .fn() + .mockRejectedValueOnce({ message: 'invalid', body: { message: 'sender company ID is invalid based on sender' } }) + .mockResolvedValueOnce({ id: 'notif_4', createdAt: '2026-06-09T00:00:00Z' }) + + const id = await sendGroupedEmail({ + content, + senderId: 'client_actor', + senderType: 'client', + senderCompanyId: 'company_1', + recipientInternalUserId: 'iu_1', + copilot: buildCopilotMock(createNotification), + }) + + expect(id).toBe('notif_4') + expect(createNotification).toHaveBeenCalledTimes(2) + expect(createNotification.mock.calls[1][0].senderCompanyId).toBeUndefined() + }) }) diff --git a/src/jobs/notifications/send-grouped-email.ts b/src/jobs/notifications/send-grouped-email.ts index 14d0f9bcf..14b23f725 100644 --- a/src/jobs/notifications/send-grouped-email.ts +++ b/src/jobs/notifications/send-grouped-email.ts @@ -2,31 +2,48 @@ import 'server-only' import { GroupedEmailContent } from '@/app/api/notification/groupedEmail.composer' import { renderGroupedEmail } from '@/app/api/notification/groupedEmail.renderer' -import { NotificationRequestBody } from '@/types/common' +import { NotificationRequestBody, NotificationSender } from '@/types/common' +import { isMessagableError } from '@/utils/copilotError' import { CopilotAPI } from '@/utils/CopilotAPI' +import { logger } from '@trigger.dev/sdk/v3' export type SendGroupedEmailArgs = { content: GroupedEmailContent senderId: string - recipientClientId: string - recipientCompanyId: string | null + // Copilot rejects an IU-recipient email whose sender identity is inconsistent, so the summary must + // carry the real actor's type/company (a client can be the actor, e.g. completing their own task). + senderType?: NotificationSender + senderCompanyId?: string + recipientClientId?: string | null + recipientCompanyId?: string | null + recipientInternalUserId?: string | null + // Set only for a single-category IU window so the platform can gate this summary per the IU's + // preference. A mixed-category window carries no id (can't gate against one setting). + notificationSettingId?: string copilot: CopilotAPI } export const sendGroupedEmail = async ({ content, senderId, + senderType, + senderCompanyId, recipientClientId, recipientCompanyId, + recipientInternalUserId, + notificationSettingId, copilot, -}: SendGroupedEmailArgs): Promise => { +}: SendGroupedEmailArgs): Promise => { const email = renderGroupedEmail(content) const payload: NotificationRequestBody = { senderId, - senderType: 'internalUser', - recipientClientId, + senderType: senderType ?? 'internalUser', + senderCompanyId, + recipientClientId: recipientClientId ?? undefined, recipientCompanyId: recipientCompanyId ?? undefined, + recipientInternalUserId: recipientInternalUserId ?? undefined, + notificationSettingId, deliveryTargets: { email: { subject: email.subject, @@ -37,6 +54,16 @@ export const sendGroupedEmail = async ({ }, } - const notification = await copilot.createNotification(payload) - return notification.id + logger.log('flush-grouped-email: createNotification payload (grouped summary)', { payload }) + try { + const notification = await copilot.createNotification(payload) + return notification?.id + } catch (e: unknown) { + // Account for workspaces without multi-companies, which reject senderCompanyId (mirrors NotificationService). + if (isMessagableError(e) && e.body?.message === 'sender company ID is invalid based on sender') { + const notification = await copilot.createNotification({ ...payload, senderCompanyId: undefined }) + return notification?.id + } + throw e + } } diff --git a/src/jobs/notifications/send-grouped-reminder-email.ts b/src/jobs/notifications/send-grouped-reminder-email.ts index c8f78feb9..37cc33961 100644 --- a/src/jobs/notifications/send-grouped-reminder-email.ts +++ b/src/jobs/notifications/send-grouped-reminder-email.ts @@ -37,5 +37,7 @@ export const sendGroupedReminderEmail = async ({ } const notification = await copilot.createNotification(payload) + // Reminder emails never pass a notificationSettingId, so the platform can't suppress them. + if (!notification) throw new Error('sendGroupedReminderEmail: notification was unexpectedly suppressed') return notification.id } diff --git a/src/jobs/notifications/send-reminder-email.ts b/src/jobs/notifications/send-reminder-email.ts index dcbdaae04..b3f5d1ee5 100644 --- a/src/jobs/notifications/send-reminder-email.ts +++ b/src/jobs/notifications/send-reminder-email.ts @@ -64,5 +64,7 @@ export const sendReminderEmail = async ({ } const notification = await copilot.createNotification(payload) + // Reminder emails never pass a notificationSettingId, so the platform can't suppress them. + if (!notification) throw new Error('sendReminderEmail: notification was unexpectedly suppressed') return notification.id } diff --git a/src/jobs/notifications/send-reply-create-notifications.ts b/src/jobs/notifications/send-reply-create-notifications.ts index 630a0487e..812d34653 100644 --- a/src/jobs/notifications/send-reply-create-notifications.ts +++ b/src/jobs/notifications/send-reply-create-notifications.ts @@ -5,9 +5,11 @@ import { CopilotAPI } from '@/utils/CopilotAPI' import { isMessagableError } from '@/utils/copilotError' import { CommentRepository } from '@/app/api/comments/comment.repository' import { CommentService } from '@/app/api/comments/comment.service' +import { NotificationService } from '@/app/api/notification/notification.service' +// import { resolveIuNotificationSettingId } from '@/app/api/notification/resolveNotificationSettingId' import User from '@api/core/models/User.model' import { TasksService } from '@api/tasks/tasks.service' -import { Comment, CommentInitiator, Task } from '@prisma/client' +import { Comment, CommentInitiator, GroupedEmailEventType, Task } from '@prisma/client' import { logger, task } from '@trigger.dev/sdk/v3' import { z } from 'zod' @@ -35,6 +37,7 @@ export const sendReplyCreateNotifications = task({ const commentsRepo = new CommentRepository(user) const copilot = new CopilotAPI(user.token) + const notificationService = new NotificationService(user) const senderId = z .string() @@ -46,11 +49,33 @@ export const sendReplyCreateNotifications = task({ const deliveryTargets = await getNotificationDetails(copilot, user, comment) + // Replies are buffered as COMMENT grouped events like top-level comments. + // Gating disabled until Copilot exposes a per-IU preference read endpoint — ship IUs ungated. + const notificationSettingId = undefined + // const notificationSettingId = await resolveIuNotificationSettingId({ copilot, workspaceId: user.workspaceId, category: GroupedEmailEventType.COMMENT }) + const notificationPromises: Promise[] = [] - const queueNotificationPromise = (promise: Promise): void => { + const queueNotificationPromise = (promise: Promise): void => { notificationPromises.push(copilotBottleneck.schedule(() => promise)) } + const shared = { + copilot, + notificationService, + task: payload.task, + senderId, + senderType, + senderCompanyId, + deliveryTargets, + commentId: comment.id, + // NOTE: We are sending payload.task.companyId here. This might sound silly, i agree. + // However, it is very safe to assume that client users can ONLY reply to comments in tasks + // assigned to their company, or to them. In both cases, payload.task.companyId works + // For IU tasks, this will be undefined + initiatorCompanyId: payload.task.companyId || undefined, + notificationSettingId, + } + // Get all initiators involved in thread except the current user const threadInitiators = (await commentsRepo.getFirstCommentInitiators([comment.parentId], 10_000)).filter( (initiator) => initiator.initiatorId !== senderId, @@ -58,19 +83,7 @@ export const sendReplyCreateNotifications = task({ // Queue notifications to every unique reply initiator for (let initiator of threadInitiators) { - const promise = getInitiatorNotificationPromises( - copilot, - initiator, - senderId, - senderType, - senderCompanyId, - deliveryTargets, - // NOTE: We are sending payload.task.companyId here. This might sound silly, i agree. - // However, it is very safe to assume that client users can ONLY reply to comments in tasks - // assigned to their company, or to them. In both cases, payload.task.companyId works - // For IU tasks, this will be undefined - payload.task.companyId || undefined, - ) + const promise = getInitiatorNotificationPromises({ ...shared, initiator }) promise && queueNotificationPromise(promise) // It's certain we will get a promise here } @@ -87,27 +100,9 @@ export const sendReplyCreateNotifications = task({ (initiator) => initiator.initiatorId === parentComment.initiatorId, ) if (!isParentCommentDeleted && !parentInitiatorIsCurrentUser && !isNotificationAlreadySent) { - const typedPromise = getInitiatorNotificationPromises( - copilot, - parentComment, - senderId, - senderType, - senderCompanyId, - deliveryTargets, - payload.task.companyId || undefined, - ) + const typedPromise = getInitiatorNotificationPromises({ ...shared, initiator: parentComment }) // If there is no "initiatorType" for parentComment we have to be slightly creative (coughhackycough) - const promise = - typedPromise ?? - getNotificationToUntypedInitiator( - copilot, - parentComment, - payload.task, - senderId, - senderType, - senderCompanyId, - deliveryTargets, - ) + const promise = typedPromise ?? getNotificationToUntypedInitiator({ ...shared, parentComment }) queueNotificationPromise(promise) } } @@ -148,37 +143,81 @@ const getNotificationDetails = async (copilot: CopilotAPI, user: User, comment: return deliveryTargets } -const getInitiatorNotificationPromises = ( - copilot: CopilotAPI, +type ReplyDispatchArgs = { + copilot: CopilotAPI + notificationService: NotificationService + task: Task // Initiator in this context means previous initiators that were active in the thread, NOT the currently commenting user - initiator: { initiatorId: string; initiatorType: CommentInitiator | null }, - senderId: string, - senderType: NotificationSender, - senderCompanyId: string | undefined, - deliveryTargets: { inProduct: Record<'title', any>; email: object }, - initiatorCompanyId?: string, + initiator: { initiatorId: string; initiatorType: CommentInitiator | null } + senderId: string + senderType: NotificationSender + senderCompanyId: string | undefined + deliveryTargets: { inProduct: Record<'title', any>; email: object } + initiatorCompanyId?: string + commentId: string // Forces recipient branch when initiator.initiatorType is unset (legacy comments) - assume?: CommentInitiator, -) => { + assume?: CommentInitiator + notificationSettingId?: string +} + +const getInitiatorNotificationPromises = ({ + copilot, + notificationService, + task: parentTask, + initiator, + senderId, + senderType, + senderCompanyId, + deliveryTargets, + initiatorCompanyId, + commentId, + assume, + notificationSettingId, +}: ReplyDispatchArgs) => { const base = { senderId, senderType, senderCompanyId } - let body: NotificationRequestBody - if (initiator.initiatorType === CommentInitiator.internalUser || assume === CommentInitiator.internalUser) { - body = { + const isIu = initiator.initiatorType === CommentInitiator.internalUser || assume === CommentInitiator.internalUser + const isClient = initiator.initiatorType === CommentInitiator.client || assume === CommentInitiator.client + if (!isIu && !isClient) return null + + if (isIu) { + // IU: fire the in-product notification now (the platform gates it per the IU's preference via the + // setting id) and buffer the email as a COMMENT event so it groups + gates like top-level comments. + const iuBase = { ...base, recipientInternalUserId: initiator.initiatorId, - deliveryTargets: { inProduct: deliveryTargets.inProduct }, - } - } else if (initiator.initiatorType === CommentInitiator.client || assume === CommentInitiator.client) { - body = { - ...base, - recipientClientId: initiator.initiatorId, - recipientCompanyId: initiatorCompanyId, - deliveryTargets: { email: deliveryTargets.email }, + ...(notificationSettingId ? { notificationSettingId } : {}), } - } else { - return null + const inProductBody: NotificationRequestBody = { ...iuBase, deliveryTargets: { inProduct: deliveryTargets.inProduct } } + const emailBody: NotificationRequestBody = { ...iuBase, deliveryTargets: { email: deliveryTargets.email } } + return Promise.all([ + createNotificationWithCompanyFallback(copilot, inProductBody), + notificationService.bufferGroupedEmailEvent({ + task: parentTask, + recipientId: initiator.initiatorId, + isRecipientIu: true, + eventType: GroupedEmailEventType.COMMENT, + commentId, + individualEmail: emailBody, + }), + ]) } - return createNotificationWithCompanyFallback(copilot, body) + + // Client: buffer the reply email only (clients get no in-product reply notification today). + const clientEmailBody: NotificationRequestBody = { + ...base, + recipientClientId: initiator.initiatorId, + recipientCompanyId: initiatorCompanyId, + deliveryTargets: { email: deliveryTargets.email }, + } + return notificationService.bufferGroupedEmailEvent({ + task: parentTask, + recipientId: initiator.initiatorId, + companyId: initiatorCompanyId, + isRecipientIu: false, + eventType: GroupedEmailEventType.COMMENT, + commentId, + individualEmail: clientEmailBody, + }) } // Single-company workspaces reject senderCompanyId; retry without it on that specific error. @@ -194,42 +233,20 @@ const createNotificationWithCompanyFallback = async (copilot: CopilotAPI, body: } } -const getNotificationToUntypedInitiator = async ( - copilot: CopilotAPI, - parentComment: Comment, - task: Task, - senderId: string, - senderType: NotificationSender, - senderCompanyId: string | undefined, - deliveryTargets: { inProduct: Record<'title', any>; email: object }, -) => { +const getNotificationToUntypedInitiator = async ({ + parentComment, + ...shared +}: Omit & { parentComment: Comment }) => { + const withInitiator = { ...shared, initiator: parentComment } try { - await copilot.getInternalUser(parentComment.initiatorId) + await shared.copilot.getInternalUser(parentComment.initiatorId) // `assume` guarantees a non-null promise - return getInitiatorNotificationPromises( - copilot, - parentComment, - senderId, - senderType, - senderCompanyId, - deliveryTargets, - task.companyId || undefined, - CommentInitiator.internalUser, - )! + return getInitiatorNotificationPromises({ ...withInitiator, assume: CommentInitiator.internalUser })! } catch (e) { console.error(e) } - return getInitiatorNotificationPromises( - copilot, - parentComment, - senderId, - senderType, - senderCompanyId, - deliveryTargets, - task.companyId || undefined, - CommentInitiator.client, - )!.catch((e) => { + return getInitiatorNotificationPromises({ ...withInitiator, assume: CommentInitiator.client })!.catch((e) => { console.error(e) return undefined }) diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 000000000..a55f42d4c --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,18 @@ +import { NextResponse, type NextRequest } from 'next/server' + +export function proxy(request: NextRequest) { + if (request.method === 'HEAD') { + return new NextResponse(null, { + status: 200, + headers: { + 'Cache-Control': 'no-store', + }, + }) + } + + return NextResponse.next() +} + +export const config = { + matcher: ['/detail/:task_id/:user_type'], +} diff --git a/src/types/common.ts b/src/types/common.ts index 79acc7bd9..119adb64f 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -206,6 +206,9 @@ export const NotificationRequestBodySchema = z recipientInternalUserId: z.string().optional(), recipientClientId: z.string().optional(), recipientCompanyId: z.string().optional(), + // When set, the platform resolves each requested surface against the recipient IU's preference + // for this setting and silently drops suppressed surfaces (IU recipients only). + notificationSettingId: z.string().optional(), deliveryTargets: z .object({ inProduct: z @@ -220,6 +223,20 @@ export const NotificationRequestBodySchema = z }) .superRefine(validateNotificationRecipient) +// Declared notification settings for the app. +export const NotificationSettingSchema = z.object({ + id: z.string(), + label: z.string(), + surfaces: z.array(z.enum(['product', 'email'])), + default: z.object({ product: z.boolean().optional(), email: z.boolean().optional() }).optional(), +}) +export type NotificationSetting = z.infer + +export const NotificationSettingsResponseSchema = z.object({ + notifications: z.array(NotificationSettingSchema).default([]), +}) +export type NotificationSettingsResponse = z.infer + export const ScrapMediaRequestSchema = z.object({ filePath: z.string(), taskId: z.string().uuid().optional(), diff --git a/src/utils/CopilotAPI.ts b/src/utils/CopilotAPI.ts index 20b721548..82e4ed7e6 100644 --- a/src/utils/CopilotAPI.ts +++ b/src/utils/CopilotAPI.ts @@ -32,6 +32,8 @@ import { NotificationRequestBody, NotificationResponseSchema, NotificationResponseType, + NotificationSettingsResponse, + NotificationSettingsResponseSchema, Token, TokenSchema, WorkspaceResponse, @@ -228,7 +230,7 @@ export class CopilotAPI { return InternalUsersSchema.parse(await this.copilot.retrieveInternalUser({ id })) } - async _createNotification(requestBody: NotificationRequestBody): Promise { + async _createNotification(requestBody: NotificationRequestBody): Promise { console.info('CopilotAPI#_createNotification', this.token) // Direct REST call instead of the SDK: the SDK request type omits `deliveryTargets.email.htmlBody`, // so HTML email bodies only reach Copilot when we post the request body ourselves. @@ -244,6 +246,13 @@ export class CopilotAPI { method: 'POST', body: requestBody, }) + // When a notificationSettingId is supplied and the recipient IU has every requested surface + // turned off, the platform suppresses the notification: the call succeeds (2xx) but returns no + // created object. Treat that as a no-op rather than failing the schema parse. + if (!notification?.id) { + console.info('CopilotAPI#_createNotification | no notification created (suppressed by recipient preference)') + return null + } return NotificationCreatedResponseSchema.parse(notification) } @@ -346,6 +355,21 @@ export class CopilotAPI { }) } + // Declared notification settings for this app in the current workspace. + async _getNotificationSettings(): Promise { + console.info('CopilotAPI#_getNotificationSettings') + const appId = z.string({ message: 'Missing AppID in environment' }).parse(APP_ID) + const installs = await this.copilot.listAppInstalls() + const install = installs.find((entry) => entry.appId === appId) + if (!install?.id) { + console.info('CopilotAPI#_getNotificationSettings | No matching app install in workspace; no settings') + return { notifications: [] } + } + const workspaceId = await this._resolveWorkspaceId() + const response = await this._manualFetch(`installs/${install.id}/notification-settings`, undefined, workspaceId) + return NotificationSettingsResponseSchema.parse(response) + } + async dispatchWebhook( eventName: DISPATCHABLE_EVENT, { @@ -410,6 +434,7 @@ export class CopilotAPI { bulkDeleteNotifications = this.wrapWithRetry(this._bulkDeleteNotifications) manualFetch = this.wrapWithRetry(this._manualFetch) getIUNotification = this.wrapWithRetry(this._getIUNotification) + getNotificationSettings = this.wrapWithRetry(this._getNotificationSettings) } const cachedFetchInternalUser = cache(