Skip to content
Merged
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
118 changes: 118 additions & 0 deletions src/jobs/notifications/send-reminder-email.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { WorkspaceResponse } from '@/types/common'
import { CopilotAPI } from '@/utils/CopilotAPI'
import { TaskReminderType } from '@prisma/client'
import { sendReminderEmail } from './send-reminder-email'

const workspace: WorkspaceResponse = {
id: 'ws_1',
brandName: 'Acme',
labels: {
individualTerm: 'client',
individualTermPlural: 'clients',
groupTerm: 'company',
groupTermPlural: 'companies',
},
}

const task = { id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }

const buildCopilotMock = (createNotification: jest.Mock) => ({ createNotification }) as unknown as CopilotAPI

describe('sendReminderEmail', () => {
it('returns the Copilot notification id', async () => {
const createNotification = jest.fn().mockResolvedValue({ id: 'notif_1', createdAt: '2026-05-25T00:00:00Z' })

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).toBe('notif_1')
})

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' })

await sendReminderEmail({
task,
recipientClientId: 'client_1',
recipientCompanyId: 'company_1',
reminderType: TaskReminderType.NO_DUE_DATE_3D,
isCompanyRecipient: false,
workspace,
copilot: buildCopilotMock(createNotification),
})

expect(createNotification).toHaveBeenCalledTimes(1)
const payload = createNotification.mock.calls[0][0]
expect(payload).toMatchObject({
senderId: 'iu_1',
senderType: 'internalUser',
recipientClientId: 'client_1',
recipientCompanyId: 'company_1',
})
expect(payload.deliveryTargets.email).toEqual({
subject: 'Acme portal: [Reminder] You have a task to complete',
header: 'A task was assigned to you',
title: 'View task',
body: expect.stringContaining('‘Submit timesheet’'),
})
expect(payload.deliveryTargets.inProduct).toBeUndefined()
})

it('uses the company-recipient header when isCompanyRecipient=true', async () => {
const createNotification = jest.fn().mockResolvedValue({ id: 'notif_2', createdAt: '2026-05-25T00:00:00Z' })

await sendReminderEmail({
task,
recipientClientId: 'client_1',
recipientCompanyId: 'company_1',
reminderType: TaskReminderType.DUE_DATE_TODAY,
isCompanyRecipient: true,
workspace,
copilot: buildCopilotMock(createNotification),
})

const payload = createNotification.mock.calls[0][0]
expect(payload.deliveryTargets.email.header).toBe('A task was assigned to your company')
expect(payload.deliveryTargets.email.subject).toBe('Acme portal: [Due Soon] Task due today')
})

it('omits recipientCompanyId when null', async () => {
const createNotification = jest.fn().mockResolvedValue({ id: 'notif_3', createdAt: '2026-05-25T00:00:00Z' })

await sendReminderEmail({
task,
recipientClientId: 'client_1',
recipientCompanyId: null,
reminderType: TaskReminderType.NO_DUE_DATE_3D,
isCompanyRecipient: false,
workspace,
copilot: buildCopilotMock(createNotification),
})

const payload = createNotification.mock.calls[0][0]
expect(payload.recipientCompanyId).toBeUndefined()
})

it('propagates errors from Copilot (no ledger compensation here)', async () => {
const createNotification = jest.fn().mockRejectedValue(new Error('copilot 5xx'))

await expect(
sendReminderEmail({
task,
recipientClientId: 'client_1',
recipientCompanyId: 'company_1',
reminderType: TaskReminderType.NO_DUE_DATE_3D,
isCompanyRecipient: false,
workspace,
copilot: buildCopilotMock(createNotification),
}),
).rejects.toThrow('copilot 5xx')
})
})
58 changes: 58 additions & 0 deletions src/jobs/notifications/send-reminder-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import 'server-only'

import { getReminderEmailDetails } from '@/app/api/notification/notification.helpers'
import { NotificationRequestBody, WorkspaceResponse } from '@/types/common'
import { CopilotAPI } from '@/utils/CopilotAPI'
import { Task, TaskReminderType } from '@prisma/client'

export type SendReminderEmailArgs = {
task: Pick<Task, 'id' | 'title' | 'createdById'>
recipientClientId: string
recipientCompanyId: string | null
reminderType: TaskReminderType
isCompanyRecipient: boolean
workspace: WorkspaceResponse
copilot: CopilotAPI
}

/**
* Dispatches a single task reminder email via Copilot's notification API.
*
* Email-only delivery: omits `deliveryTargets.inProduct` so no in-product notification
* is created. We also deliberately skip writing to `ClientNotification` —
* `ClientNotification` tracks read-state for in-product notifications, which reminders
* don't create. Reminder dedupe state lives in `TaskReminderSent`, which the caller
* inserts on success (the unique constraint is the idempotency primitive).
*
* Throws on Copilot failure. Callers compensate by NOT inserting into
* `TaskReminderSent`, so a future cron run will retry the same `(task, recipient, type)`.
*/
export const sendReminderEmail = async ({
task,
recipientClientId,
recipientCompanyId,
reminderType,
isCompanyRecipient,
workspace,
copilot,
}: SendReminderEmailArgs): Promise<string> => {
const details = getReminderEmailDetails(workspace, task, isCompanyRecipient)[reminderType]

const payload: NotificationRequestBody = {
senderId: task.createdById,
senderType: 'internalUser',
recipientClientId,
recipientCompanyId: recipientCompanyId ?? undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 recipientCompanyId: null will cause Copilot API rejection when recipientClientId is set

When recipientCompanyId is null, the payload drops the field (line 45) but keeps recipientClientId. validateNotificationRecipient (in src/utils/notifications.ts) enforces that recipientClientId must always be paired with recipientCompanyId — and eligibility.ts line 17 confirms this is a Copilot API-level requirement for email-bearing notifications. Any client task where task.companyId is NULL in the database will produce a payload that the API rejects on every cron run, permanently blocking reminder delivery for that recipient. The test at line 86 of the test file mocks createNotification so this is not caught by the test suite. Consider throwing early when recipientCompanyId is null so the caller (OUT-3730) can skip or log the ineligible row rather than silently eating a 4xx on every run.

deliveryTargets: {
email: {
subject: details.subject,
header: details.header,
title: details.title,
body: details.body,
},
},
}

const notification = await copilot.createNotification(payload)
return notification.id
}
Loading