Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b37755b
feat(OUT-3730): add send-task-reminders scheduled task
arpandhakal May 25, 2026
67ac802
refactor(OUT-3730): init per-workspace CopilotAPI via workspace-scope…
arpandhakal May 25, 2026
abef42e
refactor(OUT-3730): swap raw INSERT for prisma createManyAndReturn
arpandhakal May 25, 2026
2e4ced4
chore(OUT-3730): trim comments in reminder cron + helper
arpandhakal May 25, 2026
b34ab8c
refactor(OUT-3730): fold title + createdById into EligibilityRow
arpandhakal May 25, 2026
84f82d2
chore(OUT-3736): trim comments in eligibility.ts
arpandhakal May 25, 2026
ace17f1
Merge branch 'feature/email-reminders' into OUT-3730
arpandhakal May 25, 2026
c1cf5f7
fix(OUT-3735): drop `<brand> portal:` prefix from reminder subjects
arpandhakal May 26, 2026
29d5a00
fix(OUT-3735): use <br><br> for paragraph breaks in reminder bodies
arpandhakal May 26, 2026
a93fdaf
revert(OUT-3735): restore \n\n separator in reminder bodies
arpandhakal May 26, 2026
cbab0eb
perf(OUT-3730): offload reminder sends to dispatchReminderEmail task
arpandhakal May 26, 2026
7c99164
fix(OUT-3730): chunk batchTrigger at 500 and compensate ledger on fai…
arpandhakal May 26, 2026
d340458
refactor(OUT-3730): rename row → task per PR review (priosshrsth)
arpandhakal May 26, 2026
b0b1337
fix(OUT-3730): contain getCompanyClients failure to the failing task
arpandhakal May 26, 2026
4961236
refactor(OUT-3730): address PR review feedback (priosshrsth)
arpandhakal May 26, 2026
0f1adb0
refactor(OUT-3730): extract dispatchChunk helper
arpandhakal May 26, 2026
1ddb459
Merge branch 'feature/email-reminders' of https://github.com/assembly…
arpandhakal May 26, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Please make sure to complete this task by the due date.",
"taskId": "task_1",
},
"header": "A task was assigned to your company",
"subject": "Acme portal: [Due Soon] Task due in 3 days",
"subject": "[Due Soon] Task due in 3 days",
"title": "View task",
},
"DUE_DATE_OVERDUE_3D": {
Expand All @@ -19,7 +19,7 @@ Please make sure to complete this task by the due date.",
"taskId": "task_1",
},
"header": "A task was assigned to your company",
"subject": "Acme portal: [Overdue] Task was due 3 days ago",
"subject": "[Overdue] Task was due 3 days ago",
"title": "View task",
},
"DUE_DATE_OVERDUE_7D": {
Expand All @@ -30,7 +30,7 @@ Please complete this task as soon as possible.",
"taskId": "task_1",
},
"header": "A task was assigned to your company",
"subject": "Acme portal: [Overdue] Task overdue by one week",
"subject": "[Overdue] Task overdue by one week",
"title": "View task",
},
"DUE_DATE_TODAY": {
Expand All @@ -41,7 +41,7 @@ Please complete this task as soon as possible.",
"taskId": "task_1",
},
"header": "A task was assigned to your company",
"subject": "Acme portal: [Due Soon] Task due today",
"subject": "[Due Soon] Task due today",
"title": "View task",
},
"NO_DUE_DATE_3D": {
Expand All @@ -52,7 +52,7 @@ If you've already completed this task, please mark it as done in the portal.",
"taskId": "task_1",
},
"header": "A task was assigned to your company",
"subject": "Acme portal: [Reminder] You have a task to complete",
"subject": "[Reminder] You have a task to complete",
"title": "View task",
},
"NO_DUE_DATE_7D": {
Expand All @@ -63,7 +63,7 @@ If you've already completed this task, please mark it as done in the portal.",
"taskId": "task_1",
},
"header": "A task was assigned to your company",
"subject": "Acme portal: [Reminder] Task still pending",
"subject": "[Reminder] Task still pending",
"title": "View task",
},
}
Expand All @@ -79,7 +79,7 @@ Please make sure to complete this task by the due date.",
"taskId": "task_1",
},
"header": "A task was assigned to you",
"subject": "Acme portal: [Due Soon] Task due in 3 days",
"subject": "[Due Soon] Task due in 3 days",
"title": "View task",
},
"DUE_DATE_OVERDUE_3D": {
Expand All @@ -88,7 +88,7 @@ Please make sure to complete this task by the due date.",
"taskId": "task_1",
},
"header": "A task was assigned to you",
"subject": "Acme portal: [Overdue] Task was due 3 days ago",
"subject": "[Overdue] Task was due 3 days ago",
"title": "View task",
},
"DUE_DATE_OVERDUE_7D": {
Expand All @@ -99,7 +99,7 @@ Please complete this task as soon as possible.",
"taskId": "task_1",
},
"header": "A task was assigned to you",
"subject": "Acme portal: [Overdue] Task overdue by one week",
"subject": "[Overdue] Task overdue by one week",
"title": "View task",
},
"DUE_DATE_TODAY": {
Expand All @@ -110,7 +110,7 @@ Please complete this task as soon as possible.",
"taskId": "task_1",
},
"header": "A task was assigned to you",
"subject": "Acme portal: [Due Soon] Task due today",
"subject": "[Due Soon] Task due today",
"title": "View task",
},
"NO_DUE_DATE_3D": {
Expand All @@ -121,7 +121,7 @@ If you've already completed this task, please mark it as done in the portal.",
"taskId": "task_1",
},
"header": "A task was assigned to you",
"subject": "Acme portal: [Reminder] You have a task to complete",
"subject": "[Reminder] You have a task to complete",
"title": "View task",
},
"NO_DUE_DATE_7D": {
Expand All @@ -132,7 +132,7 @@ If you've already completed this task, please mark it as done in the portal.",
"taskId": "task_1",
},
"header": "A task was assigned to you",
"subject": "Acme portal: [Reminder] Task still pending",
"subject": "[Reminder] Task still pending",
"title": "View task",
},
}
Expand Down
9 changes: 5 additions & 4 deletions src/app/api/notification/notification.helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ describe('getReminderEmailDetails', () => {
expect(result[TaskReminderType.NO_DUE_DATE_3D].header).toBe('A task was assigned to your team')
})

