From c978519cdaa139c562e5d1dded6172bceb181b2d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 00:12:21 +0000 Subject: [PATCH] fix(reminders): accept suppressed email notifications Co-authored-by: Neil Raina --- .../dispatch-grouped-reminder-email.test.ts | 86 +++++++++++++++++++ .../dispatch-grouped-reminder-email.ts | 3 + .../dispatch-reminder-email.test.ts | 10 +++ .../notifications/dispatch-reminder-email.ts | 3 + .../reminder-idempotency.integration.test.ts | 12 +++ .../send-grouped-reminder-email.test.ts | 48 +++++++++++ .../send-grouped-reminder-email.ts | 6 +- .../notifications/send-reminder-email.test.ts | 16 ++++ src/jobs/notifications/send-reminder-email.ts | 6 +- 9 files changed, 182 insertions(+), 8 deletions(-) create mode 100644 src/jobs/notifications/dispatch-grouped-reminder-email.test.ts create mode 100644 src/jobs/notifications/send-grouped-reminder-email.test.ts diff --git a/src/jobs/notifications/dispatch-grouped-reminder-email.test.ts b/src/jobs/notifications/dispatch-grouped-reminder-email.test.ts new file mode 100644 index 000000000..ea3e83965 --- /dev/null +++ b/src/jobs/notifications/dispatch-grouped-reminder-email.test.ts @@ -0,0 +1,86 @@ +import { TaskReminderType } from '@prisma/client' + +const mockSendGroupedReminderEmail = jest.fn() +const mockExecuteRaw = jest.fn() +const mockCopilotApiCtor = jest.fn() +const mockCaptureException = jest.fn() + +jest.mock('@trigger.dev/sdk/v3', () => ({ + task: ({ run }: { run: (payload: unknown) => unknown }) => ({ run }), + tasks: { onFailure: () => undefined }, + logger: { log: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})) + +jest.mock('@/config', () => ({ copilotAPIKey: 'test-api-key' })) + +jest.mock('@/jobs/sentry', () => ({ + Sentry: { captureException: (...args: unknown[]) => mockCaptureException(...args) }, +})) + +jest.mock('@/lib/db', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + $executeRaw: mockExecuteRaw, + }), + }, +})) + +jest.mock('@/utils/CopilotAPI', () => ({ + CopilotAPI: jest.fn().mockImplementation((...args: unknown[]) => { + mockCopilotApiCtor(...args) + return {} + }), +})) + +jest.mock('./send-grouped-reminder-email', () => ({ + sendGroupedReminderEmail: (...args: unknown[]) => mockSendGroupedReminderEmail(...args), +})) + +import { DispatchGroupedReminderEmailPayload, dispatchGroupedReminderEmailRun } from './dispatch-grouped-reminder-email' + +const payload: DispatchGroupedReminderEmailPayload = { + ledgerIds: ['ledger_1', 'ledger_2'], + workspaceId: 'ws_1', + tasks: [ + { taskTitle: 'Submit timesheet', reminderType: TaskReminderType.NO_DUE_DATE_3D }, + { taskTitle: 'Review contract', reminderType: TaskReminderType.DUE_DATE_TODAY }, + ], + recipientClientId: 'client_1', + recipientCompanyId: 'company_1', + senderId: 'iu_1', +} + +describe('dispatchGroupedReminderEmail', () => { + beforeEach(() => { + jest.clearAllMocks() + mockSendGroupedReminderEmail.mockReset() + }) + + it('returns the created notification id', async () => { + mockSendGroupedReminderEmail.mockResolvedValueOnce('notif_1') + + const result = await dispatchGroupedReminderEmailRun(payload) + + expect(mockCopilotApiCtor).toHaveBeenCalledWith('', 'ws_1/test-api-key') + expect(result).toEqual({ ledgerIds: ['ledger_1', 'ledger_2'], notificationId: 'notif_1', sent: true }) + }) + + it('treats a missing Copilot notification as a terminal no-op', async () => { + mockSendGroupedReminderEmail.mockResolvedValueOnce(null) + + const result = await dispatchGroupedReminderEmailRun(payload) + + expect(result).toEqual({ ledgerIds: ['ledger_1', 'ledger_2'], notificationId: null, sent: false }) + expect(mockExecuteRaw).not.toHaveBeenCalled() + expect(mockCaptureException).not.toHaveBeenCalled() + }) + + it('rethrows so Trigger.dev can apply its retry policy', async () => { + mockSendGroupedReminderEmail.mockRejectedValueOnce(new Error('copilot 5xx')) + + await expect(dispatchGroupedReminderEmailRun(payload)).rejects.toThrow('copilot 5xx') + expect(mockExecuteRaw).not.toHaveBeenCalled() + expect(mockCaptureException).not.toHaveBeenCalled() + }) +}) diff --git a/src/jobs/notifications/dispatch-grouped-reminder-email.ts b/src/jobs/notifications/dispatch-grouped-reminder-email.ts index 174aa81f4..21f103991 100644 --- a/src/jobs/notifications/dispatch-grouped-reminder-email.ts +++ b/src/jobs/notifications/dispatch-grouped-reminder-email.ts @@ -30,6 +30,9 @@ export const dispatchGroupedReminderEmailRun = async (payload: DispatchGroupedRe recipientCompanyId: payload.recipientCompanyId, copilot, }) + if (notificationId === null) { + return { ledgerIds: payload.ledgerIds, notificationId, sent: false as const } + } return { ledgerIds: payload.ledgerIds, notificationId, sent: true as const } } diff --git a/src/jobs/notifications/dispatch-reminder-email.test.ts b/src/jobs/notifications/dispatch-reminder-email.test.ts index 94fb44da5..06fc491b3 100644 --- a/src/jobs/notifications/dispatch-reminder-email.test.ts +++ b/src/jobs/notifications/dispatch-reminder-email.test.ts @@ -83,6 +83,16 @@ describe('dispatchReminderEmail', () => { expect(result).toEqual({ ledgerId: 'ledger_1', notificationId: 'notif_1', sent: true }) }) + it('treats a missing Copilot notification as a terminal no-op', async () => { + mockSendReminderEmail.mockResolvedValueOnce(null) + + const result = await dispatchReminderEmailRun(buildPayload()) + + expect(result).toEqual({ ledgerId: 'ledger_1', notificationId: null, sent: false }) + expect(mockExecuteRaw).not.toHaveBeenCalled() + expect(mockCaptureException).not.toHaveBeenCalled() + }) + it('rethrows so Trigger.dev can apply its retry policy', async () => { mockSendReminderEmail.mockRejectedValueOnce(new Error('copilot 5xx')) diff --git a/src/jobs/notifications/dispatch-reminder-email.ts b/src/jobs/notifications/dispatch-reminder-email.ts index b9fafbf67..56cf419bc 100644 --- a/src/jobs/notifications/dispatch-reminder-email.ts +++ b/src/jobs/notifications/dispatch-reminder-email.ts @@ -35,6 +35,9 @@ export const dispatchReminderEmailRun = async (payload: DispatchReminderEmailPay workspace: payload.workspace, copilot, }) + if (notificationId === null) { + return { ledgerId: payload.ledgerId, notificationId, sent: false as const } + } return { ledgerId: payload.ledgerId, notificationId, sent: true as const } } diff --git a/src/jobs/notifications/reminder-idempotency.integration.test.ts b/src/jobs/notifications/reminder-idempotency.integration.test.ts index 7d615ed16..b4835bcda 100644 --- a/src/jobs/notifications/reminder-idempotency.integration.test.ts +++ b/src/jobs/notifications/reminder-idempotency.integration.test.ts @@ -122,6 +122,18 @@ describe('reminder idempotency (real DB)', () => { expect(await getTestDb().taskReminderSent.count()).toBe(1) }) + it('keeps the ledger and does not report when Copilot creates no notification', async () => { + await seedEligibleClientTask() + mockCreateNotification.mockResolvedValue(null) + + await runCron() + await runCron() + + expect(mockCreateNotification).toHaveBeenCalledTimes(1) + expect(await getTestDb().taskReminderSent.count()).toBe(1) + expect(mockCaptureException).not.toHaveBeenCalled() + }) + it('on a terminal Copilot failure, deletes the ledger row and reports to Sentry', async () => { const { taskId, assigneeId } = await seedEligibleClientTask() mockCreateNotification.mockRejectedValue(new Error('copilot 500')) diff --git a/src/jobs/notifications/send-grouped-reminder-email.test.ts b/src/jobs/notifications/send-grouped-reminder-email.test.ts new file mode 100644 index 000000000..66118739f --- /dev/null +++ b/src/jobs/notifications/send-grouped-reminder-email.test.ts @@ -0,0 +1,48 @@ +import { CopilotAPI } from '@/utils/CopilotAPI' +import { TaskReminderType } from '@prisma/client' + +import { sendGroupedReminderEmail } from './send-grouped-reminder-email' + +const buildCopilotMock = (createNotification: jest.Mock) => ({ createNotification }) as unknown as CopilotAPI + +const args = { + entries: [{ taskTitle: 'Submit timesheet', reminderType: TaskReminderType.NO_DUE_DATE_3D }], + senderId: 'iu_1', + recipientClientId: 'client_1', + recipientCompanyId: 'company_1', +} + +describe('sendGroupedReminderEmail', () => { + it('returns the Copilot notification id', async () => { + const createNotification = jest.fn().mockResolvedValue({ id: 'notif_1' }) + + const id = await sendGroupedReminderEmail({ + ...args, + copilot: buildCopilotMock(createNotification), + }) + + expect(id).toBe('notif_1') + }) + + it('returns null when Copilot does not create a notification', async () => { + const createNotification = jest.fn().mockResolvedValue(null) + + const id = await sendGroupedReminderEmail({ + ...args, + copilot: buildCopilotMock(createNotification), + }) + + expect(id).toBeNull() + }) + + it('propagates errors from Copilot', async () => { + const createNotification = jest.fn().mockRejectedValue(new Error('copilot 5xx')) + + await expect( + sendGroupedReminderEmail({ + ...args, + copilot: buildCopilotMock(createNotification), + }), + ).rejects.toThrow('copilot 5xx') + }) +}) diff --git a/src/jobs/notifications/send-grouped-reminder-email.ts b/src/jobs/notifications/send-grouped-reminder-email.ts index 37cc33961..30c2bdd28 100644 --- a/src/jobs/notifications/send-grouped-reminder-email.ts +++ b/src/jobs/notifications/send-grouped-reminder-email.ts @@ -18,7 +18,7 @@ export const sendGroupedReminderEmail = async ({ recipientClientId, recipientCompanyId, copilot, -}: SendGroupedReminderEmailArgs): Promise => { +}: SendGroupedReminderEmailArgs): Promise => { const email = renderGroupedReminderEmail(entries) const payload: NotificationRequestBody = { @@ -37,7 +37,5 @@ 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 + return notification?.id ?? null } diff --git a/src/jobs/notifications/send-reminder-email.test.ts b/src/jobs/notifications/send-reminder-email.test.ts index 9437bbb5d..2f55da394 100644 --- a/src/jobs/notifications/send-reminder-email.test.ts +++ b/src/jobs/notifications/send-reminder-email.test.ts @@ -41,6 +41,22 @@ describe('sendReminderEmail', () => { expect(id).toBe('notif_1') }) + it('returns null when Copilot does not create a notification', async () => { + const createNotification = jest.fn().mockResolvedValue(null) + + const id = await sendReminderEmail({ + task, + recipientClientId: 'client_1', + recipientCompanyId: 'company_1', + reminderType: TaskReminderType.NO_DUE_DATE_3D, + isCompanyRecipient: false, + workspace, + copilot: buildCopilotMock(createNotification), + }) + + expect(id).toBeNull() + }) + it('builds an email-only payload (no inProduct, IU sender, client recipient)', async () => { const createNotification = jest.fn().mockResolvedValue({ id: 'notif_1', createdAt: '2026-05-25T00:00:00Z' }) diff --git a/src/jobs/notifications/send-reminder-email.ts b/src/jobs/notifications/send-reminder-email.ts index b3f5d1ee5..aa7003288 100644 --- a/src/jobs/notifications/send-reminder-email.ts +++ b/src/jobs/notifications/send-reminder-email.ts @@ -26,7 +26,7 @@ export const sendReminderEmail = async ({ isCompanyRecipient, workspace, copilot, -}: SendReminderEmailArgs): Promise => { +}: SendReminderEmailArgs): Promise => { // For opted-in workspaces, mirror the customized assignment email by using the task title as the // subject, prefixed with the escalating cadence tag (OUT-3861). const needsOverride = reminderSubjectOverrideWorkspaces.has(workspace.id) @@ -64,7 +64,5 @@ 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 + return notification?.id ?? null }