From 956dd66a5f552f61171f4a3a0ec146ca1348ab92 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 25 May 2026 14:42:16 +0545 Subject: [PATCH] feat(OUT-3737): add sendReminderEmail helper for email-only reminders Plumbs the reminder email payload through copilot.createNotification with deliveryTargets.email only. Deliberately does not write to ClientNotification (that table tracks in-product read-state, which reminders don't create) and does not write to TaskReminderSent (the caller owns the ledger insert as the dedupe primitive on success). Throws on Copilot failure so callers can skip the ledger and let the next cron run retry. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../notifications/send-reminder-email.test.ts | 118 ++++++++++++++++++ src/jobs/notifications/send-reminder-email.ts | 58 +++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/jobs/notifications/send-reminder-email.test.ts create mode 100644 src/jobs/notifications/send-reminder-email.ts 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 +}