it('falls back gracefully when brandName is missing', () => {
const noBrand: WorkspaceResponse = { ...workspace, brandName: undefined }
const result = getReminderEmailDetails(noBrand, task, false)
expect(result[TaskReminderType.NO_DUE_DATE_3D].subject).toBe('portal: [Reminder] You have a task to complete')
it('omits any `<brand> portal:` prefix from subjects (Copilot prepends it server-side)', () => {
const result = getReminderEmailDetails(workspace, task, false)
for (const variant of Object.values(TaskReminderType)) {
expect(result[variant].subject).not.toMatch(/portal:/i)
}
})

it('emits ctaParams with the task id for every variant', () => {
Expand Down
25 changes: 8 additions & 17 deletions src/app/api/notification/notification.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,16 +202,8 @@ export const getEmailDetails = (
}
}

/**
* Helper function that returns reminder email content for each TaskReminderType variant.
* Lifecycle is independent from `getEmailDetails` (which is keyed by NotificationTaskActions).
* @param {WorkspaceResponse} workspace - Workspace whose brandName fronts the subject and
* whose labels resolve the company term.
* @param {Pick<Task, 'id' | 'title'>} task - Task being reminded about. Used for ctaParams and body interpolation.
* @param {boolean} isCompanyRecipient - True if recipient is a company (header reads "your {groupTerm}"),
* false for an individual recipient (header reads "you").
* @returns Reminder email content keyed by TaskReminderType.
*/
// Subjects intentionally omit any `<brandName> portal:` prefix — Copilot's email
// service prepends that itself, and adding it here results in a duplicated prefix.
export const getReminderEmailDetails = (
workspace: WorkspaceResponse,
task: Pick<Task, 'id' | 'title'>,
Expand All @@ -226,50 +218,49 @@ export const getReminderEmailDetails = (
ctaParams: { taskId: string }
}
> => {
const portalPrefix = `${workspace.brandName ?? ''} portal:`.trimStart()
const labels = getWorkspaceLabels(workspace)
const header = isCompanyRecipient ? `A task was assigned to your ${labels.groupTerm}` : 'A task was assigned to you'
const ctaParams = { taskId: task.id }
const title = 'View task'

return {
[TaskReminderType.NO_DUE_DATE_3D]: {
subject: `${portalPrefix} [Reminder] You have a task to complete`,
subject: '[Reminder] You have a task to complete',
header,
title,
body: `This is a friendly reminder that you have a task ‘${task.title}’ assigned to you that's still pending completion.\n\nIf you've already completed this task, please mark it as done in the portal.`,
ctaParams,
},
[TaskReminderType.NO_DUE_DATE_7D]: {
subject: `${portalPrefix} [Reminder] Task still pending`,
subject: '[Reminder] Task still pending',
header,
title,
body: `This is a friendly reminder that you have a task ‘${task.title}’ that was assigned to you a week ago and is still pending.\n\nIf you've already completed this task, please mark it as done in the portal.`,
ctaParams,
},
[TaskReminderType.DUE_DATE_BEFORE_3D]: {
subject: `${portalPrefix} [Due Soon] Task due in 3 days`,
subject: '[Due Soon] Task due in 3 days',
header,
title,
body: `This is a friendly reminder that you have a task ‘${task.title}’ due in 3 days.\n\nPlease make sure to complete this task by the due date.`,
ctaParams,
},
[TaskReminderType.DUE_DATE_TODAY]: {
subject: `${portalPrefix} [Due Soon] Task due today`,
subject: '[Due Soon] Task due today',
header,
title,
body: `This is a friendly reminder that you have a task ‘${task.title}’ due today.\n\nPlease complete this task as soon as possible.`,
ctaParams,
},
[TaskReminderType.DUE_DATE_OVERDUE_3D]: {
subject: `${portalPrefix} [Overdue] Task was due 3 days ago`,
subject: '[Overdue] Task was due 3 days ago',
header,
title,
body: `This is a friendly reminder that the task ‘${task.title}’ is now overdue. It was due 3 days ago and is still pending completion.`,
ctaParams,
},
[TaskReminderType.DUE_DATE_OVERDUE_7D]: {
subject: `${portalPrefix} [Overdue] Task overdue by one week`,
subject: '[Overdue] Task overdue by one week',
header,
title,
body: `This is a friendly reminder that the task ‘${task.title}’ is now one week overdue.\n\nPlease complete this task as soon as possible.`,
Expand Down
110 changes: 110 additions & 0 deletions src/jobs/notifications/dispatch-reminder-email.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { TaskReminderType } from '@prisma/client'

const mockSendReminderEmail = jest.fn()
const mockTaskReminderSentDelete = jest.fn()
const mockCopilotApiCtor = 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('@/lib/db', () => ({
__esModule: true,
default: {
getInstance: () => ({
taskReminderSent: { delete: mockTaskReminderSentDelete },
}),
},
}))

jest.mock('@/utils/CopilotAPI', () => ({
CopilotAPI: jest.fn().mockImplementation((...args: unknown[]) => {
mockCopilotApiCtor(...args)
return {}
}),
}))

jest.mock('./send-reminder-email', () => ({
sendReminderEmail: (...args: unknown[]) => mockSendReminderEmail(...args),
}))

import {
DispatchReminderEmailPayload,
dispatchReminderEmailOnFailure,
dispatchReminderEmailRun,
} from './dispatch-reminder-email'

const buildPayload = (overrides: Partial<DispatchReminderEmailPayload> = {}): DispatchReminderEmailPayload => ({
ledgerId: 'ledger_1',
workspaceId: 'ws_1',
task: { id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' },
recipientClientId: 'client_1',
recipientCompanyId: 'company_1',
reminderType: TaskReminderType.NO_DUE_DATE_3D,
isCompanyRecipient: false,
workspace: { id: 'ws_1', brandName: 'Acme' },
...overrides,
})

describe('dispatchReminderEmail', () => {
beforeEach(() => {
jest.clearAllMocks()
mockSendReminderEmail.mockReset()
mockTaskReminderSentDelete.mockReset()
mockCopilotApiCtor.mockReset()
})

describe('run', () => {
it('mints a workspace-scoped CopilotAPI and forwards the payload to sendReminderEmail', async () => {
mockSendReminderEmail.mockResolvedValueOnce('notif_1')

const result = await dispatchReminderEmailRun(buildPayload())

expect(mockCopilotApiCtor).toHaveBeenCalledWith('', 'ws_1/test-api-key')
expect(mockSendReminderEmail).toHaveBeenCalledTimes(1)
expect(mockSendReminderEmail.mock.calls[0][0]).toMatchObject({
task: { id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' },
recipientClientId: 'client_1',
recipientCompanyId: 'company_1',
reminderType: TaskReminderType.NO_DUE_DATE_3D,
isCompanyRecipient: false,
})
expect(result).toEqual({ ledgerId: 'ledger_1', notificationId: 'notif_1', sent: true })
})

it('rethrows so Trigger.dev can apply its retry policy', async () => {
mockSendReminderEmail.mockRejectedValueOnce(new Error('copilot 5xx'))

await expect(dispatchReminderEmailRun(buildPayload())).rejects.toThrow('copilot 5xx')
expect(mockTaskReminderSentDelete).not.toHaveBeenCalled() // compensation is onFailure's job, not run's
})
})

describe('onFailure', () => {
it('deletes the ledger row so the next cron run can retry', async () => {
mockTaskReminderSentDelete.mockResolvedValueOnce({ id: 'ledger_1' })

await dispatchReminderEmailOnFailure({
payload: buildPayload(),
error: new Error('all retries exhausted'),
})

expect(mockTaskReminderSentDelete).toHaveBeenCalledWith({ where: { id: 'ledger_1' } })
})

it('does not throw if the ledger DELETE itself fails (logs and moves on)', async () => {
mockTaskReminderSentDelete.mockRejectedValueOnce(new Error('db blew up'))

await expect(
dispatchReminderEmailOnFailure({
payload: buildPayload(),
error: new Error('all retries exhausted'),
}),
).resolves.toBeUndefined()
})
})
})
72 changes: 72 additions & 0 deletions src/jobs/notifications/dispatch-reminder-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import 'server-only'

import { copilotAPIKey } from '@/config'
import DBClient from '@/lib/db'
import { WorkspaceResponse } from '@/types/common'
import { CopilotAPI } from '@/utils/CopilotAPI'
import { serializeError } from '@/utils/serializeError'
import { Task, TaskReminderType } from '@prisma/client'
import { logger, task, tasks } from '@trigger.dev/sdk/v3'

import { sendReminderEmail } from './send-reminder-email'

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

const TASK_ID = 'dispatch-reminder-email'

export const dispatchReminderEmailRun = async (payload: DispatchReminderEmailPayload) => {
const copilot = new CopilotAPI('', `${payload.workspaceId}/${copilotAPIKey}`)
const notificationId = await sendReminderEmail({
task: payload.task,
recipientClientId: payload.recipientClientId,
recipientCompanyId: payload.recipientCompanyId,
reminderType: payload.reminderType,
isCompanyRecipient: payload.isCompanyRecipient,
workspace: payload.workspace,
copilot,
})
return { ledgerId: payload.ledgerId, notificationId, sent: true as const }
}

// Fires after Trigger.dev exhausts all retries. Compensating here (instead of inside run's
// catch) avoids dropping the ledger row on transient failures a retry would have recovered.
// The SDK types the hook's payload as `unknown`; we cast once via destructure.
export const dispatchReminderEmailOnFailure = async ({ payload, error }: { payload: unknown; error: unknown }) => {
const { ledgerId, workspaceId, task, recipientClientId, reminderType } = payload as DispatchReminderEmailPayload
logger.error('dispatch-reminder-email: retries exhausted, compensating ledger', {
ledgerId,
workspaceId,
taskId: task.id,
recipientClientId,
reminderType,
error: serializeError(error),
})
const db = DBClient.getInstance()
try {
await db.taskReminderSent.delete({ where: { id: ledgerId } })
} catch (deleteErr) {
logger.error('dispatch-reminder-email: ledger compensation DELETE failed, reminder will not retry', {
ledgerId,
error: serializeError(deleteErr),
})
}
}

export const dispatchReminderEmail = task({
id: TASK_ID,
queue: { concurrencyLimit: 5 },
retry: { maxAttempts: 3, factor: 2, minTimeoutInMs: 1_000, maxTimeoutInMs: 15_000, randomize: true },
maxDuration: 30,
run: dispatchReminderEmailRun,
})

tasks.onFailure(TASK_ID, dispatchReminderEmailOnFailure)
Loading
Loading