diff --git a/src/jobs/notifications/send-reminder-email.test.ts b/src/jobs/notifications/send-reminder-email.test.ts new file mode 100644 index 000000000..5628c4403 --- /dev/null +++ b/src/jobs/notifications/send-reminder-email.test.ts @@ -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') + }) +}) diff --git a/src/jobs/notifications/send-reminder-email.ts b/src/jobs/notifications/send-reminder-email.ts new file mode 100644 index 000000000..f30441e7d --- /dev/null +++ b/src/jobs/notifications/send-reminder-email.ts @@ -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 + 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 => { + const details = getReminderEmailDetails(workspace, task, isCompanyRecipient)[reminderType] + + const payload: NotificationRequestBody = { + senderId: task.createdById, + senderType: 'internalUser', + recipientClientId, + recipientCompanyId: recipientCompanyId ?? undefined, + deliveryTargets: { + email: { + subject: details.subject, + header: details.header, + title: details.title, + body: details.body, + }, + }, + } + + const notification = await copilot.createNotification(payload) + return notification.id +}