Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/jobs/notifications/dispatch-grouped-reminder-email.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
3 changes: 3 additions & 0 deletions src/jobs/notifications/dispatch-grouped-reminder-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand Down
10 changes: 10 additions & 0 deletions src/jobs/notifications/dispatch-reminder-email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))

Expand Down
3 changes: 3 additions & 0 deletions src/jobs/notifications/dispatch-reminder-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand Down
12 changes: 12 additions & 0 deletions src/jobs/notifications/reminder-idempotency.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
48 changes: 48 additions & 0 deletions src/jobs/notifications/send-grouped-reminder-email.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
6 changes: 2 additions & 4 deletions src/jobs/notifications/send-grouped-reminder-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const sendGroupedReminderEmail = async ({
recipientClientId,
recipientCompanyId,
copilot,
}: SendGroupedReminderEmailArgs): Promise<string> => {
}: SendGroupedReminderEmailArgs): Promise<string | null> => {
const email = renderGroupedReminderEmail(entries)

const payload: NotificationRequestBody = {
Expand All @@ -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
}
16 changes: 16 additions & 0 deletions src/jobs/notifications/send-reminder-email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })

Expand Down
6 changes: 2 additions & 4 deletions src/jobs/notifications/send-reminder-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const sendReminderEmail = async ({
isCompanyRecipient,
workspace,
copilot,
}: SendReminderEmailArgs): Promise<string> => {
}: SendReminderEmailArgs): Promise<string | null> => {
// 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)
Expand Down Expand Up @@ -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
}
Loading