From d3b94ad0155eec1d5b799e16d3697fbecfbcb10d Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Fri, 15 May 2026 15:02:25 +0545 Subject: [PATCH 01/27] feat(OUT-3734): add TaskReminderSents table and TaskReminderType enum Adds a minimal ledger to enforce reminder idempotency at the DB level. Unique constraint on (taskId, recipientId, reminderType) is the dedupe primitive so retries and manual re-triggers cannot double-send. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 20 ++++++++++++++++++ prisma/schema/task.prisma | 1 + prisma/schema/taskReminderSent.prisma | 21 +++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 prisma/migrations/20260515091539_add_task_reminder_sents_table/migration.sql create mode 100644 prisma/schema/taskReminderSent.prisma diff --git a/prisma/migrations/20260515091539_add_task_reminder_sents_table/migration.sql b/prisma/migrations/20260515091539_add_task_reminder_sents_table/migration.sql new file mode 100644 index 000000000..e2a163b8e --- /dev/null +++ b/prisma/migrations/20260515091539_add_task_reminder_sents_table/migration.sql @@ -0,0 +1,20 @@ +-- CreateEnum +CREATE TYPE "TaskReminderType" AS ENUM ('NO_DUE_DATE_3D', 'NO_DUE_DATE_7D', 'DUE_DATE_BEFORE_3D', 'DUE_DATE_TODAY', 'DUE_DATE_OVERDUE_3D', 'DUE_DATE_OVERDUE_7D'); + +-- CreateTable +CREATE TABLE "TaskReminderSents" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "taskId" UUID NOT NULL, + "workspaceId" VARCHAR(32) NOT NULL, + "recipientId" UUID NOT NULL, + "reminderType" "TaskReminderType" NOT NULL, + "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TaskReminderSents_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TaskReminderSents_taskId_recipientId_reminderType_key" ON "TaskReminderSents"("taskId", "recipientId", "reminderType"); + +-- AddForeignKey +ALTER TABLE "TaskReminderSents" ADD CONSTRAINT "TaskReminderSents_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema/task.prisma b/prisma/schema/task.prisma index d679cefec..84d9e1648 100644 --- a/prisma/schema/task.prisma +++ b/prisma/schema/task.prisma @@ -57,6 +57,7 @@ model Task { deletedBy String? @db.Uuid taskUpdateBacklogs TaskUpdateBacklog[] + taskReminderSents TaskReminderSent[] associations Json @db.JsonB @default("[]") isShared Boolean @default(false) diff --git a/prisma/schema/taskReminderSent.prisma b/prisma/schema/taskReminderSent.prisma new file mode 100644 index 000000000..4fb93999a --- /dev/null +++ b/prisma/schema/taskReminderSent.prisma @@ -0,0 +1,21 @@ +enum TaskReminderType { + NO_DUE_DATE_3D + NO_DUE_DATE_7D + DUE_DATE_BEFORE_3D + DUE_DATE_TODAY + DUE_DATE_OVERDUE_3D + DUE_DATE_OVERDUE_7D +} + +model TaskReminderSent { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + taskId String @db.Uuid + workspaceId String @db.VarChar(32) + recipientId String @db.Uuid + reminderType TaskReminderType + sentAt DateTime @default(now()) + + @@unique([taskId, recipientId, reminderType]) + @@map("TaskReminderSents") +} From ff14b29c8be0e6cfe916af711d5b6148917d6470 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Fri, 15 May 2026 16:29:58 +0545 Subject: [PATCH 02/27] feat(OUT-3735): add getReminderEmailDetails copy helper Returns subject/header/title/body/ctaParams for each of the six TaskReminderType variants. Header branches on whether the recipient is a company (uses workspace groupTerm) or an individual. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../notification.helpers.test.ts.snap | 139 ++++++++++++++++++ .../notification/notification.helpers.test.ts | 54 +++++++ .../api/notification/notification.helpers.ts | 78 +++++++++- 3 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap create mode 100644 src/app/api/notification/notification.helpers.test.ts diff --git a/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap b/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap new file mode 100644 index 000000000..0904c2538 --- /dev/null +++ b/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap @@ -0,0 +1,139 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getReminderEmailDetails matches snapshot for company recipient 1`] = ` +{ + "DUE_DATE_BEFORE_3D": { + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due in 3 days. + +Please make sure to complete this task by the due date.", + "ctaParams": { + "taskId": "task_1", + }, + "header": "A task was assigned to your company", + "subject": "Acme portal: [Due Soon] Task due in 3 days", + "title": "View task", + }, + "DUE_DATE_OVERDUE_3D": { + "body": "This is a friendly reminder that the task ‘Submit timesheet’ is now overdue. It was due 3 days ago and is still pending completion.", + "ctaParams": { + "taskId": "task_1", + }, + "header": "A task was assigned to your company", + "subject": "Acme portal: [Overdue] Task was due 3 days ago", + "title": "View task", + }, + "DUE_DATE_OVERDUE_7D": { + "body": "This is a friendly reminder that the task ‘Submit timesheet’ is now one week overdue. + +Please complete this task as soon as possible.", + "ctaParams": { + "taskId": "task_1", + }, + "header": "A task was assigned to your company", + "subject": "Acme portal: [Overdue] Task overdue by one week", + "title": "View task", + }, + "DUE_DATE_TODAY": { + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due today. + +Please complete this task as soon as possible.", + "ctaParams": { + "taskId": "task_1", + }, + "header": "A task was assigned to your company", + "subject": "Acme portal: [Due Soon] Task due today", + "title": "View task", + }, + "NO_DUE_DATE_3D": { + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ assigned to you that's still pending completion. + +If you've already completed this task, please mark it as done in the portal.", + "ctaParams": { + "taskId": "task_1", + }, + "header": "A task was assigned to your company", + "subject": "Acme portal: [Reminder] You have a task to complete", + "title": "View task", + }, + "NO_DUE_DATE_7D": { + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ that was assigned to you a week ago and is still pending. + +If you've already completed this task, please mark it as done in the portal.", + "ctaParams": { + "taskId": "task_1", + }, + "header": "A task was assigned to your company", + "subject": "Acme portal: [Reminder] Task still pending", + "title": "View task", + }, +} +`; + +exports[`getReminderEmailDetails matches snapshot for individual recipient 1`] = ` +{ + "DUE_DATE_BEFORE_3D": { + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due in 3 days. + +Please make sure to complete this task by the due date.", + "ctaParams": { + "taskId": "task_1", + }, + "header": "A task was assigned to you", + "subject": "Acme portal: [Due Soon] Task due in 3 days", + "title": "View task", + }, + "DUE_DATE_OVERDUE_3D": { + "body": "This is a friendly reminder that the task ‘Submit timesheet’ is now overdue. It was due 3 days ago and is still pending completion.", + "ctaParams": { + "taskId": "task_1", + }, + "header": "A task was assigned to you", + "subject": "Acme portal: [Overdue] Task was due 3 days ago", + "title": "View task", + }, + "DUE_DATE_OVERDUE_7D": { + "body": "This is a friendly reminder that the task ‘Submit timesheet’ is now one week overdue. + +Please complete this task as soon as possible.", + "ctaParams": { + "taskId": "task_1", + }, + "header": "A task was assigned to you", + "subject": "Acme portal: [Overdue] Task overdue by one week", + "title": "View task", + }, + "DUE_DATE_TODAY": { + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due today. + +Please complete this task as soon as possible.", + "ctaParams": { + "taskId": "task_1", + }, + "header": "A task was assigned to you", + "subject": "Acme portal: [Due Soon] Task due today", + "title": "View task", + }, + "NO_DUE_DATE_3D": { + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ assigned to you that's still pending completion. + +If you've already completed this task, please mark it as done in the portal.", + "ctaParams": { + "taskId": "task_1", + }, + "header": "A task was assigned to you", + "subject": "Acme portal: [Reminder] You have a task to complete", + "title": "View task", + }, + "NO_DUE_DATE_7D": { + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ that was assigned to you a week ago and is still pending. + +If you've already completed this task, please mark it as done in the portal.", + "ctaParams": { + "taskId": "task_1", + }, + "header": "A task was assigned to you", + "subject": "Acme portal: [Reminder] Task still pending", + "title": "View task", + }, +} +`; diff --git a/src/app/api/notification/notification.helpers.test.ts b/src/app/api/notification/notification.helpers.test.ts new file mode 100644 index 000000000..414fadc2b --- /dev/null +++ b/src/app/api/notification/notification.helpers.test.ts @@ -0,0 +1,54 @@ +import { WorkspaceResponse } from '@/types/common' +import { getReminderEmailDetails } from './notification.helpers' +import { TaskReminderType } from '@prisma/client' + +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' } + +describe('getReminderEmailDetails', () => { + it('returns a value for every TaskReminderType', () => { + const result = getReminderEmailDetails(workspace, task, false) + const expectedKeys = Object.values(TaskReminderType).sort() + expect(Object.keys(result).sort()).toEqual(expectedKeys) + }) + + it('matches snapshot for individual recipient', () => { + expect(getReminderEmailDetails(workspace, task, false)).toMatchSnapshot() + }) + + it('matches snapshot for company recipient', () => { + expect(getReminderEmailDetails(workspace, task, true)).toMatchSnapshot() + }) + + it('uses custom group term from workspace labels for company recipient', () => { + const customWorkspace: WorkspaceResponse = { + ...workspace, + labels: { ...workspace.labels, groupTerm: 'team' }, + } + const result = getReminderEmailDetails(customWorkspace, task, true) + 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('emits ctaParams with the task id for every variant', () => { + const result = getReminderEmailDetails(workspace, task, false) + for (const variant of Object.values(TaskReminderType)) { + expect(result[variant].ctaParams).toEqual({ taskId: 'task_1' }) + } + }) +}) diff --git a/src/app/api/notification/notification.helpers.ts b/src/app/api/notification/notification.helpers.ts index 1c66f651d..708af2eae 100644 --- a/src/app/api/notification/notification.helpers.ts +++ b/src/app/api/notification/notification.helpers.ts @@ -1,7 +1,7 @@ import { WorkspaceResponse } from '@/types/common' import { getWorkspaceLabels } from '@/utils/getWorkspaceLabels' import { NotificationTaskActions } from '@api/core/types/tasks' -import { Task } from '@prisma/client' +import { Task, TaskReminderType } from '@prisma/client' /** * Helper function that sets the in-product notification title and body for a given notification trigger @@ -201,3 +201,79 @@ 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 - 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. + */ +export const getReminderEmailDetails = ( + workspace: WorkspaceResponse, + task: Pick, + isCompanyRecipient: boolean, +): Record< + TaskReminderType, + { + title: string + subject: string + header: string + body: string + 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`, + 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`, + 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`, + 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`, + 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`, + 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`, + 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.`, + ctaParams, + }, + } +} From a3718921f789dd3ab300e1dea96d186c723a4c51 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 18 May 2026 16:04:10 +0545 Subject: [PATCH 03/27] feat(OUT-3736): add eligibility SQL for single-day reminder query Adds getEligibleReminders() that returns one row per (task, assignee, reminderType) eligible for a task reminder today, across the six exact-day windows defined in the Reminder Emails PRD. The EligibilityRow carries the companyId derived per assigneeType so the future sender can stamp ClientNotifications.companyId and Copilot's recipientCompanyId without a follow-up task lookup. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/jobs/notifications/eligibility.test.ts | 54 ++++++++++++++++ src/jobs/notifications/eligibility.ts | 75 ++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/jobs/notifications/eligibility.test.ts create mode 100644 src/jobs/notifications/eligibility.ts diff --git a/src/jobs/notifications/eligibility.test.ts b/src/jobs/notifications/eligibility.test.ts new file mode 100644 index 000000000..af67ed0b1 --- /dev/null +++ b/src/jobs/notifications/eligibility.test.ts @@ -0,0 +1,54 @@ +import { AssigneeType, TaskReminderType } from '@prisma/client' + +const mockQueryRaw = jest.fn() + +jest.mock('@/lib/db', () => ({ + __esModule: true, + default: { + getInstance: () => ({ $queryRaw: mockQueryRaw }), + }, +})) + +import DBClient from '@/lib/db' +import { getEligibleReminders } from './eligibility' + +describe('getEligibleReminders', () => { + beforeEach(() => { + mockQueryRaw.mockReset() + }) + + it('returns rows verbatim from the underlying $queryRaw call', async () => { + const rows = [ + { + taskId: 't1', + workspaceId: 'ws1', + assigneeId: 'c1', + assigneeType: AssigneeType.client, + companyId: 'co1', + reminderType: TaskReminderType.NO_DUE_DATE_3D, + }, + ] + mockQueryRaw.mockResolvedValueOnce(rows) + + const result = await getEligibleReminders(DBClient.getInstance()) + + expect(result).toEqual(rows) + }) + + it('returns an empty array when no tasks are eligible', async () => { + mockQueryRaw.mockResolvedValueOnce([]) + const result = await getEligibleReminders(DBClient.getInstance()) + expect(result).toEqual([]) + }) + + it('issues exactly one $queryRaw call', async () => { + mockQueryRaw.mockResolvedValueOnce([]) + await getEligibleReminders(DBClient.getInstance()) + expect(mockQueryRaw).toHaveBeenCalledTimes(1) + }) + + it('propagates errors from $queryRaw', async () => { + mockQueryRaw.mockRejectedValueOnce(new Error('connection refused')) + await expect(getEligibleReminders(DBClient.getInstance())).rejects.toThrow('connection refused') + }) +}) diff --git a/src/jobs/notifications/eligibility.ts b/src/jobs/notifications/eligibility.ts new file mode 100644 index 000000000..f615b5a6f --- /dev/null +++ b/src/jobs/notifications/eligibility.ts @@ -0,0 +1,75 @@ +import DBClient from '@/lib/db' +import { AssigneeType, TaskReminderType } from '@prisma/client' + +export type EligibilityRow = { + taskId: string + workspaceId: string + assigneeId: string + assigneeType: AssigneeType + /** + * Company context for the recipient. + * - assigneeType='client' → task.companyId (a client may belong to multiple companies on Copilot; + * this disambiguates which "hat" they're wearing for this task) + * - assigneeType='company' → assigneeId (the company IS the assignee; caller fans out to members) + * - assigneeType='internalUser'→ null (IUs have no company concept and never receive email notifications) + * + * Required for ClientNotifications inserts (unique key includes companyId) and for + * Copilot's recipientCompanyId on email-bearing notifications. See + * src/app/api/notification/notification.service.ts:558. + */ + companyId: string | null + reminderType: TaskReminderType +} + +/** + * Returns one row per (task, assignee, reminderType) eligible for a reminder today. + * + * Company-assigned tasks emit a single row with assigneeType='company' and assigneeId + * set to the company id. Caller fans those out to individual members via Copilot — + * the SQL deliberately stops at the company boundary so DB has no Copilot dependency. + * + * Already-sent reminders are NOT filtered here. The TaskReminderSents unique constraint + * is the dedupe primitive at insert time, so a retried cron run is idempotent without + * an extra NOT EXISTS check (the windows are exact-day so day-N reminders don't repeat + * in normal operation). + */ +export const getEligibleReminders = async (db: ReturnType): Promise => { + return db.$queryRaw` + SELECT + t.id::text AS "taskId", + t."workspaceId", + t."assigneeId"::text AS "assigneeId", + t."assigneeType" AS "assigneeType", + (CASE + WHEN t."assigneeType" = 'company' THEN t."assigneeId"::text + WHEN t."assigneeType" = 'client' THEN t."companyId"::text + ELSE NULL + END) AS "companyId", + (CASE + WHEN t."dueDate" IS NULL AND t."assignedAt"::date = CURRENT_DATE - 3 THEN 'NO_DUE_DATE_3D' + WHEN t."dueDate" IS NULL AND t."assignedAt"::date = CURRENT_DATE - 7 THEN 'NO_DUE_DATE_7D' + WHEN t."dueDate"::date = CURRENT_DATE + 3 THEN 'DUE_DATE_BEFORE_3D' + WHEN t."dueDate"::date = CURRENT_DATE THEN 'DUE_DATE_TODAY' + WHEN t."dueDate"::date = CURRENT_DATE - 3 THEN 'DUE_DATE_OVERDUE_3D' + WHEN t."dueDate"::date = CURRENT_DATE - 7 THEN 'DUE_DATE_OVERDUE_7D' + END)::"TaskReminderType" AS "reminderType" + FROM "Tasks" t + LEFT JOIN "Tasks" parent ON parent.id = t."parentId" + WHERE t."deletedAt" IS NULL + AND t."isArchived" = false + AND t."completedAt" IS NULL + AND t."assigneeId" IS NOT NULL + AND t."assigneeType" IS NOT NULL + -- Subtask carve-out: same-assignee subtasks fold into the parent reminder. + -- A NULL parent.assigneeId counts as "different" so a standalone subtask under + -- an unassigned parent still gets a reminder. + AND (t."parentId" IS NULL OR parent."assigneeId" IS DISTINCT FROM t."assigneeId") + -- Guard against malformed VARCHAR(10) dueDate values: only cast when the string + -- looks like ISO YYYY-MM-DD. Without this, a single bad row poisons the whole query. + AND (t."dueDate" IS NULL OR t."dueDate" ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$') + AND ( + (t."dueDate" IS NULL AND t."assignedAt"::date IN (CURRENT_DATE - 3, CURRENT_DATE - 7)) + OR t."dueDate"::date IN (CURRENT_DATE - 7, CURRENT_DATE - 3, CURRENT_DATE, CURRENT_DATE + 3) + ) + ` +} From 5d68cc1e73e69e416d92afe1b43572a78145c9a8 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 18 May 2026 16:29:08 +0545 Subject: [PATCH 04/27] fix(OUT-3736): apply parent lifecycle filter to subtask carve-out join Without this, a same-assignee subtask under a soft-deleted (or archived / completed) parent is silently dropped: the parent itself is filtered out by the main WHERE, but the LEFT JOIN still returns its assigneeId, which fails IS DISTINCT FROM and drops the subtask. Filtering parent lifecycle in the JOIN makes parent.assigneeId come back NULL for dead parents, so subtasks correctly emit their own reminder. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/jobs/notifications/eligibility.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/jobs/notifications/eligibility.ts b/src/jobs/notifications/eligibility.ts index f615b5a6f..8173e6301 100644 --- a/src/jobs/notifications/eligibility.ts +++ b/src/jobs/notifications/eligibility.ts @@ -54,7 +54,16 @@ export const getEligibleReminders = async (db: ReturnType Date: Mon, 25 May 2026 13:17:04 +0545 Subject: [PATCH 05/27] fix(OUT-3736): wrap dueDate regex+cast in CASE WHEN to enforce eval order Postgres does not guarantee AND-predicate evaluation order, so the separate regex guard could be reordered after the ::date cast and the cron would crash on any malformed dueDate. CASE WHEN evaluates sequentially and short-circuits, which is the documented-safe pattern for this. Behavior is unchanged for valid rows. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/jobs/notifications/eligibility.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/jobs/notifications/eligibility.ts b/src/jobs/notifications/eligibility.ts index 8173e6301..87e7915b6 100644 --- a/src/jobs/notifications/eligibility.ts +++ b/src/jobs/notifications/eligibility.ts @@ -74,12 +74,15 @@ export const getEligibleReminders = async (db: ReturnType Date: Mon, 25 May 2026 14:42:16 +0545 Subject: [PATCH 06/27] 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 +} From b37755b99b7af48890bf835d5830b17efe9f84cc Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 25 May 2026 16:05:46 +0545 Subject: [PATCH 07/27] feat(OUT-3730): add send-task-reminders scheduled task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daily 00:00 UTC cron that walks getEligibleReminders, fans out company- assigned rows to current members via getCompanyClients, and dispatches email-only notifications via sendReminderEmail. Idempotency lives in the ledger insert: a single batched INSERT ... ON CONFLICT (taskId, recipientId, reminderType) DO NOTHING RETURNING ... runs *before* any Copilot call, so retried cron runs and in-flight duplicates can never double-send. Only rows that come back from RETURNING are net-new and proceed to the send phase. On Copilot failure we DELETE the ledger row so the next cron run retries; a failing DELETE is logged distinctly so on-call can clean up the stuck row. Per-workspace CopilotAPI is minted from any task.createdById + workspaceId via encodePayload — same shape as cmd/backfill-missed-emails. Workspace bottleneck = 5 matches WORKSPACE_CONCURRENCY in auto-archive. allSettled keeps a failing workspace from aborting the sweep. IU rows are filtered in the cron rather than in the SQL to keep OUT-3736's contract untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/jobs/notifications/index.ts | 1 + .../notifications/send-task-reminders.test.ts | 237 +++++++++++++++++ src/jobs/notifications/send-task-reminders.ts | 239 ++++++++++++++++++ 3 files changed, 477 insertions(+) create mode 100644 src/jobs/notifications/send-task-reminders.test.ts create mode 100644 src/jobs/notifications/send-task-reminders.ts diff --git a/src/jobs/notifications/index.ts b/src/jobs/notifications/index.ts index 1bbf77b3d..552e8240b 100644 --- a/src/jobs/notifications/index.ts +++ b/src/jobs/notifications/index.ts @@ -2,3 +2,4 @@ export { deleteTaskNotifications } from './delete-task-notifications' export { sendTaskCreateNotifications } from './send-task-create-notifications' export { sendTaskUpdateNotifications } from './send-task-update-notifications' export { sendCommentCreateNotifications } from './send-comment-create-notifications' +export { sendTaskReminders } from './send-task-reminders' diff --git a/src/jobs/notifications/send-task-reminders.test.ts b/src/jobs/notifications/send-task-reminders.test.ts new file mode 100644 index 000000000..efc2597dd --- /dev/null +++ b/src/jobs/notifications/send-task-reminders.test.ts @@ -0,0 +1,237 @@ +import { AssigneeType, TaskReminderType } from '@prisma/client' + +// Mocks must be configured before requiring the SUT. Variables referenced inside the +// jest.mock factory must start with `mock` so the babel-jest allow-list lets the closure +// see them once the const declarations have run. +const mockQueryRaw = jest.fn() +const mockTaskFindMany = jest.fn() +const mockTaskReminderSentDelete = jest.fn() + +const mockGetEligibleReminders = jest.fn() +const mockSendReminderEmail = jest.fn() + +const mockGetWorkspace = jest.fn() +const mockGetCompanyClients = jest.fn() +const mockCopilotApiCtor = jest.fn() + +jest.mock('@trigger.dev/sdk/v3', () => ({ + schedules: { + task: ({ run }: { run: (payload: unknown, ctx?: unknown) => unknown }) => ({ run }), + }, + logger: { log: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})) + +jest.mock('@/lib/db', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + $queryRaw: mockQueryRaw, + task: { findMany: mockTaskFindMany }, + taskReminderSent: { delete: mockTaskReminderSentDelete }, + }), + }, +})) + +jest.mock('@/utils/crypto', () => ({ + encodePayload: jest.fn(() => 'stub-token'), +})) + +jest.mock('@/utils/CopilotAPI', () => ({ + CopilotAPI: jest.fn().mockImplementation((...args: unknown[]) => { + mockCopilotApiCtor(...args) + return { + getWorkspace: mockGetWorkspace, + getCompanyClients: mockGetCompanyClients, + } + }), +})) + +jest.mock('./eligibility', () => ({ + getEligibleReminders: (...args: unknown[]) => mockGetEligibleReminders(...args), +})) + +jest.mock('./send-reminder-email', () => ({ + sendReminderEmail: (...args: unknown[]) => mockSendReminderEmail(...args), +})) + +// Bypass Bottleneck's rate-limiting in tests but preserve sequential ordering per instance +// via a promise chain. Matches the pattern used in auto-archive-completed-tasks.test.ts so +// FIFO mockResolvedValueOnce queues drain deterministically. +jest.mock('bottleneck', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => { + let chain: Promise = Promise.resolve() + return { + schedule: (fn: () => Promise) => { + const next = chain.then(() => fn()) + chain = next.catch(() => undefined) + return next + }, + } + }), +})) + +import { sendTaskReminders } from './send-task-reminders' + +type RunResult = { sent: number; failed: number; skipped: number; workspaceCount: number } +const runJob = async (): Promise => { + const { run } = sendTaskReminders as unknown as { + run: (payload: { timestamp: Date }) => Promise + } + return run({ timestamp: new Date() }) +} + +const workspace = { id: 'ws_1', brandName: 'Acme' } + +const buildRow = (overrides: Partial[1]> = {}) => ({ + taskId: 'task_1', + workspaceId: 'ws_1', + assigneeId: 'client_1', + assigneeType: AssigneeType.client, + companyId: 'company_1', + reminderType: TaskReminderType.NO_DUE_DATE_3D, + ...overrides, +}) + +describe('sendTaskReminders', () => { + beforeEach(() => { + jest.clearAllMocks() + mockQueryRaw.mockReset() + mockTaskFindMany.mockReset() + mockTaskReminderSentDelete.mockReset() + mockGetEligibleReminders.mockReset() + mockSendReminderEmail.mockReset() + mockGetWorkspace.mockReset() + mockGetCompanyClients.mockReset() + mockCopilotApiCtor.mockReset() + mockGetWorkspace.mockResolvedValue(workspace) + }) + + it('exits cleanly when no rows are eligible', async () => { + mockGetEligibleReminders.mockResolvedValueOnce([]) + + const result = await runJob() + + expect(result).toEqual({ sent: 0, failed: 0, skipped: 0, workspaceCount: 0 }) + expect(mockQueryRaw).not.toHaveBeenCalled() + expect(mockSendReminderEmail).not.toHaveBeenCalled() + expect(mockCopilotApiCtor).not.toHaveBeenCalled() + }) + + it('filters out internalUser rows before any DB or Copilot work', async () => { + mockGetEligibleReminders.mockResolvedValueOnce([ + buildRow({ assigneeType: AssigneeType.internalUser, assigneeId: 'iu_1', companyId: null }), + ]) + + const result = await runJob() + + expect(result.workspaceCount).toBe(0) + expect(mockTaskFindMany).not.toHaveBeenCalled() + expect(mockQueryRaw).not.toHaveBeenCalled() + expect(mockSendReminderEmail).not.toHaveBeenCalled() + }) + + it('sends one reminder for a client-assigned task and writes one ledger row', async () => { + mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) + mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) + mockQueryRaw.mockResolvedValueOnce([ + { + id: 'ledger_1', + taskId: 'task_1', + recipientId: 'client_1', + reminderType: TaskReminderType.NO_DUE_DATE_3D, + }, + ]) + mockSendReminderEmail.mockResolvedValueOnce('notif_1') + + const result = await runJob() + + expect(result).toEqual({ sent: 1, failed: 0, skipped: 0, workspaceCount: 1 }) + 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, + workspace, + }) + }) + + it('treats ON CONFLICT returning zero rows as fully-skipped (re-run idempotency)', async () => { + mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) + mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) + mockQueryRaw.mockResolvedValueOnce([]) + + const result = await runJob() + + expect(result).toEqual({ sent: 0, failed: 0, skipped: 1, workspaceCount: 1 }) + expect(mockSendReminderEmail).not.toHaveBeenCalled() + }) + + it('fans out a company-assigned task to one send per current member', async () => { + mockGetEligibleReminders.mockResolvedValueOnce([ + buildRow({ + assigneeType: AssigneeType.company, + assigneeId: 'company_1', + companyId: 'company_1', + }), + ]) + mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) + mockGetCompanyClients.mockResolvedValueOnce([{ id: 'm_1' }, { id: 'm_2' }, { id: 'm_3' }]) + mockQueryRaw.mockResolvedValueOnce([ + { id: 'l_1', taskId: 'task_1', recipientId: 'm_1', reminderType: TaskReminderType.NO_DUE_DATE_3D }, + { id: 'l_2', taskId: 'task_1', recipientId: 'm_2', reminderType: TaskReminderType.NO_DUE_DATE_3D }, + { id: 'l_3', taskId: 'task_1', recipientId: 'm_3', reminderType: TaskReminderType.NO_DUE_DATE_3D }, + ]) + mockSendReminderEmail.mockResolvedValue('notif') + + const result = await runJob() + + expect(result).toEqual({ sent: 3, failed: 0, skipped: 0, workspaceCount: 1 }) + expect(mockSendReminderEmail).toHaveBeenCalledTimes(3) + const recipientIds = mockSendReminderEmail.mock.calls.map((c) => c[0].recipientClientId).sort() + expect(recipientIds).toEqual(['m_1', 'm_2', 'm_3']) + expect(mockSendReminderEmail.mock.calls[0][0].isCompanyRecipient).toBe(true) + }) + + it('compensates the ledger when Copilot send fails', async () => { + mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) + mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) + mockQueryRaw.mockResolvedValueOnce([ + { id: 'ledger_1', taskId: 'task_1', recipientId: 'client_1', reminderType: TaskReminderType.NO_DUE_DATE_3D }, + ]) + mockSendReminderEmail.mockRejectedValueOnce(new Error('copilot 5xx')) + mockTaskReminderSentDelete.mockResolvedValueOnce({ id: 'ledger_1' }) + + const result = await runJob() + + expect(result).toEqual({ sent: 0, failed: 1, skipped: 0, workspaceCount: 1 }) + expect(mockTaskReminderSentDelete).toHaveBeenCalledWith({ where: { id: 'ledger_1' } }) + }) + + it('does not abort the sweep when one workspace throws (per-workspace isolation)', async () => { + mockGetEligibleReminders.mockResolvedValueOnce([ + buildRow({ workspaceId: 'ws_bad', taskId: 'task_bad' }), + buildRow({ workspaceId: 'ws_good', taskId: 'task_good', assigneeId: 'client_good' }), + ]) + // ws_bad findMany throws; ws_good completes a single send. + mockTaskFindMany + .mockRejectedValueOnce(new Error('db blew up')) + .mockResolvedValueOnce([{ id: 'task_good', title: 'Submit timesheet', createdById: 'iu_good' }]) + mockQueryRaw.mockResolvedValueOnce([ + { + id: 'ledger_g', + taskId: 'task_good', + recipientId: 'client_good', + reminderType: TaskReminderType.NO_DUE_DATE_3D, + }, + ]) + mockSendReminderEmail.mockResolvedValueOnce('notif_good') + + const result = await runJob() + + expect(result.workspaceCount).toBe(2) + expect(result.sent).toBe(1) + }) +}) diff --git a/src/jobs/notifications/send-task-reminders.ts b/src/jobs/notifications/send-task-reminders.ts new file mode 100644 index 000000000..bbba086fe --- /dev/null +++ b/src/jobs/notifications/send-task-reminders.ts @@ -0,0 +1,239 @@ +import 'server-only' + +import { copilotAPIKey } from '@/config' +import DBClient from '@/lib/db' +import { ClientResponse } from '@/types/common' +import { CopilotAPI } from '@/utils/CopilotAPI' +import { encodePayload } from '@/utils/crypto' +import { AssigneeType, Prisma, TaskReminderType } from '@prisma/client' +import { logger, schedules } from '@trigger.dev/sdk/v3' +import Bottleneck from 'bottleneck' + +import { EligibilityRow, getEligibleReminders } from './eligibility' +import { sendReminderEmail } from './send-reminder-email' + +const WORKSPACE_CONCURRENCY = 5 + +type WorkspaceTotals = { sent: number; failed: number; skipped: number } + +type TaskInfo = { id: string; title: string; createdById: string } + +type Recipient = { clientId: string; companyId: string | null } + +type LedgerInsertedRow = { + id: string + taskId: string + recipientId: string + reminderType: TaskReminderType +} + +type LedgerPlanEntry = { + row: EligibilityRow + task: TaskInfo + recipient: Recipient +} + +export const sendTaskReminders = schedules.task({ + id: 'send-task-reminders', + cron: '0 0 * * *', + maxDuration: 3000, + run: async (payload) => { + const db = DBClient.getInstance() + + const allRows = await getEligibleReminders(db) + // IUs are deliberately excluded from reminder emails — see EligibilityRow typedoc + // in ./eligibility.ts. The eligibility SQL still emits IU rows for symmetry; the + // filter lives here so OUT-3736's contract stays untouched. + const rows = allRows.filter((r) => r.assigneeType !== AssigneeType.internalUser) + + const byWorkspace = new Map() + for (const row of rows) { + const bucket = byWorkspace.get(row.workspaceId) + if (bucket) bucket.push(row) + else byWorkspace.set(row.workspaceId, [row]) + } + + logger.log('send-task-reminders: sweep starting', { + totalEligible: allRows.length, + afterIuFilter: rows.length, + eligibleWorkspaces: byWorkspace.size, + workspaceConcurrency: WORKSPACE_CONCURRENCY, + runAt: payload.timestamp, + }) + + const totals = { sent: 0, failed: 0, skipped: 0 } + let processed = 0 + const workspaceCount = byWorkspace.size + + const workspaceBottleneck = new Bottleneck({ maxConcurrent: WORKSPACE_CONCURRENCY }) + + await Promise.allSettled( + Array.from(byWorkspace.entries()).map(([workspaceId, workspaceRows]) => + workspaceBottleneck.schedule(async () => { + let wsTotals: WorkspaceTotals = { sent: 0, failed: 0, skipped: 0 } + try { + wsTotals = await processWorkspace(db, workspaceId, workspaceRows) + } catch (err) { + // Per-workspace isolation: one bad workspace shouldn't abort the sweep. + logger.error('send-task-reminders: workspace failed', { + workspaceId, + error: serializeError(err), + }) + } finally { + totals.sent += wsTotals.sent + totals.failed += wsTotals.failed + totals.skipped += wsTotals.skipped + processed += 1 + logger.log( + `[${processed}/${workspaceCount}] workspace ${workspaceId}: sent ${wsTotals.sent}, failed ${wsTotals.failed}, skipped ${wsTotals.skipped}`, + { workspaceId, ...wsTotals, processed, eligibleWorkspaces: workspaceCount }, + ) + } + }), + ), + ) + + logger.log('send-task-reminders: sweep complete', { + ...totals, + workspaceCount, + totalEligible: allRows.length, + }) + + return { ...totals, workspaceCount } + }, +}) + +const processWorkspace = async ( + db: ReturnType, + workspaceId: string, + rows: EligibilityRow[], +): Promise => { + // Fetch the task fields we need that aren't on EligibilityRow (title, createdById). + // Kept here rather than in eligibility.ts to leave OUT-3736's contract intact. + const taskIds = Array.from(new Set(rows.map((r) => r.taskId))) + const tasks = await db.task.findMany({ + where: { id: { in: taskIds } }, + select: { id: true, title: true, createdById: true }, + }) + const taskById = new Map(tasks.map((t) => [t.id, t])) + + // Mint a per-workspace Copilot client. Cron has no request-bound user, so we encode + // an IU token from any task's createdById + workspaceId — same shape as + // src/cmd/backfill-missed-emails/index.ts:97-99. + const senderIu = tasks[0]?.createdById + if (!senderIu) { + logger.warn('send-task-reminders: no IU found to mint workspace token, skipping', { + workspaceId, + rowCount: rows.length, + }) + return { sent: 0, failed: 0, skipped: 0 } + } + const token = encodePayload(copilotAPIKey, { internalUserId: senderIu, workspaceId }) + const copilot = new CopilotAPI(token) + const workspace = await copilot.getWorkspace() + + // Plan: fan out company rows to one entry per current member; client rows stay 1:1. + // Members no longer in the company are filtered naturally — they don't come back from + // getCompanyClients, per OUT-3736 ticket. + const plan: LedgerPlanEntry[] = [] + for (const row of rows) { + const task = taskById.get(row.taskId) + if (!task) continue + const recipients = await resolveRecipients(copilot, row) + for (const recipient of recipients) { + plan.push({ row, task, recipient }) + } + } + + if (plan.length === 0) return { sent: 0, failed: 0, skipped: 0 } + + // Ledger insert is the idempotency boundary. ON CONFLICT DO NOTHING ensures a retried + // cron run can never double-send: only the rows that come back from RETURNING are + // net-new claims to send. + const valuesSql = Prisma.join( + plan.map( + (entry) => Prisma.sql`( + gen_random_uuid(), + ${entry.row.taskId}::uuid, + ${workspaceId}, + ${entry.recipient.clientId}::uuid, + ${entry.row.reminderType}::"TaskReminderType", + NOW() + )`, + ), + ) + const inserted = await db.$queryRaw` + INSERT INTO "TaskReminderSents" ("id", "taskId", "workspaceId", "recipientId", "reminderType", "sentAt") + VALUES ${valuesSql} + ON CONFLICT ("taskId", "recipientId", "reminderType") DO NOTHING + RETURNING "id"::text AS "id", + "taskId"::text AS "taskId", + "recipientId"::text AS "recipientId", + "reminderType" + ` + + const insertedKey = (taskId: string, recipientId: string, type: TaskReminderType) => `${taskId}|${recipientId}|${type}` + const insertedById = new Map(inserted.map((r) => [insertedKey(r.taskId, r.recipientId, r.reminderType), r.id])) + + const skipped = plan.length - inserted.length + + let sent = 0 + let failed = 0 + + for (const entry of plan) { + const ledgerId = insertedById.get(insertedKey(entry.row.taskId, entry.recipient.clientId, entry.row.reminderType)) + if (!ledgerId) continue // already-sent (ON CONFLICT skipped this one) + + try { + await sendReminderEmail({ + task: entry.task, + recipientClientId: entry.recipient.clientId, + recipientCompanyId: entry.recipient.companyId, + reminderType: entry.row.reminderType, + isCompanyRecipient: entry.row.assigneeType === AssigneeType.company, + workspace, + copilot, + }) + sent += 1 + } catch (err) { + // Compensate: drop the ledger row so the next cron run retries this (task, recipient, type). + // If the DELETE itself fails the row stays in the ledger and we won't retry — that's + // a permanent miss, logged distinctly so on-call can clean up. + failed += 1 + logger.error('send-task-reminders: Copilot send failed, compensating ledger', { + workspaceId, + taskId: entry.row.taskId, + recipientClientId: entry.recipient.clientId, + reminderType: entry.row.reminderType, + error: serializeError(err), + }) + try { + await db.taskReminderSent.delete({ where: { id: ledgerId } }) + } catch (deleteErr) { + logger.error('send-task-reminders: ledger compensation DELETE failed, reminder will not retry', { + workspaceId, + ledgerId, + taskId: entry.row.taskId, + recipientClientId: entry.recipient.clientId, + reminderType: entry.row.reminderType, + error: serializeError(deleteErr), + }) + } + } + } + + return { sent, failed, skipped } +} + +const resolveRecipients = async (copilot: CopilotAPI, row: EligibilityRow): Promise => { + if (row.assigneeType === AssigneeType.client) { + return [{ clientId: row.assigneeId, companyId: row.companyId }] + } + if (row.assigneeType === AssigneeType.company) { + const members: ClientResponse[] = await copilot.getCompanyClients(row.assigneeId) + return members.map((m) => ({ clientId: m.id, companyId: row.assigneeId })) + } + return [] +} + +const serializeError = (err: unknown) => (err instanceof Error ? { message: err.message, stack: err.stack } : err) From 67ac802e1edd65d123a63b746cf3968883196612 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 25 May 2026 16:56:18 +0545 Subject: [PATCH 08/27] refactor(OUT-3730): init per-workspace CopilotAPI via workspace-scoped apiKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the IU-token mint and uses the workspace-scoped apiKey pattern that the SDK patch already supports when COPILOT_ENV is set on the Trigger.dev runtime — same env that auto-archive's dispatch-task-archived-webhook relies on. Two wins: - No "pick a random task's createdById to forge a token" fallback, which was structurally awkward (the IU we mint as had no semantic meaning). - One fewer crypto call per workspace per cron run. senderId for the email itself still comes from task.createdById in sendReminderEmail — that's unchanged, since the IU who created the task is the legitimate sender identity for the reminder. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../notifications/send-task-reminders.test.ts | 26 ++++++++++++++++--- src/jobs/notifications/send-task-reminders.ts | 20 +++++--------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/jobs/notifications/send-task-reminders.test.ts b/src/jobs/notifications/send-task-reminders.test.ts index efc2597dd..5a3a73c58 100644 --- a/src/jobs/notifications/send-task-reminders.test.ts +++ b/src/jobs/notifications/send-task-reminders.test.ts @@ -21,6 +21,10 @@ jest.mock('@trigger.dev/sdk/v3', () => ({ logger: { log: jest.fn(), error: jest.fn(), warn: jest.fn() }, })) +jest.mock('@/config', () => ({ + copilotAPIKey: 'test-api-key', +})) + jest.mock('@/lib/db', () => ({ __esModule: true, default: { @@ -32,10 +36,6 @@ jest.mock('@/lib/db', () => ({ }, })) -jest.mock('@/utils/crypto', () => ({ - encodePayload: jest.fn(() => 'stub-token'), -})) - jest.mock('@/utils/CopilotAPI', () => ({ CopilotAPI: jest.fn().mockImplementation((...args: unknown[]) => { mockCopilotApiCtor(...args) @@ -158,6 +158,24 @@ describe('sendTaskReminders', () => { }) }) + it('initializes CopilotAPI with a workspace-scoped apiKey (no user token mint)', async () => { + mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) + mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) + mockQueryRaw.mockResolvedValueOnce([ + { + id: 'ledger_1', + taskId: 'task_1', + recipientId: 'client_1', + reminderType: TaskReminderType.NO_DUE_DATE_3D, + }, + ]) + mockSendReminderEmail.mockResolvedValueOnce('notif_1') + + await runJob() + + expect(mockCopilotApiCtor).toHaveBeenCalledWith('', 'ws_1/test-api-key') + }) + it('treats ON CONFLICT returning zero rows as fully-skipped (re-run idempotency)', async () => { mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) diff --git a/src/jobs/notifications/send-task-reminders.ts b/src/jobs/notifications/send-task-reminders.ts index bbba086fe..2ac3bbcc5 100644 --- a/src/jobs/notifications/send-task-reminders.ts +++ b/src/jobs/notifications/send-task-reminders.ts @@ -4,7 +4,6 @@ import { copilotAPIKey } from '@/config' import DBClient from '@/lib/db' import { ClientResponse } from '@/types/common' import { CopilotAPI } from '@/utils/CopilotAPI' -import { encodePayload } from '@/utils/crypto' import { AssigneeType, Prisma, TaskReminderType } from '@prisma/client' import { logger, schedules } from '@trigger.dev/sdk/v3' import Bottleneck from 'bottleneck' @@ -115,21 +114,14 @@ const processWorkspace = async ( where: { id: { in: taskIds } }, select: { id: true, title: true, createdById: true }, }) + if (tasks.length === 0) return { sent: 0, failed: 0, skipped: 0 } const taskById = new Map(tasks.map((t) => [t.id, t])) - // Mint a per-workspace Copilot client. Cron has no request-bound user, so we encode - // an IU token from any task's createdById + workspaceId — same shape as - // src/cmd/backfill-missed-emails/index.ts:97-99. - const senderIu = tasks[0]?.createdById - if (!senderIu) { - logger.warn('send-task-reminders: no IU found to mint workspace token, skipping', { - workspaceId, - rowCount: rows.length, - }) - return { sent: 0, failed: 0, skipped: 0 } - } - const token = encodePayload(copilotAPIKey, { internalUserId: senderIu, workspaceId }) - const copilot = new CopilotAPI(token) + // Per-workspace Copilot client using a workspace-scoped apiKey. The SDK patch + // (src/lib/patch-copilot-node-sdk.js) accepts `${workspaceId}/${apiKey}` as the auth key + // directly when COPILOT_ENV is set on the Trigger.dev runtime (`local` for prod, + // `__SECRET_STAGING__` for staging) — no user token needed. Empty token = no user context. + const copilot = new CopilotAPI('', `${workspaceId}/${copilotAPIKey}`) const workspace = await copilot.getWorkspace() // Plan: fan out company rows to one entry per current member; client rows stay 1:1. From abef42e5de9da3652920909ff8f7f874450dd59b Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 25 May 2026 17:00:52 +0545 Subject: [PATCH 09/27] refactor(OUT-3730): swap raw INSERT for prisma createManyAndReturn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prisma 5.14+ exposes createManyAndReturn, which compiles to exactly the INSERT ... ON CONFLICT DO NOTHING RETURNING shape the cron needs but does it as a typed Prisma call. Drops: - The Prisma.sql / Prisma.join template assembly. - Manual ::uuid and ::"TaskReminderType" casts (Prisma handles via the model's @db.Uuid / enum typing). - The hand-written gen_random_uuid() in VALUES — the model already sets id via @default(dbgenerated("gen_random_uuid()")), so Postgres fills it in automatically when Prisma omits it from the INSERT. - The LedgerInsertedRow shim type (now inferred from the Prisma model). Net 15 lines shorter, no behavior change. skipDuplicates: true compiles to ON CONFLICT DO NOTHING against the existing (taskId, recipientId, reminderType) unique constraint, and createManyAndReturn only returns the rows that actually got inserted — identical semantics to the previous raw query. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../notifications/send-task-reminders.test.ts | 26 +++++----- src/jobs/notifications/send-task-reminders.ts | 47 ++++++------------- 2 files changed, 29 insertions(+), 44 deletions(-) diff --git a/src/jobs/notifications/send-task-reminders.test.ts b/src/jobs/notifications/send-task-reminders.test.ts index 5a3a73c58..fa6e4ee72 100644 --- a/src/jobs/notifications/send-task-reminders.test.ts +++ b/src/jobs/notifications/send-task-reminders.test.ts @@ -3,8 +3,8 @@ import { AssigneeType, TaskReminderType } from '@prisma/client' // Mocks must be configured before requiring the SUT. Variables referenced inside the // jest.mock factory must start with `mock` so the babel-jest allow-list lets the closure // see them once the const declarations have run. -const mockQueryRaw = jest.fn() const mockTaskFindMany = jest.fn() +const mockTaskReminderSentCreateManyAndReturn = jest.fn() const mockTaskReminderSentDelete = jest.fn() const mockGetEligibleReminders = jest.fn() @@ -29,9 +29,11 @@ jest.mock('@/lib/db', () => ({ __esModule: true, default: { getInstance: () => ({ - $queryRaw: mockQueryRaw, task: { findMany: mockTaskFindMany }, - taskReminderSent: { delete: mockTaskReminderSentDelete }, + taskReminderSent: { + createManyAndReturn: mockTaskReminderSentCreateManyAndReturn, + delete: mockTaskReminderSentDelete, + }, }), }, })) @@ -96,8 +98,8 @@ const buildRow = (overrides: Partial[1]> = {}) describe('sendTaskReminders', () => { beforeEach(() => { jest.clearAllMocks() - mockQueryRaw.mockReset() mockTaskFindMany.mockReset() + mockTaskReminderSentCreateManyAndReturn.mockReset() mockTaskReminderSentDelete.mockReset() mockGetEligibleReminders.mockReset() mockSendReminderEmail.mockReset() @@ -113,7 +115,7 @@ describe('sendTaskReminders', () => { const result = await runJob() expect(result).toEqual({ sent: 0, failed: 0, skipped: 0, workspaceCount: 0 }) - expect(mockQueryRaw).not.toHaveBeenCalled() + expect(mockTaskReminderSentCreateManyAndReturn).not.toHaveBeenCalled() expect(mockSendReminderEmail).not.toHaveBeenCalled() expect(mockCopilotApiCtor).not.toHaveBeenCalled() }) @@ -127,14 +129,14 @@ describe('sendTaskReminders', () => { expect(result.workspaceCount).toBe(0) expect(mockTaskFindMany).not.toHaveBeenCalled() - expect(mockQueryRaw).not.toHaveBeenCalled() + expect(mockTaskReminderSentCreateManyAndReturn).not.toHaveBeenCalled() expect(mockSendReminderEmail).not.toHaveBeenCalled() }) it('sends one reminder for a client-assigned task and writes one ledger row', async () => { mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) - mockQueryRaw.mockResolvedValueOnce([ + mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ { id: 'ledger_1', taskId: 'task_1', @@ -161,7 +163,7 @@ describe('sendTaskReminders', () => { it('initializes CopilotAPI with a workspace-scoped apiKey (no user token mint)', async () => { mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) - mockQueryRaw.mockResolvedValueOnce([ + mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ { id: 'ledger_1', taskId: 'task_1', @@ -179,7 +181,7 @@ describe('sendTaskReminders', () => { it('treats ON CONFLICT returning zero rows as fully-skipped (re-run idempotency)', async () => { mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) - mockQueryRaw.mockResolvedValueOnce([]) + mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([]) const result = await runJob() @@ -197,7 +199,7 @@ describe('sendTaskReminders', () => { ]) mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) mockGetCompanyClients.mockResolvedValueOnce([{ id: 'm_1' }, { id: 'm_2' }, { id: 'm_3' }]) - mockQueryRaw.mockResolvedValueOnce([ + mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ { id: 'l_1', taskId: 'task_1', recipientId: 'm_1', reminderType: TaskReminderType.NO_DUE_DATE_3D }, { id: 'l_2', taskId: 'task_1', recipientId: 'm_2', reminderType: TaskReminderType.NO_DUE_DATE_3D }, { id: 'l_3', taskId: 'task_1', recipientId: 'm_3', reminderType: TaskReminderType.NO_DUE_DATE_3D }, @@ -216,7 +218,7 @@ describe('sendTaskReminders', () => { it('compensates the ledger when Copilot send fails', async () => { mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) - mockQueryRaw.mockResolvedValueOnce([ + mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ { id: 'ledger_1', taskId: 'task_1', recipientId: 'client_1', reminderType: TaskReminderType.NO_DUE_DATE_3D }, ]) mockSendReminderEmail.mockRejectedValueOnce(new Error('copilot 5xx')) @@ -237,7 +239,7 @@ describe('sendTaskReminders', () => { mockTaskFindMany .mockRejectedValueOnce(new Error('db blew up')) .mockResolvedValueOnce([{ id: 'task_good', title: 'Submit timesheet', createdById: 'iu_good' }]) - mockQueryRaw.mockResolvedValueOnce([ + mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ { id: 'ledger_g', taskId: 'task_good', diff --git a/src/jobs/notifications/send-task-reminders.ts b/src/jobs/notifications/send-task-reminders.ts index 2ac3bbcc5..f888c3e47 100644 --- a/src/jobs/notifications/send-task-reminders.ts +++ b/src/jobs/notifications/send-task-reminders.ts @@ -4,7 +4,7 @@ import { copilotAPIKey } from '@/config' import DBClient from '@/lib/db' import { ClientResponse } from '@/types/common' import { CopilotAPI } from '@/utils/CopilotAPI' -import { AssigneeType, Prisma, TaskReminderType } from '@prisma/client' +import { AssigneeType, TaskReminderType } from '@prisma/client' import { logger, schedules } from '@trigger.dev/sdk/v3' import Bottleneck from 'bottleneck' @@ -19,13 +19,6 @@ type TaskInfo = { id: string; title: string; createdById: string } type Recipient = { clientId: string; companyId: string | null } -type LedgerInsertedRow = { - id: string - taskId: string - recipientId: string - reminderType: TaskReminderType -} - type LedgerPlanEntry = { row: EligibilityRow task: TaskInfo @@ -139,30 +132,20 @@ const processWorkspace = async ( if (plan.length === 0) return { sent: 0, failed: 0, skipped: 0 } - // Ledger insert is the idempotency boundary. ON CONFLICT DO NOTHING ensures a retried - // cron run can never double-send: only the rows that come back from RETURNING are - // net-new claims to send. - const valuesSql = Prisma.join( - plan.map( - (entry) => Prisma.sql`( - gen_random_uuid(), - ${entry.row.taskId}::uuid, - ${workspaceId}, - ${entry.recipient.clientId}::uuid, - ${entry.row.reminderType}::"TaskReminderType", - NOW() - )`, - ), - ) - const inserted = await db.$queryRaw` - INSERT INTO "TaskReminderSents" ("id", "taskId", "workspaceId", "recipientId", "reminderType", "sentAt") - VALUES ${valuesSql} - ON CONFLICT ("taskId", "recipientId", "reminderType") DO NOTHING - RETURNING "id"::text AS "id", - "taskId"::text AS "taskId", - "recipientId"::text AS "recipientId", - "reminderType" - ` + // Ledger insert is the idempotency boundary. `skipDuplicates: true` compiles to + // `ON CONFLICT DO NOTHING` against the (taskId, recipientId, reminderType) unique + // constraint, so a retried cron run cannot double-send. `createManyAndReturn` only + // returns the rows that actually got inserted — duplicates skipped by ON CONFLICT + // are absent from the result, which is precisely the "net-new to send" list. + const inserted = await db.taskReminderSent.createManyAndReturn({ + data: plan.map((entry) => ({ + taskId: entry.row.taskId, + workspaceId, + recipientId: entry.recipient.clientId, + reminderType: entry.row.reminderType, + })), + skipDuplicates: true, + }) const insertedKey = (taskId: string, recipientId: string, type: TaskReminderType) => `${taskId}|${recipientId}|${type}` const insertedById = new Map(inserted.map((r) => [insertedKey(r.taskId, r.recipientId, r.reminderType), r.id])) From 2e4ced427398e2a82723a714bfb23afc637ef355 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 25 May 2026 17:04:19 +0545 Subject: [PATCH 10/27] chore(OUT-3730): trim comments in reminder cron + helper Strip restating-the-code and ticket-reference comments. Keep three short load-bearing notes: the workspace-scoped apiKey shape, the ledger-before-send ordering, and why we DELETE on Copilot failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/jobs/notifications/send-reminder-email.ts | 14 ++-------- src/jobs/notifications/send-task-reminders.ts | 27 ++++--------------- 2 files changed, 7 insertions(+), 34 deletions(-) diff --git a/src/jobs/notifications/send-reminder-email.ts b/src/jobs/notifications/send-reminder-email.ts index f30441e7d..7b247d4d5 100644 --- a/src/jobs/notifications/send-reminder-email.ts +++ b/src/jobs/notifications/send-reminder-email.ts @@ -15,18 +15,8 @@ export type SendReminderEmailArgs = { 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)`. - */ +// Email-only: omits deliveryTargets.inProduct and does not write to ClientNotification. +// Reminder dedupe lives in TaskReminderSent (caller's responsibility). export const sendReminderEmail = async ({ task, recipientClientId, diff --git a/src/jobs/notifications/send-task-reminders.ts b/src/jobs/notifications/send-task-reminders.ts index f888c3e47..59c2d0d8f 100644 --- a/src/jobs/notifications/send-task-reminders.ts +++ b/src/jobs/notifications/send-task-reminders.ts @@ -33,9 +33,6 @@ export const sendTaskReminders = schedules.task({ const db = DBClient.getInstance() const allRows = await getEligibleReminders(db) - // IUs are deliberately excluded from reminder emails — see EligibilityRow typedoc - // in ./eligibility.ts. The eligibility SQL still emits IU rows for symmetry; the - // filter lives here so OUT-3736's contract stays untouched. const rows = allRows.filter((r) => r.assigneeType !== AssigneeType.internalUser) const byWorkspace = new Map() @@ -66,7 +63,6 @@ export const sendTaskReminders = schedules.task({ try { wsTotals = await processWorkspace(db, workspaceId, workspaceRows) } catch (err) { - // Per-workspace isolation: one bad workspace shouldn't abort the sweep. logger.error('send-task-reminders: workspace failed', { workspaceId, error: serializeError(err), @@ -100,8 +96,6 @@ const processWorkspace = async ( workspaceId: string, rows: EligibilityRow[], ): Promise => { - // Fetch the task fields we need that aren't on EligibilityRow (title, createdById). - // Kept here rather than in eligibility.ts to leave OUT-3736's contract intact. const taskIds = Array.from(new Set(rows.map((r) => r.taskId))) const tasks = await db.task.findMany({ where: { id: { in: taskIds } }, @@ -110,16 +104,11 @@ const processWorkspace = async ( if (tasks.length === 0) return { sent: 0, failed: 0, skipped: 0 } const taskById = new Map(tasks.map((t) => [t.id, t])) - // Per-workspace Copilot client using a workspace-scoped apiKey. The SDK patch - // (src/lib/patch-copilot-node-sdk.js) accepts `${workspaceId}/${apiKey}` as the auth key - // directly when COPILOT_ENV is set on the Trigger.dev runtime (`local` for prod, - // `__SECRET_STAGING__` for staging) — no user token needed. Empty token = no user context. + // Workspace-scoped apiKey: empty token + `${workspaceId}/${apiKey}` is accepted by the + // SDK when COPILOT_ENV is set on the Trigger.dev runtime. const copilot = new CopilotAPI('', `${workspaceId}/${copilotAPIKey}`) const workspace = await copilot.getWorkspace() - // Plan: fan out company rows to one entry per current member; client rows stay 1:1. - // Members no longer in the company are filtered naturally — they don't come back from - // getCompanyClients, per OUT-3736 ticket. const plan: LedgerPlanEntry[] = [] for (const row of rows) { const task = taskById.get(row.taskId) @@ -132,11 +121,7 @@ const processWorkspace = async ( if (plan.length === 0) return { sent: 0, failed: 0, skipped: 0 } - // Ledger insert is the idempotency boundary. `skipDuplicates: true` compiles to - // `ON CONFLICT DO NOTHING` against the (taskId, recipientId, reminderType) unique - // constraint, so a retried cron run cannot double-send. `createManyAndReturn` only - // returns the rows that actually got inserted — duplicates skipped by ON CONFLICT - // are absent from the result, which is precisely the "net-new to send" list. + // Ledger insert before send: the unique constraint is the dedupe primitive. const inserted = await db.taskReminderSent.createManyAndReturn({ data: plan.map((entry) => ({ taskId: entry.row.taskId, @@ -157,7 +142,7 @@ const processWorkspace = async ( for (const entry of plan) { const ledgerId = insertedById.get(insertedKey(entry.row.taskId, entry.recipient.clientId, entry.row.reminderType)) - if (!ledgerId) continue // already-sent (ON CONFLICT skipped this one) + if (!ledgerId) continue try { await sendReminderEmail({ @@ -171,9 +156,7 @@ const processWorkspace = async ( }) sent += 1 } catch (err) { - // Compensate: drop the ledger row so the next cron run retries this (task, recipient, type). - // If the DELETE itself fails the row stays in the ledger and we won't retry — that's - // a permanent miss, logged distinctly so on-call can clean up. + // Delete the ledger row so the next cron run retries. failed += 1 logger.error('send-task-reminders: Copilot send failed, compensating ledger', { workspaceId, From b34ab8c29c89221d6b4074ff56c6bd3b4eed3bf7 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 25 May 2026 17:16:09 +0545 Subject: [PATCH 11/27] refactor(OUT-3730): fold title + createdById into EligibilityRow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds t.title and t.createdById to the eligibility SELECT and drops the per-workspace task.findMany. processWorkspace now operates on a single consistent snapshot from the eligibility query — no more two-step read that could pick up divergent state between the query and the send. Same behavior, fewer DB calls, tighter consistency window. The remaining race (task reassigned between eligibility query and Copilot send) is the unavoidable one and was never closable without distributed transactions. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/jobs/notifications/eligibility.ts | 19 +++++++-------- .../notifications/send-task-reminders.test.ts | 23 +++++-------------- src/jobs/notifications/send-task-reminders.ts | 17 ++------------ 3 files changed, 16 insertions(+), 43 deletions(-) diff --git a/src/jobs/notifications/eligibility.ts b/src/jobs/notifications/eligibility.ts index 87e7915b6..bc5d4d97c 100644 --- a/src/jobs/notifications/eligibility.ts +++ b/src/jobs/notifications/eligibility.ts @@ -4,19 +4,14 @@ import { AssigneeType, TaskReminderType } from '@prisma/client' export type EligibilityRow = { taskId: string workspaceId: string + title: string + createdById: string assigneeId: string assigneeType: AssigneeType - /** - * Company context for the recipient. - * - assigneeType='client' → task.companyId (a client may belong to multiple companies on Copilot; - * this disambiguates which "hat" they're wearing for this task) - * - assigneeType='company' → assigneeId (the company IS the assignee; caller fans out to members) - * - assigneeType='internalUser'→ null (IUs have no company concept and never receive email notifications) - * - * Required for ClientNotifications inserts (unique key includes companyId) and for - * Copilot's recipientCompanyId on email-bearing notifications. See - * src/app/api/notification/notification.service.ts:558. - */ + // companyId derivation per assigneeType: + // client → task.companyId (disambiguates which company "hat" the client wears) + // company → assigneeId (the company IS the assignee) + // internalUser → null (IUs don't receive email reminders) companyId: string | null reminderType: TaskReminderType } @@ -38,6 +33,8 @@ export const getEligibleReminders = async (db: ReturnType ({ __esModule: true, default: { getInstance: () => ({ - task: { findMany: mockTaskFindMany }, taskReminderSent: { createManyAndReturn: mockTaskReminderSentCreateManyAndReturn, delete: mockTaskReminderSentDelete, @@ -57,8 +55,7 @@ jest.mock('./send-reminder-email', () => ({ })) // Bypass Bottleneck's rate-limiting in tests but preserve sequential ordering per instance -// via a promise chain. Matches the pattern used in auto-archive-completed-tasks.test.ts so -// FIFO mockResolvedValueOnce queues drain deterministically. +// via a promise chain so FIFO mockResolvedValueOnce queues drain deterministically. jest.mock('bottleneck', () => ({ __esModule: true, default: jest.fn().mockImplementation(() => { @@ -88,6 +85,8 @@ const workspace = { id: 'ws_1', brandName: 'Acme' } const buildRow = (overrides: Partial[1]> = {}) => ({ taskId: 'task_1', workspaceId: 'ws_1', + title: 'Submit timesheet', + createdById: 'iu_1', assigneeId: 'client_1', assigneeType: AssigneeType.client, companyId: 'company_1', @@ -98,7 +97,6 @@ const buildRow = (overrides: Partial[1]> = {}) describe('sendTaskReminders', () => { beforeEach(() => { jest.clearAllMocks() - mockTaskFindMany.mockReset() mockTaskReminderSentCreateManyAndReturn.mockReset() mockTaskReminderSentDelete.mockReset() mockGetEligibleReminders.mockReset() @@ -120,7 +118,7 @@ describe('sendTaskReminders', () => { expect(mockCopilotApiCtor).not.toHaveBeenCalled() }) - it('filters out internalUser rows before any DB or Copilot work', async () => { + it('filters out internalUser rows before any Copilot work', async () => { mockGetEligibleReminders.mockResolvedValueOnce([ buildRow({ assigneeType: AssigneeType.internalUser, assigneeId: 'iu_1', companyId: null }), ]) @@ -128,14 +126,12 @@ describe('sendTaskReminders', () => { const result = await runJob() expect(result.workspaceCount).toBe(0) - expect(mockTaskFindMany).not.toHaveBeenCalled() expect(mockTaskReminderSentCreateManyAndReturn).not.toHaveBeenCalled() expect(mockSendReminderEmail).not.toHaveBeenCalled() }) it('sends one reminder for a client-assigned task and writes one ledger row', async () => { mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) - mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ { id: 'ledger_1', @@ -162,7 +158,6 @@ describe('sendTaskReminders', () => { it('initializes CopilotAPI with a workspace-scoped apiKey (no user token mint)', async () => { mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) - mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ { id: 'ledger_1', @@ -180,7 +175,6 @@ describe('sendTaskReminders', () => { it('treats ON CONFLICT returning zero rows as fully-skipped (re-run idempotency)', async () => { mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) - mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([]) const result = await runJob() @@ -197,7 +191,6 @@ describe('sendTaskReminders', () => { companyId: 'company_1', }), ]) - mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) mockGetCompanyClients.mockResolvedValueOnce([{ id: 'm_1' }, { id: 'm_2' }, { id: 'm_3' }]) mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ { id: 'l_1', taskId: 'task_1', recipientId: 'm_1', reminderType: TaskReminderType.NO_DUE_DATE_3D }, @@ -217,7 +210,6 @@ describe('sendTaskReminders', () => { it('compensates the ledger when Copilot send fails', async () => { mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) - mockTaskFindMany.mockResolvedValueOnce([{ id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }]) mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ { id: 'ledger_1', taskId: 'task_1', recipientId: 'client_1', reminderType: TaskReminderType.NO_DUE_DATE_3D }, ]) @@ -235,11 +227,8 @@ describe('sendTaskReminders', () => { buildRow({ workspaceId: 'ws_bad', taskId: 'task_bad' }), buildRow({ workspaceId: 'ws_good', taskId: 'task_good', assigneeId: 'client_good' }), ]) - // ws_bad findMany throws; ws_good completes a single send. - mockTaskFindMany - .mockRejectedValueOnce(new Error('db blew up')) - .mockResolvedValueOnce([{ id: 'task_good', title: 'Submit timesheet', createdById: 'iu_good' }]) - mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ + // ws_bad's ledger insert throws; ws_good completes a single send. + mockTaskReminderSentCreateManyAndReturn.mockRejectedValueOnce(new Error('db blew up')).mockResolvedValueOnce([ { id: 'ledger_g', taskId: 'task_good', diff --git a/src/jobs/notifications/send-task-reminders.ts b/src/jobs/notifications/send-task-reminders.ts index 59c2d0d8f..e6857a223 100644 --- a/src/jobs/notifications/send-task-reminders.ts +++ b/src/jobs/notifications/send-task-reminders.ts @@ -15,13 +15,10 @@ const WORKSPACE_CONCURRENCY = 5 type WorkspaceTotals = { sent: number; failed: number; skipped: number } -type TaskInfo = { id: string; title: string; createdById: string } - type Recipient = { clientId: string; companyId: string | null } type LedgerPlanEntry = { row: EligibilityRow - task: TaskInfo recipient: Recipient } @@ -96,14 +93,6 @@ const processWorkspace = async ( workspaceId: string, rows: EligibilityRow[], ): Promise => { - const taskIds = Array.from(new Set(rows.map((r) => r.taskId))) - const tasks = await db.task.findMany({ - where: { id: { in: taskIds } }, - select: { id: true, title: true, createdById: true }, - }) - if (tasks.length === 0) return { sent: 0, failed: 0, skipped: 0 } - const taskById = new Map(tasks.map((t) => [t.id, t])) - // Workspace-scoped apiKey: empty token + `${workspaceId}/${apiKey}` is accepted by the // SDK when COPILOT_ENV is set on the Trigger.dev runtime. const copilot = new CopilotAPI('', `${workspaceId}/${copilotAPIKey}`) @@ -111,11 +100,9 @@ const processWorkspace = async ( const plan: LedgerPlanEntry[] = [] for (const row of rows) { - const task = taskById.get(row.taskId) - if (!task) continue const recipients = await resolveRecipients(copilot, row) for (const recipient of recipients) { - plan.push({ row, task, recipient }) + plan.push({ row, recipient }) } } @@ -146,7 +133,7 @@ const processWorkspace = async ( try { await sendReminderEmail({ - task: entry.task, + task: { id: entry.row.taskId, title: entry.row.title, createdById: entry.row.createdById }, recipientClientId: entry.recipient.clientId, recipientCompanyId: entry.recipient.companyId, reminderType: entry.row.reminderType, From 84f82d2a1881924d6625782b7aeea6c6601f4dbc Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Mon, 25 May 2026 17:20:51 +0545 Subject: [PATCH 12/27] chore(OUT-3736): trim comments in eligibility.ts Drop the type-field annotation, the function docstring, and shorten the three inline SQL comments to one line each. Keeps the genuinely load-bearing notes (subtask carve-out, IS DISTINCT FROM rationale, the CASE WHEN evaluation-order guarantee). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/jobs/notifications/eligibility.ts | 38 +++++++-------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/src/jobs/notifications/eligibility.ts b/src/jobs/notifications/eligibility.ts index bc5d4d97c..028dde4fe 100644 --- a/src/jobs/notifications/eligibility.ts +++ b/src/jobs/notifications/eligibility.ts @@ -8,26 +8,13 @@ export type EligibilityRow = { createdById: string assigneeId: string assigneeType: AssigneeType - // companyId derivation per assigneeType: - // client → task.companyId (disambiguates which company "hat" the client wears) - // company → assigneeId (the company IS the assignee) - // internalUser → null (IUs don't receive email reminders) companyId: string | null reminderType: TaskReminderType } -/** - * Returns one row per (task, assignee, reminderType) eligible for a reminder today. - * - * Company-assigned tasks emit a single row with assigneeType='company' and assigneeId - * set to the company id. Caller fans those out to individual members via Copilot — - * the SQL deliberately stops at the company boundary so DB has no Copilot dependency. - * - * Already-sent reminders are NOT filtered here. The TaskReminderSents unique constraint - * is the dedupe primitive at insert time, so a retried cron run is idempotent without - * an extra NOT EXISTS check (the windows are exact-day so day-N reminders don't repeat - * in normal operation). - */ +// Company-assigned tasks emit one row at the company level; caller fans out to members. +// Already-sent reminders are not filtered here — TaskReminderSents' unique constraint is +// the dedupe primitive at insert time. export const getEligibleReminders = async (db: ReturnType): Promise => { return db.$queryRaw` SELECT @@ -51,11 +38,8 @@ export const getEligibleReminders = async (db: ReturnType Date: Tue, 26 May 2026 15:30:22 +0545 Subject: [PATCH 13/27] fix(OUT-3735): drop ` portal:` prefix from reminder subjects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's email service prepends ` portal:` to every notification subject server-side. Our reminder copy helper was also prepending it, producing doubled subjects like: "Assembly + Outside portal: Assembly + Outside portal: [Overdue] ..." The existing `getEmailDetails` (for non-reminder emails) emits bare subjects for this reason — reminders should match that convention. Side effect: closes the open PRD-verbatim question on DUE_DATE_OVERDUE_7D. The PRD's inconsistent inclusion of `{Company} portal:` was a description of the rendered subject, not what the code should emit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../notification.helpers.test.ts.snap | 24 +++++++++--------- .../notification/notification.helpers.test.ts | 9 ++++--- .../api/notification/notification.helpers.ts | 25 ++++++------------- .../notifications/send-reminder-email.test.ts | 4 +-- 4 files changed, 27 insertions(+), 35 deletions(-) diff --git a/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap b/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap index 0904c2538..81f1621a7 100644 --- a/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap +++ b/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap @@ -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": { @@ -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": { @@ -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": { @@ -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": { @@ -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": { @@ -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", }, } @@ -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": { @@ -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": { @@ -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": { @@ -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": { @@ -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": { @@ -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", }, } diff --git a/src/app/api/notification/notification.helpers.test.ts b/src/app/api/notification/notification.helpers.test.ts index 414fadc2b..5874c181e 100644 --- a/src/app/api/notification/notification.helpers.test.ts +++ b/src/app/api/notification/notification.helpers.test.ts @@ -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 ` 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', () => { diff --git a/src/app/api/notification/notification.helpers.ts b/src/app/api/notification/notification.helpers.ts index 708af2eae..7b889f761 100644 --- a/src/app/api/notification/notification.helpers.ts +++ b/src/app/api/notification/notification.helpers.ts @@ -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 - 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 ` 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, @@ -226,7 +218,6 @@ 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 } @@ -234,42 +225,42 @@ export const getReminderEmailDetails = ( 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.`, diff --git a/src/jobs/notifications/send-reminder-email.test.ts b/src/jobs/notifications/send-reminder-email.test.ts index 5628c4403..1728420f1 100644 --- a/src/jobs/notifications/send-reminder-email.test.ts +++ b/src/jobs/notifications/send-reminder-email.test.ts @@ -57,7 +57,7 @@ describe('sendReminderEmail', () => { recipientCompanyId: 'company_1', }) expect(payload.deliveryTargets.email).toEqual({ - subject: 'Acme portal: [Reminder] You have a task to complete', + subject: '[Reminder] You have a task to complete', header: 'A task was assigned to you', title: 'View task', body: expect.stringContaining('‘Submit timesheet’'), @@ -80,7 +80,7 @@ describe('sendReminderEmail', () => { 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') + expect(payload.deliveryTargets.email.subject).toBe('[Due Soon] Task due today') }) it('omits recipientCompanyId when null', async () => { From 29d5a0047127bbca8ea20834c2b5706d147df788 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 26 May 2026 15:36:10 +0545 Subject: [PATCH 14/27] fix(OUT-3735): use

for paragraph breaks in reminder bodies Copilot's email template collapses \n\n, so the two sentences in every reminder body were rendering as a single paragraph. The PRD specifies a paragraph break between the reminder statement and the call-to-action. HTML

survives Copilot's whitespace normalization and renders as the expected visible gap. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../notification.helpers.test.ts.snap | 40 +++++-------------- .../api/notification/notification.helpers.ts | 10 ++--- 2 files changed, 15 insertions(+), 35 deletions(-) diff --git a/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap b/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap index 81f1621a7..7991f1599 100644 --- a/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap +++ b/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap @@ -3,9 +3,7 @@ exports[`getReminderEmailDetails matches snapshot for company recipient 1`] = ` { "DUE_DATE_BEFORE_3D": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due in 3 days. - -Please make sure to complete this task by the due date.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due in 3 days.

Please make sure to complete this task by the due date.", "ctaParams": { "taskId": "task_1", }, @@ -23,9 +21,7 @@ Please make sure to complete this task by the due date.", "title": "View task", }, "DUE_DATE_OVERDUE_7D": { - "body": "This is a friendly reminder that the task ‘Submit timesheet’ is now one week overdue. - -Please complete this task as soon as possible.", + "body": "This is a friendly reminder that the task ‘Submit timesheet’ is now one week overdue.

Please complete this task as soon as possible.", "ctaParams": { "taskId": "task_1", }, @@ -34,9 +30,7 @@ Please complete this task as soon as possible.", "title": "View task", }, "DUE_DATE_TODAY": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due today. - -Please complete this task as soon as possible.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due today.

Please complete this task as soon as possible.", "ctaParams": { "taskId": "task_1", }, @@ -45,9 +39,7 @@ Please complete this task as soon as possible.", "title": "View task", }, "NO_DUE_DATE_3D": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ assigned to you that's still pending completion. - -If you've already completed this task, please mark it as done in the portal.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ assigned to you that's still pending completion.

If you've already completed this task, please mark it as done in the portal.", "ctaParams": { "taskId": "task_1", }, @@ -56,9 +48,7 @@ If you've already completed this task, please mark it as done in the portal.", "title": "View task", }, "NO_DUE_DATE_7D": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ that was assigned to you a week ago and is still pending. - -If you've already completed this task, please mark it as done in the portal.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ that was assigned to you a week ago and is still pending.

If you've already completed this task, please mark it as done in the portal.", "ctaParams": { "taskId": "task_1", }, @@ -72,9 +62,7 @@ If you've already completed this task, please mark it as done in the portal.", exports[`getReminderEmailDetails matches snapshot for individual recipient 1`] = ` { "DUE_DATE_BEFORE_3D": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due in 3 days. - -Please make sure to complete this task by the due date.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due in 3 days.

Please make sure to complete this task by the due date.", "ctaParams": { "taskId": "task_1", }, @@ -92,9 +80,7 @@ Please make sure to complete this task by the due date.", "title": "View task", }, "DUE_DATE_OVERDUE_7D": { - "body": "This is a friendly reminder that the task ‘Submit timesheet’ is now one week overdue. - -Please complete this task as soon as possible.", + "body": "This is a friendly reminder that the task ‘Submit timesheet’ is now one week overdue.

Please complete this task as soon as possible.", "ctaParams": { "taskId": "task_1", }, @@ -103,9 +89,7 @@ Please complete this task as soon as possible.", "title": "View task", }, "DUE_DATE_TODAY": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due today. - -Please complete this task as soon as possible.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due today.

Please complete this task as soon as possible.", "ctaParams": { "taskId": "task_1", }, @@ -114,9 +98,7 @@ Please complete this task as soon as possible.", "title": "View task", }, "NO_DUE_DATE_3D": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ assigned to you that's still pending completion. - -If you've already completed this task, please mark it as done in the portal.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ assigned to you that's still pending completion.

If you've already completed this task, please mark it as done in the portal.", "ctaParams": { "taskId": "task_1", }, @@ -125,9 +107,7 @@ If you've already completed this task, please mark it as done in the portal.", "title": "View task", }, "NO_DUE_DATE_7D": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ that was assigned to you a week ago and is still pending. - -If you've already completed this task, please mark it as done in the portal.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ that was assigned to you a week ago and is still pending.

If you've already completed this task, please mark it as done in the portal.", "ctaParams": { "taskId": "task_1", }, diff --git a/src/app/api/notification/notification.helpers.ts b/src/app/api/notification/notification.helpers.ts index 7b889f761..0ec1d34e4 100644 --- a/src/app/api/notification/notification.helpers.ts +++ b/src/app/api/notification/notification.helpers.ts @@ -228,28 +228,28 @@ export const getReminderEmailDetails = ( 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.`, + body: `This is a friendly reminder that you have a task ‘${task.title}’ assigned to you that's still pending completion.

If you've already completed this task, please mark it as done in the portal.`, ctaParams, }, [TaskReminderType.NO_DUE_DATE_7D]: { 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.`, + 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.

If you've already completed this task, please mark it as done in the portal.`, ctaParams, }, [TaskReminderType.DUE_DATE_BEFORE_3D]: { 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.`, + body: `This is a friendly reminder that you have a task ‘${task.title}’ due in 3 days.

Please make sure to complete this task by the due date.`, ctaParams, }, [TaskReminderType.DUE_DATE_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.`, + body: `This is a friendly reminder that you have a task ‘${task.title}’ due today.

Please complete this task as soon as possible.`, ctaParams, }, [TaskReminderType.DUE_DATE_OVERDUE_3D]: { @@ -263,7 +263,7 @@ export const getReminderEmailDetails = ( 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.`, + body: `This is a friendly reminder that the task ‘${task.title}’ is now one week overdue.

Please complete this task as soon as possible.`, ctaParams, }, } From a93fdaff257a649292fd40b40b07533c0c4c4319 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 26 May 2026 15:41:38 +0545 Subject: [PATCH 15/27] revert(OUT-3735): restore \n\n separator in reminder bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both

and

...

showed up as literal text — Copilot's email template escapes all HTML in the body. Reverting to \n\n so the source matches the PRD copy verbatim; the paragraph-rendering gap will be fixed platform-side by the Copilot team. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../notification.helpers.test.ts.snap | 40 ++++++++++++++----- .../api/notification/notification.helpers.ts | 10 ++--- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap b/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap index 7991f1599..81f1621a7 100644 --- a/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap +++ b/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap @@ -3,7 +3,9 @@ exports[`getReminderEmailDetails matches snapshot for company recipient 1`] = ` { "DUE_DATE_BEFORE_3D": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due in 3 days.

Please make sure to complete this task by the due date.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due in 3 days. + +Please make sure to complete this task by the due date.", "ctaParams": { "taskId": "task_1", }, @@ -21,7 +23,9 @@ exports[`getReminderEmailDetails matches snapshot for company recipient 1`] = ` "title": "View task", }, "DUE_DATE_OVERDUE_7D": { - "body": "This is a friendly reminder that the task ‘Submit timesheet’ is now one week overdue.

Please complete this task as soon as possible.", + "body": "This is a friendly reminder that the task ‘Submit timesheet’ is now one week overdue. + +Please complete this task as soon as possible.", "ctaParams": { "taskId": "task_1", }, @@ -30,7 +34,9 @@ exports[`getReminderEmailDetails matches snapshot for company recipient 1`] = ` "title": "View task", }, "DUE_DATE_TODAY": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due today.

Please complete this task as soon as possible.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due today. + +Please complete this task as soon as possible.", "ctaParams": { "taskId": "task_1", }, @@ -39,7 +45,9 @@ exports[`getReminderEmailDetails matches snapshot for company recipient 1`] = ` "title": "View task", }, "NO_DUE_DATE_3D": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ assigned to you that's still pending completion.

If you've already completed this task, please mark it as done in the portal.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ assigned to you that's still pending completion. + +If you've already completed this task, please mark it as done in the portal.", "ctaParams": { "taskId": "task_1", }, @@ -48,7 +56,9 @@ exports[`getReminderEmailDetails matches snapshot for company recipient 1`] = ` "title": "View task", }, "NO_DUE_DATE_7D": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ that was assigned to you a week ago and is still pending.

If you've already completed this task, please mark it as done in the portal.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ that was assigned to you a week ago and is still pending. + +If you've already completed this task, please mark it as done in the portal.", "ctaParams": { "taskId": "task_1", }, @@ -62,7 +72,9 @@ exports[`getReminderEmailDetails matches snapshot for company recipient 1`] = ` exports[`getReminderEmailDetails matches snapshot for individual recipient 1`] = ` { "DUE_DATE_BEFORE_3D": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due in 3 days.

Please make sure to complete this task by the due date.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due in 3 days. + +Please make sure to complete this task by the due date.", "ctaParams": { "taskId": "task_1", }, @@ -80,7 +92,9 @@ exports[`getReminderEmailDetails matches snapshot for individual recipient 1`] = "title": "View task", }, "DUE_DATE_OVERDUE_7D": { - "body": "This is a friendly reminder that the task ‘Submit timesheet’ is now one week overdue.

Please complete this task as soon as possible.", + "body": "This is a friendly reminder that the task ‘Submit timesheet’ is now one week overdue. + +Please complete this task as soon as possible.", "ctaParams": { "taskId": "task_1", }, @@ -89,7 +103,9 @@ exports[`getReminderEmailDetails matches snapshot for individual recipient 1`] = "title": "View task", }, "DUE_DATE_TODAY": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due today.

Please complete this task as soon as possible.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ due today. + +Please complete this task as soon as possible.", "ctaParams": { "taskId": "task_1", }, @@ -98,7 +114,9 @@ exports[`getReminderEmailDetails matches snapshot for individual recipient 1`] = "title": "View task", }, "NO_DUE_DATE_3D": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ assigned to you that's still pending completion.

If you've already completed this task, please mark it as done in the portal.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ assigned to you that's still pending completion. + +If you've already completed this task, please mark it as done in the portal.", "ctaParams": { "taskId": "task_1", }, @@ -107,7 +125,9 @@ exports[`getReminderEmailDetails matches snapshot for individual recipient 1`] = "title": "View task", }, "NO_DUE_DATE_7D": { - "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ that was assigned to you a week ago and is still pending.

If you've already completed this task, please mark it as done in the portal.", + "body": "This is a friendly reminder that you have a task ‘Submit timesheet’ that was assigned to you a week ago and is still pending. + +If you've already completed this task, please mark it as done in the portal.", "ctaParams": { "taskId": "task_1", }, diff --git a/src/app/api/notification/notification.helpers.ts b/src/app/api/notification/notification.helpers.ts index 0ec1d34e4..7b889f761 100644 --- a/src/app/api/notification/notification.helpers.ts +++ b/src/app/api/notification/notification.helpers.ts @@ -228,28 +228,28 @@ export const getReminderEmailDetails = ( 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.

If you've already completed this task, please mark it as done in the portal.`, + 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: '[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.

If you've already completed this task, please mark it as done in the portal.`, + 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: '[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.

Please make sure to complete this task by the due date.`, + 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: '[Due Soon] Task due today', header, title, - body: `This is a friendly reminder that you have a task ‘${task.title}’ due today.

Please complete this task as soon as possible.`, + 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]: { @@ -263,7 +263,7 @@ export const getReminderEmailDetails = ( 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.

Please complete this task as soon as possible.`, + 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.`, ctaParams, }, } From cbab0ebc019e483726ebfaeb9c36aa073b54be95 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 26 May 2026 16:16:30 +0545 Subject: [PATCH 16/27] perf(OUT-3730): offload reminder sends to dispatchReminderEmail task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors auto-archive's dispatchTaskArchivedWebhook pattern. The cron used to call copilot.createNotification sequentially within each workspace; a company task with 50 members forced 50 serial round-trips inside the scheduled task's wall-clock budget. Now the cron: 1. Resolves recipients (still includes copilot.getCompanyClients fan-out). 2. Inserts the ledger with ON CONFLICT DO NOTHING. 3. batchTriggers one dispatch-reminder-email per net-new ledger row. Each dispatch-reminder-email is its own Trigger.dev task with: * queue.concurrencyLimit = 5 (global parallelism across all workspaces). * retry.maxAttempts = 3 with exponential backoff (transient 5xx no longer costs a day of reminders). * onFailure hook that DELETEs the ledger row after retries exhaust, so the next cron run retries. Compensating in onFailure (not inline catch) avoids dropping the ledger on transient failures a retry would recover. Cron's per-workspace totals shift from {sent, failed, skipped} to {enqueued, skipped} — per-send success/failure is now tracked in the dispatcher's Trigger.dev logs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dispatch-reminder-email.test.ts | 110 +++++++++++++++ .../notifications/dispatch-reminder-email.ts | 72 ++++++++++ src/jobs/notifications/index.ts | 1 + .../notifications/send-task-reminders.test.ts | 132 ++++++------------ src/jobs/notifications/send-task-reminders.ts | 74 ++++------ 5 files changed, 252 insertions(+), 137 deletions(-) create mode 100644 src/jobs/notifications/dispatch-reminder-email.test.ts create mode 100644 src/jobs/notifications/dispatch-reminder-email.ts diff --git a/src/jobs/notifications/dispatch-reminder-email.test.ts b/src/jobs/notifications/dispatch-reminder-email.test.ts new file mode 100644 index 000000000..a9188e6dc --- /dev/null +++ b/src/jobs/notifications/dispatch-reminder-email.test.ts @@ -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 => ({ + 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() + }) + }) +}) diff --git a/src/jobs/notifications/dispatch-reminder-email.ts b/src/jobs/notifications/dispatch-reminder-email.ts new file mode 100644 index 000000000..481d0960a --- /dev/null +++ b/src/jobs/notifications/dispatch-reminder-email.ts @@ -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 { 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 + recipientClientId: string + recipientCompanyId: string | null + reminderType: TaskReminderType + isCompanyRecipient: boolean + workspace: WorkspaceResponse +} + +const TASK_ID = 'dispatch-reminder-email' + +const serializeError = (err: unknown) => (err instanceof Error ? { message: err.message, stack: err.stack } : err) + +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. +export const dispatchReminderEmailOnFailure = async ({ payload, error }: { payload: unknown; error: unknown }) => { + const p = payload as DispatchReminderEmailPayload + logger.error('dispatch-reminder-email: retries exhausted, compensating ledger', { + ledgerId: p.ledgerId, + workspaceId: p.workspaceId, + taskId: p.task.id, + recipientClientId: p.recipientClientId, + reminderType: p.reminderType, + error: serializeError(error), + }) + const db = DBClient.getInstance() + try { + await db.taskReminderSent.delete({ where: { id: p.ledgerId } }) + } catch (deleteErr) { + logger.error('dispatch-reminder-email: ledger compensation DELETE failed, reminder will not retry', { + ledgerId: p.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) diff --git a/src/jobs/notifications/index.ts b/src/jobs/notifications/index.ts index 552e8240b..8046314bc 100644 --- a/src/jobs/notifications/index.ts +++ b/src/jobs/notifications/index.ts @@ -3,3 +3,4 @@ export { sendTaskCreateNotifications } from './send-task-create-notifications' export { sendTaskUpdateNotifications } from './send-task-update-notifications' export { sendCommentCreateNotifications } from './send-comment-create-notifications' export { sendTaskReminders } from './send-task-reminders' +export { dispatchReminderEmail } from './dispatch-reminder-email' diff --git a/src/jobs/notifications/send-task-reminders.test.ts b/src/jobs/notifications/send-task-reminders.test.ts index 999a98656..201079532 100644 --- a/src/jobs/notifications/send-task-reminders.test.ts +++ b/src/jobs/notifications/send-task-reminders.test.ts @@ -1,14 +1,8 @@ import { AssigneeType, TaskReminderType } from '@prisma/client' -// Mocks must be configured before requiring the SUT. Variables referenced inside the -// jest.mock factory must start with `mock` so the babel-jest allow-list lets the closure -// see them once the const declarations have run. const mockTaskReminderSentCreateManyAndReturn = jest.fn() -const mockTaskReminderSentDelete = jest.fn() - const mockGetEligibleReminders = jest.fn() -const mockSendReminderEmail = jest.fn() - +const mockBatchTrigger = jest.fn() const mockGetWorkspace = jest.fn() const mockGetCompanyClients = jest.fn() const mockCopilotApiCtor = jest.fn() @@ -20,18 +14,13 @@ jest.mock('@trigger.dev/sdk/v3', () => ({ logger: { log: jest.fn(), error: jest.fn(), warn: jest.fn() }, })) -jest.mock('@/config', () => ({ - copilotAPIKey: 'test-api-key', -})) +jest.mock('@/config', () => ({ copilotAPIKey: 'test-api-key' })) jest.mock('@/lib/db', () => ({ __esModule: true, default: { getInstance: () => ({ - taskReminderSent: { - createManyAndReturn: mockTaskReminderSentCreateManyAndReturn, - delete: mockTaskReminderSentDelete, - }, + taskReminderSent: { createManyAndReturn: mockTaskReminderSentCreateManyAndReturn }, }), }, })) @@ -39,10 +28,7 @@ jest.mock('@/lib/db', () => ({ jest.mock('@/utils/CopilotAPI', () => ({ CopilotAPI: jest.fn().mockImplementation((...args: unknown[]) => { mockCopilotApiCtor(...args) - return { - getWorkspace: mockGetWorkspace, - getCompanyClients: mockGetCompanyClients, - } + return { getWorkspace: mockGetWorkspace, getCompanyClients: mockGetCompanyClients } }), })) @@ -50,12 +36,10 @@ jest.mock('./eligibility', () => ({ getEligibleReminders: (...args: unknown[]) => mockGetEligibleReminders(...args), })) -jest.mock('./send-reminder-email', () => ({ - sendReminderEmail: (...args: unknown[]) => mockSendReminderEmail(...args), +jest.mock('./dispatch-reminder-email', () => ({ + dispatchReminderEmail: { batchTrigger: (...args: unknown[]) => mockBatchTrigger(...args) }, })) -// Bypass Bottleneck's rate-limiting in tests but preserve sequential ordering per instance -// via a promise chain so FIFO mockResolvedValueOnce queues drain deterministically. jest.mock('bottleneck', () => ({ __esModule: true, default: jest.fn().mockImplementation(() => { @@ -72,11 +56,9 @@ jest.mock('bottleneck', () => ({ import { sendTaskReminders } from './send-task-reminders' -type RunResult = { sent: number; failed: number; skipped: number; workspaceCount: number } +type RunResult = { enqueued: number; skipped: number; workspaceCount: number } const runJob = async (): Promise => { - const { run } = sendTaskReminders as unknown as { - run: (payload: { timestamp: Date }) => Promise - } + const { run } = sendTaskReminders as unknown as { run: (payload: { timestamp: Date }) => Promise } return run({ timestamp: new Date() }) } @@ -98,13 +80,13 @@ describe('sendTaskReminders', () => { beforeEach(() => { jest.clearAllMocks() mockTaskReminderSentCreateManyAndReturn.mockReset() - mockTaskReminderSentDelete.mockReset() mockGetEligibleReminders.mockReset() - mockSendReminderEmail.mockReset() + mockBatchTrigger.mockReset() mockGetWorkspace.mockReset() mockGetCompanyClients.mockReset() mockCopilotApiCtor.mockReset() mockGetWorkspace.mockResolvedValue(workspace) + mockBatchTrigger.mockResolvedValue({ batchId: 'b1' }) }) it('exits cleanly when no rows are eligible', async () => { @@ -112,9 +94,9 @@ describe('sendTaskReminders', () => { const result = await runJob() - expect(result).toEqual({ sent: 0, failed: 0, skipped: 0, workspaceCount: 0 }) + expect(result).toEqual({ enqueued: 0, skipped: 0, workspaceCount: 0 }) expect(mockTaskReminderSentCreateManyAndReturn).not.toHaveBeenCalled() - expect(mockSendReminderEmail).not.toHaveBeenCalled() + expect(mockBatchTrigger).not.toHaveBeenCalled() expect(mockCopilotApiCtor).not.toHaveBeenCalled() }) @@ -126,27 +108,24 @@ describe('sendTaskReminders', () => { const result = await runJob() expect(result.workspaceCount).toBe(0) - expect(mockTaskReminderSentCreateManyAndReturn).not.toHaveBeenCalled() - expect(mockSendReminderEmail).not.toHaveBeenCalled() + expect(mockBatchTrigger).not.toHaveBeenCalled() }) - it('sends one reminder for a client-assigned task and writes one ledger row', async () => { + it('enqueues one dispatch per net-new ledger row (client-assigned)', async () => { mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ - { - id: 'ledger_1', - taskId: 'task_1', - recipientId: 'client_1', - reminderType: TaskReminderType.NO_DUE_DATE_3D, - }, + { id: 'ledger_1', taskId: 'task_1', recipientId: 'client_1', reminderType: TaskReminderType.NO_DUE_DATE_3D }, ]) - mockSendReminderEmail.mockResolvedValueOnce('notif_1') const result = await runJob() - expect(result).toEqual({ sent: 1, failed: 0, skipped: 0, workspaceCount: 1 }) - expect(mockSendReminderEmail).toHaveBeenCalledTimes(1) - expect(mockSendReminderEmail.mock.calls[0][0]).toMatchObject({ + expect(result).toEqual({ enqueued: 1, skipped: 0, workspaceCount: 1 }) + expect(mockBatchTrigger).toHaveBeenCalledTimes(1) + const batch = mockBatchTrigger.mock.calls[0][0] + expect(batch).toHaveLength(1) + expect(batch[0].payload).toMatchObject({ + ledgerId: 'ledger_1', + workspaceId: 'ws_1', task: { id: 'task_1', title: 'Submit timesheet', createdById: 'iu_1' }, recipientClientId: 'client_1', recipientCompanyId: 'company_1', @@ -156,17 +135,11 @@ describe('sendTaskReminders', () => { }) }) - it('initializes CopilotAPI with a workspace-scoped apiKey (no user token mint)', async () => { + it('initializes CopilotAPI with a workspace-scoped apiKey', async () => { mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ - { - id: 'ledger_1', - taskId: 'task_1', - recipientId: 'client_1', - reminderType: TaskReminderType.NO_DUE_DATE_3D, - }, + { id: 'ledger_1', taskId: 'task_1', recipientId: 'client_1', reminderType: TaskReminderType.NO_DUE_DATE_3D }, ]) - mockSendReminderEmail.mockResolvedValueOnce('notif_1') await runJob() @@ -179,17 +152,13 @@ describe('sendTaskReminders', () => { const result = await runJob() - expect(result).toEqual({ sent: 0, failed: 0, skipped: 1, workspaceCount: 1 }) - expect(mockSendReminderEmail).not.toHaveBeenCalled() + expect(result).toEqual({ enqueued: 0, skipped: 1, workspaceCount: 1 }) + expect(mockBatchTrigger).not.toHaveBeenCalled() }) - it('fans out a company-assigned task to one send per current member', async () => { + it('fans out a company-assigned task to one dispatch per current member', async () => { mockGetEligibleReminders.mockResolvedValueOnce([ - buildRow({ - assigneeType: AssigneeType.company, - assigneeId: 'company_1', - companyId: 'company_1', - }), + buildRow({ assigneeType: AssigneeType.company, assigneeId: 'company_1', companyId: 'company_1' }), ]) mockGetCompanyClients.mockResolvedValueOnce([{ id: 'm_1' }, { id: 'm_2' }, { id: 'm_3' }]) mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ @@ -197,29 +166,19 @@ describe('sendTaskReminders', () => { { id: 'l_2', taskId: 'task_1', recipientId: 'm_2', reminderType: TaskReminderType.NO_DUE_DATE_3D }, { id: 'l_3', taskId: 'task_1', recipientId: 'm_3', reminderType: TaskReminderType.NO_DUE_DATE_3D }, ]) - mockSendReminderEmail.mockResolvedValue('notif') const result = await runJob() - expect(result).toEqual({ sent: 3, failed: 0, skipped: 0, workspaceCount: 1 }) - expect(mockSendReminderEmail).toHaveBeenCalledTimes(3) - const recipientIds = mockSendReminderEmail.mock.calls.map((c) => c[0].recipientClientId).sort() - expect(recipientIds).toEqual(['m_1', 'm_2', 'm_3']) - expect(mockSendReminderEmail.mock.calls[0][0].isCompanyRecipient).toBe(true) - }) - - it('compensates the ledger when Copilot send fails', async () => { - mockGetEligibleReminders.mockResolvedValueOnce([buildRow()]) - mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ - { id: 'ledger_1', taskId: 'task_1', recipientId: 'client_1', reminderType: TaskReminderType.NO_DUE_DATE_3D }, + expect(result).toEqual({ enqueued: 3, skipped: 0, workspaceCount: 1 }) + expect(mockBatchTrigger).toHaveBeenCalledTimes(1) + const batch = mockBatchTrigger.mock.calls[0][0] + expect(batch).toHaveLength(3) + expect(batch.map((b: { payload: { recipientClientId: string } }) => b.payload.recipientClientId).sort()).toEqual([ + 'm_1', + 'm_2', + 'm_3', ]) - mockSendReminderEmail.mockRejectedValueOnce(new Error('copilot 5xx')) - mockTaskReminderSentDelete.mockResolvedValueOnce({ id: 'ledger_1' }) - - const result = await runJob() - - expect(result).toEqual({ sent: 0, failed: 1, skipped: 0, workspaceCount: 1 }) - expect(mockTaskReminderSentDelete).toHaveBeenCalledWith({ where: { id: 'ledger_1' } }) + expect(batch[0].payload.isCompanyRecipient).toBe(true) }) it('does not abort the sweep when one workspace throws (per-workspace isolation)', async () => { @@ -227,20 +186,15 @@ describe('sendTaskReminders', () => { buildRow({ workspaceId: 'ws_bad', taskId: 'task_bad' }), buildRow({ workspaceId: 'ws_good', taskId: 'task_good', assigneeId: 'client_good' }), ]) - // ws_bad's ledger insert throws; ws_good completes a single send. - mockTaskReminderSentCreateManyAndReturn.mockRejectedValueOnce(new Error('db blew up')).mockResolvedValueOnce([ - { - id: 'ledger_g', - taskId: 'task_good', - recipientId: 'client_good', - reminderType: TaskReminderType.NO_DUE_DATE_3D, - }, - ]) - mockSendReminderEmail.mockResolvedValueOnce('notif_good') + mockTaskReminderSentCreateManyAndReturn + .mockRejectedValueOnce(new Error('db blew up')) + .mockResolvedValueOnce([ + { id: 'ledger_g', taskId: 'task_good', recipientId: 'client_good', reminderType: TaskReminderType.NO_DUE_DATE_3D }, + ]) const result = await runJob() expect(result.workspaceCount).toBe(2) - expect(result.sent).toBe(1) + expect(result.enqueued).toBe(1) }) }) diff --git a/src/jobs/notifications/send-task-reminders.ts b/src/jobs/notifications/send-task-reminders.ts index e6857a223..064f23ddb 100644 --- a/src/jobs/notifications/send-task-reminders.ts +++ b/src/jobs/notifications/send-task-reminders.ts @@ -8,12 +8,12 @@ import { AssigneeType, TaskReminderType } from '@prisma/client' import { logger, schedules } from '@trigger.dev/sdk/v3' import Bottleneck from 'bottleneck' +import { dispatchReminderEmail, DispatchReminderEmailPayload } from './dispatch-reminder-email' import { EligibilityRow, getEligibleReminders } from './eligibility' -import { sendReminderEmail } from './send-reminder-email' const WORKSPACE_CONCURRENCY = 5 -type WorkspaceTotals = { sent: number; failed: number; skipped: number } +type WorkspaceTotals = { enqueued: number; skipped: number } type Recipient = { clientId: string; companyId: string | null } @@ -47,7 +47,7 @@ export const sendTaskReminders = schedules.task({ runAt: payload.timestamp, }) - const totals = { sent: 0, failed: 0, skipped: 0 } + const totals = { enqueued: 0, skipped: 0 } let processed = 0 const workspaceCount = byWorkspace.size @@ -56,7 +56,7 @@ export const sendTaskReminders = schedules.task({ await Promise.allSettled( Array.from(byWorkspace.entries()).map(([workspaceId, workspaceRows]) => workspaceBottleneck.schedule(async () => { - let wsTotals: WorkspaceTotals = { sent: 0, failed: 0, skipped: 0 } + let wsTotals: WorkspaceTotals = { enqueued: 0, skipped: 0 } try { wsTotals = await processWorkspace(db, workspaceId, workspaceRows) } catch (err) { @@ -65,12 +65,11 @@ export const sendTaskReminders = schedules.task({ error: serializeError(err), }) } finally { - totals.sent += wsTotals.sent - totals.failed += wsTotals.failed + totals.enqueued += wsTotals.enqueued totals.skipped += wsTotals.skipped processed += 1 logger.log( - `[${processed}/${workspaceCount}] workspace ${workspaceId}: sent ${wsTotals.sent}, failed ${wsTotals.failed}, skipped ${wsTotals.skipped}`, + `[${processed}/${workspaceCount}] workspace ${workspaceId}: enqueued ${wsTotals.enqueued}, skipped ${wsTotals.skipped}`, { workspaceId, ...wsTotals, processed, eligibleWorkspaces: workspaceCount }, ) } @@ -106,7 +105,7 @@ const processWorkspace = async ( } } - if (plan.length === 0) return { sent: 0, failed: 0, skipped: 0 } + if (plan.length === 0) return { enqueued: 0, skipped: 0 } // Ledger insert before send: the unique constraint is the dedupe primitive. const inserted = await db.taskReminderSent.createManyAndReturn({ @@ -120,54 +119,33 @@ const processWorkspace = async ( }) const insertedKey = (taskId: string, recipientId: string, type: TaskReminderType) => `${taskId}|${recipientId}|${type}` - const insertedById = new Map(inserted.map((r) => [insertedKey(r.taskId, r.recipientId, r.reminderType), r.id])) - - const skipped = plan.length - inserted.length - - let sent = 0 - let failed = 0 - - for (const entry of plan) { - const ledgerId = insertedById.get(insertedKey(entry.row.taskId, entry.recipient.clientId, entry.row.reminderType)) - if (!ledgerId) continue - - try { - await sendReminderEmail({ + const planByKey = new Map( + plan.map((e) => [insertedKey(e.row.taskId, e.recipient.clientId, e.row.reminderType), e]), + ) + + const triggers: { payload: DispatchReminderEmailPayload }[] = [] + for (const row of inserted) { + const entry = planByKey.get(insertedKey(row.taskId, row.recipientId, row.reminderType)) + if (!entry) continue + triggers.push({ + payload: { + ledgerId: row.id, + workspaceId, task: { id: entry.row.taskId, title: entry.row.title, createdById: entry.row.createdById }, recipientClientId: entry.recipient.clientId, recipientCompanyId: entry.recipient.companyId, reminderType: entry.row.reminderType, isCompanyRecipient: entry.row.assigneeType === AssigneeType.company, workspace, - copilot, - }) - sent += 1 - } catch (err) { - // Delete the ledger row so the next cron run retries. - failed += 1 - logger.error('send-task-reminders: Copilot send failed, compensating ledger', { - workspaceId, - taskId: entry.row.taskId, - recipientClientId: entry.recipient.clientId, - reminderType: entry.row.reminderType, - error: serializeError(err), - }) - try { - await db.taskReminderSent.delete({ where: { id: ledgerId } }) - } catch (deleteErr) { - logger.error('send-task-reminders: ledger compensation DELETE failed, reminder will not retry', { - workspaceId, - ledgerId, - taskId: entry.row.taskId, - recipientClientId: entry.recipient.clientId, - reminderType: entry.row.reminderType, - error: serializeError(deleteErr), - }) - } - } + }, + }) + } + + if (triggers.length > 0) { + await dispatchReminderEmail.batchTrigger(triggers) } - return { sent, failed, skipped } + return { enqueued: triggers.length, skipped: plan.length - inserted.length } } const resolveRecipients = async (copilot: CopilotAPI, row: EligibilityRow): Promise => { From 7c99164a6fb500fb6517da0a670225af61a5f999 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 26 May 2026 16:39:45 +0545 Subject: [PATCH 17/27] fix(OUT-3730): chunk batchTrigger at 500 and compensate ledger on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigger.dev caps batchTrigger at 500 items per call. A workspace with a single company task fanning out to 1700+ members blew past that and threw BatchTriggerError, leaving the ledger rows orphaned — the unique constraint then blocked any future cron from re-sending those reminders. Two fixes: 1. Chunk triggers into 500-item batches so any workspace fits. 2. On per-chunk batchTrigger failure, deleteMany the chunk's ledger rows so the next cron run can retry. Same compensation contract as the per-row dispatcher's onFailure hook, just scoped to the chunk. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../notifications/send-task-reminders.test.ts | 58 ++++++++++++++++++- src/jobs/notifications/send-task-reminders.ts | 34 ++++++++++- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/jobs/notifications/send-task-reminders.test.ts b/src/jobs/notifications/send-task-reminders.test.ts index 201079532..d662727fe 100644 --- a/src/jobs/notifications/send-task-reminders.test.ts +++ b/src/jobs/notifications/send-task-reminders.test.ts @@ -1,6 +1,7 @@ import { AssigneeType, TaskReminderType } from '@prisma/client' const mockTaskReminderSentCreateManyAndReturn = jest.fn() +const mockTaskReminderSentDeleteMany = jest.fn() const mockGetEligibleReminders = jest.fn() const mockBatchTrigger = jest.fn() const mockGetWorkspace = jest.fn() @@ -20,7 +21,10 @@ jest.mock('@/lib/db', () => ({ __esModule: true, default: { getInstance: () => ({ - taskReminderSent: { createManyAndReturn: mockTaskReminderSentCreateManyAndReturn }, + taskReminderSent: { + createManyAndReturn: mockTaskReminderSentCreateManyAndReturn, + deleteMany: mockTaskReminderSentDeleteMany, + }, }), }, })) @@ -80,6 +84,7 @@ describe('sendTaskReminders', () => { beforeEach(() => { jest.clearAllMocks() mockTaskReminderSentCreateManyAndReturn.mockReset() + mockTaskReminderSentDeleteMany.mockReset() mockGetEligibleReminders.mockReset() mockBatchTrigger.mockReset() mockGetWorkspace.mockReset() @@ -181,6 +186,57 @@ describe('sendTaskReminders', () => { expect(batch[0].payload.isCompanyRecipient).toBe(true) }) + it('chunks batchTrigger calls so a workspace with >500 fanned-out sends still enqueues', async () => { + // One company task fanning out to 1200 members → 1200 dispatch payloads → 3 chunks of 500. + const members = Array.from({ length: 1200 }, (_, i) => ({ id: `m_${i}` })) + const ledgerRows = members.map((m, i) => ({ + id: `l_${i}`, + taskId: 'task_1', + recipientId: m.id, + reminderType: TaskReminderType.NO_DUE_DATE_3D, + })) + mockGetEligibleReminders.mockResolvedValueOnce([ + buildRow({ assigneeType: AssigneeType.company, assigneeId: 'company_1', companyId: 'company_1' }), + ]) + mockGetCompanyClients.mockResolvedValueOnce(members) + mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce(ledgerRows) + + const result = await runJob() + + expect(result).toEqual({ enqueued: 1200, skipped: 0, workspaceCount: 1 }) + expect(mockBatchTrigger).toHaveBeenCalledTimes(3) + expect(mockBatchTrigger.mock.calls[0][0]).toHaveLength(500) + expect(mockBatchTrigger.mock.calls[1][0]).toHaveLength(500) + expect(mockBatchTrigger.mock.calls[2][0]).toHaveLength(200) + expect(mockTaskReminderSentDeleteMany).not.toHaveBeenCalled() + }) + + it('compensates the ledger when a batchTrigger chunk fails', async () => { + const members = Array.from({ length: 800 }, (_, i) => ({ id: `m_${i}` })) + const ledgerRows = members.map((m, i) => ({ + id: `l_${i}`, + taskId: 'task_1', + recipientId: m.id, + reminderType: TaskReminderType.NO_DUE_DATE_3D, + })) + mockGetEligibleReminders.mockResolvedValueOnce([ + buildRow({ assigneeType: AssigneeType.company, assigneeId: 'company_1', companyId: 'company_1' }), + ]) + mockGetCompanyClients.mockResolvedValueOnce(members) + mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce(ledgerRows) + // First chunk (500) succeeds, second (300) fails. + mockBatchTrigger.mockResolvedValueOnce({ batchId: 'b1' }).mockRejectedValueOnce(new Error('trigger.dev 5xx')) + + const result = await runJob() + + expect(result.enqueued).toBe(500) + expect(mockTaskReminderSentDeleteMany).toHaveBeenCalledTimes(1) + const deleteArgs = mockTaskReminderSentDeleteMany.mock.calls[0][0] + expect(deleteArgs.where.id.in).toHaveLength(300) // failed chunk's ledger rows + expect(deleteArgs.where.id.in[0]).toBe('l_500') + expect(deleteArgs.where.id.in[299]).toBe('l_799') + }) + it('does not abort the sweep when one workspace throws (per-workspace isolation)', async () => { mockGetEligibleReminders.mockResolvedValueOnce([ buildRow({ workspaceId: 'ws_bad', taskId: 'task_bad' }), diff --git a/src/jobs/notifications/send-task-reminders.ts b/src/jobs/notifications/send-task-reminders.ts index 064f23ddb..1127e737a 100644 --- a/src/jobs/notifications/send-task-reminders.ts +++ b/src/jobs/notifications/send-task-reminders.ts @@ -12,6 +12,9 @@ import { dispatchReminderEmail, DispatchReminderEmailPayload } from './dispatch- import { EligibilityRow, getEligibleReminders } from './eligibility' const WORKSPACE_CONCURRENCY = 5 +// Trigger.dev caps batchTrigger at 500 items per call; chunk so a single workspace with +// thousands of fanned-out sends still gets enqueued. +const BATCH_TRIGGER_CHUNK_SIZE = 500 type WorkspaceTotals = { enqueued: number; skipped: number } @@ -141,11 +144,36 @@ const processWorkspace = async ( }) } - if (triggers.length > 0) { - await dispatchReminderEmail.batchTrigger(triggers) + let enqueued = 0 + for (let i = 0; i < triggers.length; i += BATCH_TRIGGER_CHUNK_SIZE) { + const chunk = triggers.slice(i, i + BATCH_TRIGGER_CHUNK_SIZE) + try { + await dispatchReminderEmail.batchTrigger(chunk) + enqueued += chunk.length + } catch (err) { + // Compensate: drop the chunk's ledger rows so the next cron run retries them. + // Without this, the rows are orphans: the unique constraint blocks future inserts + // but no dispatcher will ever consume them. + const ledgerIds = chunk.map((t) => t.payload.ledgerId) + logger.error('send-task-reminders: batchTrigger failed, compensating ledger', { + workspaceId, + chunkSize: chunk.length, + chunkOffset: i, + error: serializeError(err), + }) + try { + await db.taskReminderSent.deleteMany({ where: { id: { in: ledgerIds } } }) + } catch (deleteErr) { + logger.error('send-task-reminders: ledger compensation deleteMany failed, ledger rows orphaned', { + workspaceId, + ledgerIds, + error: serializeError(deleteErr), + }) + } + } } - return { enqueued: triggers.length, skipped: plan.length - inserted.length } + return { enqueued, skipped: plan.length - inserted.length } } const resolveRecipients = async (copilot: CopilotAPI, row: EligibilityRow): Promise => { From d34045832a7dc70db5131693aad71431ebb234f9 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 26 May 2026 17:12:39 +0545 Subject: [PATCH 18/27] =?UTF-8?q?refactor(OUT-3730):=20rename=20row=20?= =?UTF-8?q?=E2=86=92=20task=20per=20PR=20review=20(priosshrsth)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer's three rename suggestions, plus the cascaded references: * allRows → eligibleTasks * rows (filtered) → tasks * byWorkspace → tasksByWorkspace * workspaceRows param → workspaceTasks * processWorkspace's `rows` param → `tasks` * LedgerPlanEntry.row field → .task (so entry.row.X reads as entry.task.X) * Loop variable in resolveRecipients renamed for symmetry Variable referring to inserted ledger rows (`for (const row of inserted)`) intentionally kept as `row` — that's a SQL row, not a task. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/jobs/notifications/send-task-reminders.ts | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/src/jobs/notifications/send-task-reminders.ts b/src/jobs/notifications/send-task-reminders.ts index 1127e737a..e863990b4 100644 --- a/src/jobs/notifications/send-task-reminders.ts +++ b/src/jobs/notifications/send-task-reminders.ts @@ -21,7 +21,7 @@ type WorkspaceTotals = { enqueued: number; skipped: number } type Recipient = { clientId: string; companyId: string | null } type LedgerPlanEntry = { - row: EligibilityRow + task: EligibilityRow recipient: Recipient } @@ -32,36 +32,36 @@ export const sendTaskReminders = schedules.task({ run: async (payload) => { const db = DBClient.getInstance() - const allRows = await getEligibleReminders(db) - const rows = allRows.filter((r) => r.assigneeType !== AssigneeType.internalUser) + const eligibleTasks = await getEligibleReminders(db) + const tasks = eligibleTasks.filter((t) => t.assigneeType !== AssigneeType.internalUser) - const byWorkspace = new Map() - for (const row of rows) { - const bucket = byWorkspace.get(row.workspaceId) - if (bucket) bucket.push(row) - else byWorkspace.set(row.workspaceId, [row]) + const tasksByWorkspace = new Map() + for (const task of tasks) { + const bucket = tasksByWorkspace.get(task.workspaceId) + if (bucket) bucket.push(task) + else tasksByWorkspace.set(task.workspaceId, [task]) } logger.log('send-task-reminders: sweep starting', { - totalEligible: allRows.length, - afterIuFilter: rows.length, - eligibleWorkspaces: byWorkspace.size, + totalEligible: eligibleTasks.length, + afterIuFilter: tasks.length, + eligibleWorkspaces: tasksByWorkspace.size, workspaceConcurrency: WORKSPACE_CONCURRENCY, runAt: payload.timestamp, }) const totals = { enqueued: 0, skipped: 0 } let processed = 0 - const workspaceCount = byWorkspace.size + const workspaceCount = tasksByWorkspace.size const workspaceBottleneck = new Bottleneck({ maxConcurrent: WORKSPACE_CONCURRENCY }) await Promise.allSettled( - Array.from(byWorkspace.entries()).map(([workspaceId, workspaceRows]) => + Array.from(tasksByWorkspace.entries()).map(([workspaceId, workspaceTasks]) => workspaceBottleneck.schedule(async () => { let wsTotals: WorkspaceTotals = { enqueued: 0, skipped: 0 } try { - wsTotals = await processWorkspace(db, workspaceId, workspaceRows) + wsTotals = await processWorkspace(db, workspaceId, workspaceTasks) } catch (err) { logger.error('send-task-reminders: workspace failed', { workspaceId, @@ -83,7 +83,7 @@ export const sendTaskReminders = schedules.task({ logger.log('send-task-reminders: sweep complete', { ...totals, workspaceCount, - totalEligible: allRows.length, + totalEligible: eligibleTasks.length, }) return { ...totals, workspaceCount } @@ -93,7 +93,7 @@ export const sendTaskReminders = schedules.task({ const processWorkspace = async ( db: ReturnType, workspaceId: string, - rows: EligibilityRow[], + tasks: EligibilityRow[], ): Promise => { // Workspace-scoped apiKey: empty token + `${workspaceId}/${apiKey}` is accepted by the // SDK when COPILOT_ENV is set on the Trigger.dev runtime. @@ -101,10 +101,10 @@ const processWorkspace = async ( const workspace = await copilot.getWorkspace() const plan: LedgerPlanEntry[] = [] - for (const row of rows) { - const recipients = await resolveRecipients(copilot, row) + for (const task of tasks) { + const recipients = await resolveRecipients(copilot, task) for (const recipient of recipients) { - plan.push({ row, recipient }) + plan.push({ task, recipient }) } } @@ -113,17 +113,17 @@ const processWorkspace = async ( // Ledger insert before send: the unique constraint is the dedupe primitive. const inserted = await db.taskReminderSent.createManyAndReturn({ data: plan.map((entry) => ({ - taskId: entry.row.taskId, + taskId: entry.task.taskId, workspaceId, recipientId: entry.recipient.clientId, - reminderType: entry.row.reminderType, + reminderType: entry.task.reminderType, })), skipDuplicates: true, }) const insertedKey = (taskId: string, recipientId: string, type: TaskReminderType) => `${taskId}|${recipientId}|${type}` const planByKey = new Map( - plan.map((e) => [insertedKey(e.row.taskId, e.recipient.clientId, e.row.reminderType), e]), + plan.map((e) => [insertedKey(e.task.taskId, e.recipient.clientId, e.task.reminderType), e]), ) const triggers: { payload: DispatchReminderEmailPayload }[] = [] @@ -134,11 +134,11 @@ const processWorkspace = async ( payload: { ledgerId: row.id, workspaceId, - task: { id: entry.row.taskId, title: entry.row.title, createdById: entry.row.createdById }, + task: { id: entry.task.taskId, title: entry.task.title, createdById: entry.task.createdById }, recipientClientId: entry.recipient.clientId, recipientCompanyId: entry.recipient.companyId, - reminderType: entry.row.reminderType, - isCompanyRecipient: entry.row.assigneeType === AssigneeType.company, + reminderType: entry.task.reminderType, + isCompanyRecipient: entry.task.assigneeType === AssigneeType.company, workspace, }, }) @@ -176,13 +176,13 @@ const processWorkspace = async ( return { enqueued, skipped: plan.length - inserted.length } } -const resolveRecipients = async (copilot: CopilotAPI, row: EligibilityRow): Promise => { - if (row.assigneeType === AssigneeType.client) { - return [{ clientId: row.assigneeId, companyId: row.companyId }] +const resolveRecipients = async (copilot: CopilotAPI, task: EligibilityRow): Promise => { + if (task.assigneeType === AssigneeType.client) { + return [{ clientId: task.assigneeId, companyId: task.companyId }] } - if (row.assigneeType === AssigneeType.company) { - const members: ClientResponse[] = await copilot.getCompanyClients(row.assigneeId) - return members.map((m) => ({ clientId: m.id, companyId: row.assigneeId })) + if (task.assigneeType === AssigneeType.company) { + const members: ClientResponse[] = await copilot.getCompanyClients(task.assigneeId) + return members.map((m) => ({ clientId: m.id, companyId: task.assigneeId })) } return [] } From b0b133759c41e21c178435a8f1c9b83b645b0db4 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 26 May 2026 17:28:29 +0545 Subject: [PATCH 19/27] fix(OUT-3730): contain getCompanyClients failure to the failing task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: a single getCompanyClients throw (after Copilot's own withRetry exhausts) would propagate out of the plan loop, leak through processWorkspace, and the outer try/catch would mark the entire workspace as failed — dropping every other eligible task in that workspace for the day, including client-assigned tasks that don't even need fan-out. After: per-task try/catch around resolveRecipients. The failing task is logged and skipped; siblings continue. No added retry — Copilot's internal retry is the only retry layer; this is just blast-radius containment. Resolves greptile P1 on PR #1258. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../notifications/send-task-reminders.test.ts | 36 +++++++++++++++++++ src/jobs/notifications/send-task-reminders.ts | 17 ++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/jobs/notifications/send-task-reminders.test.ts b/src/jobs/notifications/send-task-reminders.test.ts index d662727fe..257dc5d5c 100644 --- a/src/jobs/notifications/send-task-reminders.test.ts +++ b/src/jobs/notifications/send-task-reminders.test.ts @@ -237,6 +237,42 @@ describe('sendTaskReminders', () => { expect(deleteArgs.where.id.in[299]).toBe('l_799') }) + it('skips a single task whose getCompanyClients fails without dropping siblings', async () => { + // Two company tasks in the same workspace. The first one's fan-out throws (Copilot + // exhausted its own retries); the second one should still get its reminder enqueued. + mockGetEligibleReminders.mockResolvedValueOnce([ + buildRow({ + taskId: 'task_bad', + assigneeType: AssigneeType.company, + assigneeId: 'company_bad', + companyId: 'company_bad', + }), + buildRow({ + taskId: 'task_good', + assigneeType: AssigneeType.company, + assigneeId: 'company_good', + companyId: 'company_good', + }), + ]) + mockGetCompanyClients + .mockRejectedValueOnce(new Error('copilot 5xx')) + .mockResolvedValueOnce([{ id: 'm_1' }, { id: 'm_2' }]) + mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([ + { id: 'l_1', taskId: 'task_good', recipientId: 'm_1', reminderType: TaskReminderType.NO_DUE_DATE_3D }, + { id: 'l_2', taskId: 'task_good', recipientId: 'm_2', reminderType: TaskReminderType.NO_DUE_DATE_3D }, + ]) + + const result = await runJob() + + expect(result).toEqual({ enqueued: 2, skipped: 0, workspaceCount: 1 }) + expect(mockBatchTrigger).toHaveBeenCalledTimes(1) + const batch = mockBatchTrigger.mock.calls[0][0] + expect(batch.map((b: { payload: { recipientClientId: string } }) => b.payload.recipientClientId).sort()).toEqual([ + 'm_1', + 'm_2', + ]) + }) + it('does not abort the sweep when one workspace throws (per-workspace isolation)', async () => { mockGetEligibleReminders.mockResolvedValueOnce([ buildRow({ workspaceId: 'ws_bad', taskId: 'task_bad' }), diff --git a/src/jobs/notifications/send-task-reminders.ts b/src/jobs/notifications/send-task-reminders.ts index e863990b4..5f0521792 100644 --- a/src/jobs/notifications/send-task-reminders.ts +++ b/src/jobs/notifications/send-task-reminders.ts @@ -102,7 +102,22 @@ const processWorkspace = async ( const plan: LedgerPlanEntry[] = [] for (const task of tasks) { - const recipients = await resolveRecipients(copilot, task) + let recipients: Recipient[] + try { + recipients = await resolveRecipients(copilot, task) + } catch (err) { + // Contain blast radius to this task. Copilot is already wrapped in withRetry, so + // a thrown error means retries are exhausted — propagating would drop unrelated + // sibling tasks in the same workspace for the day. + logger.error('send-task-reminders: failed to resolve recipients, skipping task', { + workspaceId, + taskId: task.taskId, + assigneeType: task.assigneeType, + assigneeId: task.assigneeId, + error: serializeError(err), + }) + continue + } for (const recipient of recipients) { plan.push({ task, recipient }) } From 4961236fa78acabe5e1667d50ec00c7c1660eddb Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 26 May 2026 20:37:37 +0545 Subject: [PATCH 20/27] refactor(OUT-3730): address PR review feedback (priosshrsth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Extract serializeError to src/utils/serializeError.ts; drop the duplicated local copy in send-task-reminders.ts and dispatch-reminder-email.ts. * Simplify resolveRecipients — drop the dead `return []` branch since IUs are filtered upstream; the function now reads as "client by default, fan out only for company". * In dispatchReminderEmailOnFailure, replace the `p` alias with a typed destructure of the payload. The SDK's AnyOnFailureHookFunction types the payload as `unknown`, so we still cast once at destructure time, but downstream code reads the meaningful field names directly. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../notifications/dispatch-reminder-email.ts | 20 +++++++++---------- src/jobs/notifications/send-task-reminders.ts | 14 +++++-------- src/utils/serializeError.ts | 2 ++ 3 files changed, 17 insertions(+), 19 deletions(-) create mode 100644 src/utils/serializeError.ts diff --git a/src/jobs/notifications/dispatch-reminder-email.ts b/src/jobs/notifications/dispatch-reminder-email.ts index 481d0960a..8a80ff0db 100644 --- a/src/jobs/notifications/dispatch-reminder-email.ts +++ b/src/jobs/notifications/dispatch-reminder-email.ts @@ -4,6 +4,7 @@ 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' @@ -22,8 +23,6 @@ export type DispatchReminderEmailPayload = { const TASK_ID = 'dispatch-reminder-email' -const serializeError = (err: unknown) => (err instanceof Error ? { message: err.message, stack: err.stack } : err) - export const dispatchReminderEmailRun = async (payload: DispatchReminderEmailPayload) => { const copilot = new CopilotAPI('', `${payload.workspaceId}/${copilotAPIKey}`) const notificationId = await sendReminderEmail({ @@ -40,22 +39,23 @@ export const dispatchReminderEmailRun = async (payload: DispatchReminderEmailPay // 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 p = payload as DispatchReminderEmailPayload + const { ledgerId, workspaceId, task, recipientClientId, reminderType } = payload as DispatchReminderEmailPayload logger.error('dispatch-reminder-email: retries exhausted, compensating ledger', { - ledgerId: p.ledgerId, - workspaceId: p.workspaceId, - taskId: p.task.id, - recipientClientId: p.recipientClientId, - reminderType: p.reminderType, + ledgerId, + workspaceId, + taskId: task.id, + recipientClientId, + reminderType, error: serializeError(error), }) const db = DBClient.getInstance() try { - await db.taskReminderSent.delete({ where: { id: p.ledgerId } }) + await db.taskReminderSent.delete({ where: { id: ledgerId } }) } catch (deleteErr) { logger.error('dispatch-reminder-email: ledger compensation DELETE failed, reminder will not retry', { - ledgerId: p.ledgerId, + ledgerId, error: serializeError(deleteErr), }) } diff --git a/src/jobs/notifications/send-task-reminders.ts b/src/jobs/notifications/send-task-reminders.ts index 5f0521792..db27a88b9 100644 --- a/src/jobs/notifications/send-task-reminders.ts +++ b/src/jobs/notifications/send-task-reminders.ts @@ -2,8 +2,8 @@ import 'server-only' import { copilotAPIKey } from '@/config' import DBClient from '@/lib/db' -import { ClientResponse } from '@/types/common' import { CopilotAPI } from '@/utils/CopilotAPI' +import { serializeError } from '@/utils/serializeError' import { AssigneeType, TaskReminderType } from '@prisma/client' import { logger, schedules } from '@trigger.dev/sdk/v3' import Bottleneck from 'bottleneck' @@ -191,15 +191,11 @@ const processWorkspace = async ( return { enqueued, skipped: plan.length - inserted.length } } +// IU rows are filtered upstream so only client/company assignees reach here. const resolveRecipients = async (copilot: CopilotAPI, task: EligibilityRow): Promise => { - if (task.assigneeType === AssigneeType.client) { + if (task.assigneeType !== AssigneeType.company) { return [{ clientId: task.assigneeId, companyId: task.companyId }] } - if (task.assigneeType === AssigneeType.company) { - const members: ClientResponse[] = await copilot.getCompanyClients(task.assigneeId) - return members.map((m) => ({ clientId: m.id, companyId: task.assigneeId })) - } - return [] + const members = await copilot.getCompanyClients(task.assigneeId) + return members.map((m) => ({ clientId: m.id, companyId: task.assigneeId })) } - -const serializeError = (err: unknown) => (err instanceof Error ? { message: err.message, stack: err.stack } : err) diff --git a/src/utils/serializeError.ts b/src/utils/serializeError.ts new file mode 100644 index 000000000..1bcfff086 --- /dev/null +++ b/src/utils/serializeError.ts @@ -0,0 +1,2 @@ +// JS can throw anything; this turns the unknown into something safe to log. +export const serializeError = (err: unknown) => (err instanceof Error ? { message: err.message, stack: err.stack } : err) From 0f1adb005216e2100e531f29ea3314f063bdae0d Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Tue, 26 May 2026 20:40:21 +0545 Subject: [PATCH 21/27] refactor(OUT-3730): extract dispatchChunk helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chunked batchTrigger loop had a nested try/catch and manual index arithmetic inline. Extract the dispatch-or-compensate logic into a small closure so the outer loop reads as just "chunk and accumulate": for (let i = 0; i < triggers.length; i += BATCH_TRIGGER_CHUNK_SIZE) { enqueued += await dispatchChunk(triggers.slice(i, i + BATCH_TRIGGER_CHUNK_SIZE)) } Per-chunk compensation semantics are unchanged. `chunkOffset` dropped from the failure log — workspaceId + chunkSize + log ordering are enough for post-mortem, and the index didn't add diagnostic value worth the noise. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/jobs/notifications/send-task-reminders.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/jobs/notifications/send-task-reminders.ts b/src/jobs/notifications/send-task-reminders.ts index db27a88b9..cca3c79e7 100644 --- a/src/jobs/notifications/send-task-reminders.ts +++ b/src/jobs/notifications/send-task-reminders.ts @@ -159,21 +159,18 @@ const processWorkspace = async ( }) } - let enqueued = 0 - for (let i = 0; i < triggers.length; i += BATCH_TRIGGER_CHUNK_SIZE) { - const chunk = triggers.slice(i, i + BATCH_TRIGGER_CHUNK_SIZE) + // Returns the number actually enqueued. On failure, drops the chunk's ledger rows + // so the next cron run can retry — without this, the unique constraint blocks any + // future insert but no dispatcher exists to consume them. + const dispatchChunk = async (chunk: { payload: DispatchReminderEmailPayload }[]): Promise => { try { await dispatchReminderEmail.batchTrigger(chunk) - enqueued += chunk.length + return chunk.length } catch (err) { - // Compensate: drop the chunk's ledger rows so the next cron run retries them. - // Without this, the rows are orphans: the unique constraint blocks future inserts - // but no dispatcher will ever consume them. const ledgerIds = chunk.map((t) => t.payload.ledgerId) logger.error('send-task-reminders: batchTrigger failed, compensating ledger', { workspaceId, chunkSize: chunk.length, - chunkOffset: i, error: serializeError(err), }) try { @@ -185,9 +182,15 @@ const processWorkspace = async ( error: serializeError(deleteErr), }) } + return 0 } } + let enqueued = 0 + for (let i = 0; i < triggers.length; i += BATCH_TRIGGER_CHUNK_SIZE) { + enqueued += await dispatchChunk(triggers.slice(i, i + BATCH_TRIGGER_CHUNK_SIZE)) + } + return { enqueued, skipped: plan.length - inserted.length } } From eb67c03591d9a57e5a8708697d0ba085001885c7 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 27 May 2026 16:38:33 +0545 Subject: [PATCH 22/27] feat(OUT-3738): structured logging + Sentry for reminder cron Wire Sentry into the Trigger.dev runtime (it runs in a separate process from the Next.js server, so sentry.server.config.ts never loads there) via a one-time init in src/jobs/sentry.ts, reusing the installed @sentry/nextjs. - send-task-reminders: capture eligibility-query failures (rethrow so the run still fails) and emit one structured run-summary log. - dispatch-reminder-email: capture terminal send failures in onFailure with taskId / recipientId / reminderType / workspaceId tags. ON CONFLICT skips are never captured. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dispatch-reminder-email.test.ts | 24 +++++++++++++++++ .../notifications/dispatch-reminder-email.ts | 12 +++++++++ .../notifications/send-task-reminders.test.ts | 19 ++++++++++++++ src/jobs/notifications/send-task-reminders.ts | 26 +++++++++++++++---- src/jobs/sentry.ts | 24 +++++++++++++++++ 5 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 src/jobs/sentry.ts diff --git a/src/jobs/notifications/dispatch-reminder-email.test.ts b/src/jobs/notifications/dispatch-reminder-email.test.ts index a9188e6dc..416e49bd6 100644 --- a/src/jobs/notifications/dispatch-reminder-email.test.ts +++ b/src/jobs/notifications/dispatch-reminder-email.test.ts @@ -3,6 +3,7 @@ import { TaskReminderType } from '@prisma/client' const mockSendReminderEmail = jest.fn() const mockTaskReminderSentDelete = jest.fn() const mockCopilotApiCtor = jest.fn() +const mockCaptureException = jest.fn() jest.mock('@trigger.dev/sdk/v3', () => ({ task: ({ run }: { run: (payload: unknown) => unknown }) => ({ run }), @@ -12,6 +13,10 @@ jest.mock('@trigger.dev/sdk/v3', () => ({ jest.mock('@/config', () => ({ copilotAPIKey: 'test-api-key' })) +jest.mock('@/jobs/sentry', () => ({ + Sentry: { captureException: (...args: unknown[]) => mockCaptureException(...args) }, +})) + jest.mock('@/lib/db', () => ({ __esModule: true, default: { @@ -56,6 +61,7 @@ describe('dispatchReminderEmail', () => { mockSendReminderEmail.mockReset() mockTaskReminderSentDelete.mockReset() mockCopilotApiCtor.mockReset() + mockCaptureException.mockReset() }) describe('run', () => { @@ -81,6 +87,7 @@ describe('dispatchReminderEmail', () => { await expect(dispatchReminderEmailRun(buildPayload())).rejects.toThrow('copilot 5xx') expect(mockTaskReminderSentDelete).not.toHaveBeenCalled() // compensation is onFailure's job, not run's + expect(mockCaptureException).not.toHaveBeenCalled() // capture waits for retries to exhaust (onFailure) }) }) @@ -96,6 +103,23 @@ describe('dispatchReminderEmail', () => { expect(mockTaskReminderSentDelete).toHaveBeenCalledWith({ where: { id: 'ledger_1' } }) }) + it('captures the terminal failure to Sentry with task/recipient/reminder/workspace tags', async () => { + mockTaskReminderSentDelete.mockResolvedValueOnce({ id: 'ledger_1' }) + const error = new Error('copilot 500 after retries') + + await dispatchReminderEmailOnFailure({ payload: buildPayload(), error }) + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + tags: { + job: 'dispatch-reminder-email', + taskId: 'task_1', + recipientId: 'client_1', + reminderType: TaskReminderType.NO_DUE_DATE_3D, + workspaceId: 'ws_1', + }, + }) + }) + it('does not throw if the ledger DELETE itself fails (logs and moves on)', async () => { mockTaskReminderSentDelete.mockRejectedValueOnce(new Error('db blew up')) diff --git a/src/jobs/notifications/dispatch-reminder-email.ts b/src/jobs/notifications/dispatch-reminder-email.ts index 8a80ff0db..a687006b8 100644 --- a/src/jobs/notifications/dispatch-reminder-email.ts +++ b/src/jobs/notifications/dispatch-reminder-email.ts @@ -1,6 +1,7 @@ import 'server-only' import { copilotAPIKey } from '@/config' +import { Sentry } from '@/jobs/sentry' import DBClient from '@/lib/db' import { WorkspaceResponse } from '@/types/common' import { CopilotAPI } from '@/utils/CopilotAPI' @@ -42,6 +43,17 @@ export const dispatchReminderEmailRun = async (payload: DispatchReminderEmailPay // 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 + // Terminal send failure (Copilot 500 etc. survived all retries). Capture here, not in + // run's catch, so transient errors a retry recovers don't generate Sentry noise. + Sentry.captureException(error, { + tags: { + job: 'dispatch-reminder-email', + taskId: task.id, + recipientId: recipientClientId, + reminderType, + workspaceId, + }, + }) logger.error('dispatch-reminder-email: retries exhausted, compensating ledger', { ledgerId, workspaceId, diff --git a/src/jobs/notifications/send-task-reminders.test.ts b/src/jobs/notifications/send-task-reminders.test.ts index 257dc5d5c..644b5dadd 100644 --- a/src/jobs/notifications/send-task-reminders.test.ts +++ b/src/jobs/notifications/send-task-reminders.test.ts @@ -7,6 +7,7 @@ const mockBatchTrigger = jest.fn() const mockGetWorkspace = jest.fn() const mockGetCompanyClients = jest.fn() const mockCopilotApiCtor = jest.fn() +const mockCaptureException = jest.fn() jest.mock('@trigger.dev/sdk/v3', () => ({ schedules: { @@ -17,6 +18,10 @@ jest.mock('@trigger.dev/sdk/v3', () => ({ jest.mock('@/config', () => ({ copilotAPIKey: 'test-api-key' })) +jest.mock('@/jobs/sentry', () => ({ + Sentry: { captureException: (...args: unknown[]) => mockCaptureException(...args) }, +})) + jest.mock('@/lib/db', () => ({ __esModule: true, default: { @@ -90,6 +95,7 @@ describe('sendTaskReminders', () => { mockGetWorkspace.mockReset() mockGetCompanyClients.mockReset() mockCopilotApiCtor.mockReset() + mockCaptureException.mockReset() mockGetWorkspace.mockResolvedValue(workspace) mockBatchTrigger.mockResolvedValue({ batchId: 'b1' }) }) @@ -159,6 +165,19 @@ describe('sendTaskReminders', () => { expect(result).toEqual({ enqueued: 0, skipped: 1, workspaceCount: 1 }) expect(mockBatchTrigger).not.toHaveBeenCalled() + // ON CONFLICT dedupe is normal — it must never reach Sentry. + expect(mockCaptureException).not.toHaveBeenCalled() + }) + + it('captures eligibility-query failures to Sentry and rethrows so the run fails', async () => { + const boom = new Error('eligibility SQL blew up') + mockGetEligibleReminders.mockRejectedValueOnce(boom) + + await expect(runJob()).rejects.toThrow('eligibility SQL blew up') + expect(mockCaptureException).toHaveBeenCalledWith(boom, { + tags: { job: 'send-task-reminders', phase: 'eligibility' }, + }) + expect(mockTaskReminderSentCreateManyAndReturn).not.toHaveBeenCalled() }) it('fans out a company-assigned task to one dispatch per current member', async () => { diff --git a/src/jobs/notifications/send-task-reminders.ts b/src/jobs/notifications/send-task-reminders.ts index cca3c79e7..84607584b 100644 --- a/src/jobs/notifications/send-task-reminders.ts +++ b/src/jobs/notifications/send-task-reminders.ts @@ -1,6 +1,7 @@ import 'server-only' import { copilotAPIKey } from '@/config' +import { Sentry } from '@/jobs/sentry' import DBClient from '@/lib/db' import { CopilotAPI } from '@/utils/CopilotAPI' import { serializeError } from '@/utils/serializeError' @@ -32,7 +33,16 @@ export const sendTaskReminders = schedules.task({ run: async (payload) => { const db = DBClient.getInstance() - const eligibleTasks = await getEligibleReminders(db) + let eligibleTasks: EligibilityRow[] + try { + eligibleTasks = await getEligibleReminders(db) + } catch (err) { + // A broken eligibility query means zero reminders go out for the day — make it loud. + // Rethrow so Trigger.dev also marks the run failed; the Sentry event carries the cause. + Sentry.captureException(err, { tags: { job: 'send-task-reminders', phase: 'eligibility' } }) + logger.error('send-task-reminders: eligibility query failed', { error: serializeError(err) }) + throw err + } const tasks = eligibleTasks.filter((t) => t.assigneeType !== AssigneeType.internalUser) const tasksByWorkspace = new Map() @@ -80,10 +90,16 @@ export const sendTaskReminders = schedules.task({ ), ) - logger.log('send-task-reminders: sweep complete', { - ...totals, - workspaceCount, - totalEligible: eligibleTasks.length, + // One greppable structured summary per run. `enqueued`/`skipped` are what this + // orchestrator can know: it fans out to dispatch-reminder-email rather than sending + // inline, so per-email sent/failed counts live in that task's Trigger.dev run metrics + // and its onFailure Sentry capture, not here. `skipped` is ON CONFLICT dedupe, not a failure. + logger.log('send-task-reminders: run summary', { + eligibleWorkspaces: workspaceCount, + totalEligibleTasks: eligibleTasks.length, + enqueued: totals.enqueued, + skipped: totals.skipped, + runAt: payload.timestamp, }) return { ...totals, workspaceCount } diff --git a/src/jobs/sentry.ts b/src/jobs/sentry.ts new file mode 100644 index 000000000..3e3c5a050 --- /dev/null +++ b/src/jobs/sentry.ts @@ -0,0 +1,24 @@ +import 'server-only' + +import * as Sentry from '@sentry/nextjs' + +// Trigger.dev runs jobs in a standalone Node process, separate from the Next.js server, so +// `sentry.server.config.ts` (loaded via instrumentation.ts) never executes here — without +// this init, `Sentry.captureException` from a job would be a silent no-op. We reuse the +// already-installed @sentry/nextjs (its exports delegate to @sentry/node on the server) +// rather than pulling in a second SDK. Module-level side effect: ESM evaluates this once, +// the first time a job imports it, which is exactly when we need the client ready. +const dsn = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN + +if (dsn) { + Sentry.init({ + dsn, + // Keep the runtime lean: targeted captureException calls don't need the full default + // integration set (matches Trigger.dev's documented Sentry setup). + defaultIntegrations: false, + environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'development', + ignoreErrors: [/fetch failed/i], + }) +} + +export { Sentry } From 815fa642396ab25fdd9f6c67ac2ce19542c12b2d Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 27 May 2026 18:25:34 +0545 Subject: [PATCH 23/27] test(OUT-3731): reminder eligibility + idempotency tests (real DB) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a testcontainers-backed integration harness (jest.integration.config.ts + test/integration/*) that boots an ephemeral Postgres, applies the real migration history, and is hard-guarded to never truncate a non-local DB. Kept separate from the default `jest` unit run. - eligibility.integration.test.ts: exercises the real SQL — all six windows hit on their exact day, boundary misses, deleted/archived/completed exclusions, company single-row, and subtask carve-outs. - reminder-idempotency.integration.test.ts: one send → one ledger row; re-run → zero new (unique constraint); forced Copilot failure → ledger cleared + Sentry event. Fix surfaced by the real DB: the global softDelete Prisma extension rewrites .delete()/.deleteMany() into deletedAt updates for every model, but TaskReminderSents has no deletedAt — so ledger compensation silently failed and the unique constraint would block all future re-sends. Both compensation paths now hard-delete via $executeRaw; affected unit tests updated. Co-Authored-By: Claude Opus 4.7 (1M context) --- jest.config.ts | 6 +- jest.integration.config.ts | 24 + package.json | 3 + .../dispatch-reminder-email.test.ts | 21 +- .../notifications/dispatch-reminder-email.ts | 6 +- .../eligibility.integration.test.ts | 164 +++++ .../reminder-idempotency.integration.test.ts | 143 ++++ .../notifications/send-task-reminders.test.ts | 21 +- src/jobs/notifications/send-task-reminders.ts | 7 +- test/integration/db.ts | 121 ++++ test/integration/globalSetup.ts | 26 + test/integration/globalTeardown.ts | 15 + test/integration/paths.ts | 6 + test/integration/setup-env.ts | 26 + yarn.lock | 634 +++++++++++++++++- 15 files changed, 1185 insertions(+), 38 deletions(-) create mode 100644 jest.integration.config.ts create mode 100644 src/jobs/notifications/eligibility.integration.test.ts create mode 100644 src/jobs/notifications/reminder-idempotency.integration.test.ts create mode 100644 test/integration/db.ts create mode 100644 test/integration/globalSetup.ts create mode 100644 test/integration/globalTeardown.ts create mode 100644 test/integration/paths.ts create mode 100644 test/integration/setup-env.ts diff --git a/jest.config.ts b/jest.config.ts index a1d73c021..9ecf33adc 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -169,10 +169,8 @@ const config: Config = { // "**/?(*.)+(spec|test).[tj]s?(x)" // ], - // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - // testPathIgnorePatterns: [ - // "/node_modules/" - // ], + // Integration tests need a real Postgres and run via jest.integration.config.ts, not here. + testPathIgnorePatterns: ['/node_modules/', '\\.integration\\.test\\.ts$'], // The regexp pattern or array of patterns that Jest uses to detect test files // testRegex: [], diff --git a/jest.integration.config.ts b/jest.integration.config.ts new file mode 100644 index 000000000..e5caaad5f --- /dev/null +++ b/jest.integration.config.ts @@ -0,0 +1,24 @@ +import type { Config } from 'jest' +import nextJest from 'next/jest.js' + +const createJestConfig = nextJest({ dir: './' }) + +// Real-Postgres integration tests. A testcontainer is booted once in globalSetup, migrated, +// and torn down after. Kept separate from the default `jest` run, which has no DB. +const config: Config = { + testEnvironment: 'node', + testMatch: ['**/*.integration.test.ts'], + globalSetup: '/test/integration/globalSetup.ts', + globalTeardown: '/test/integration/globalTeardown.ts', + setupFilesAfterEnv: ['/test/integration/setup-env.ts'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '^@api/(.*)$': '/src/app/api/$1', + }, + collectCoverage: false, + // One Postgres, shared serially: parallel workers would race truncateAll between tests. + maxWorkers: 1, + testTimeout: 30000, +} + +export default createJestConfig(config) diff --git a/package.json b/package.json index 8f237946c..6326c10d8 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@faker-js/faker": "^8.4.1", "@ngrok/ngrok": "^1.4.1", "@svgr/webpack": "^8.1.0", + "@testcontainers/postgresql": "^12.0.0", "@trigger.dev/build": "4.3.1", "@types/file-saver": "^2.0.7", "@types/jest": "^29.5.12", @@ -81,6 +82,7 @@ "prettier": "^3.1.1", "tailwind-merge": "^3.4.0", "tailwindcss": "^3.3.0", + "testcontainers": "^12.0.0", "text-table": "^0.2.0", "ts-node": "^10.9.2", "tsx": "^4.16.5", @@ -144,6 +146,7 @@ "seed:activity-logs": "tsx ./src/cmd/fill-activity-logs", "start": "next start", "test": "jest", + "test:integration": "jest --config jest.integration.config.ts --runInBand", "tsc": "tsc --noEmit", "trigger": "npx trigger.dev@latest", "trigger:deploy-staging": "yarn trigger deploy -e staging", diff --git a/src/jobs/notifications/dispatch-reminder-email.test.ts b/src/jobs/notifications/dispatch-reminder-email.test.ts index 416e49bd6..94fb44da5 100644 --- a/src/jobs/notifications/dispatch-reminder-email.test.ts +++ b/src/jobs/notifications/dispatch-reminder-email.test.ts @@ -1,7 +1,7 @@ import { TaskReminderType } from '@prisma/client' const mockSendReminderEmail = jest.fn() -const mockTaskReminderSentDelete = jest.fn() +const mockExecuteRaw = jest.fn() const mockCopilotApiCtor = jest.fn() const mockCaptureException = jest.fn() @@ -21,7 +21,8 @@ jest.mock('@/lib/db', () => ({ __esModule: true, default: { getInstance: () => ({ - taskReminderSent: { delete: mockTaskReminderSentDelete }, + // Compensation hard-deletes via $executeRaw to bypass the softDelete extension. + $executeRaw: mockExecuteRaw, }), }, })) @@ -59,7 +60,7 @@ describe('dispatchReminderEmail', () => { beforeEach(() => { jest.clearAllMocks() mockSendReminderEmail.mockReset() - mockTaskReminderSentDelete.mockReset() + mockExecuteRaw.mockReset() mockCopilotApiCtor.mockReset() mockCaptureException.mockReset() }) @@ -86,25 +87,27 @@ describe('dispatchReminderEmail', () => { 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 + expect(mockExecuteRaw).not.toHaveBeenCalled() // compensation is onFailure's job, not run's expect(mockCaptureException).not.toHaveBeenCalled() // capture waits for retries to exhaust (onFailure) }) }) describe('onFailure', () => { - it('deletes the ledger row so the next cron run can retry', async () => { - mockTaskReminderSentDelete.mockResolvedValueOnce({ id: 'ledger_1' }) + it('hard-deletes the ledger row (raw SQL, bypassing softDelete) so the next cron run can retry', async () => { + mockExecuteRaw.mockResolvedValueOnce(1) await dispatchReminderEmailOnFailure({ payload: buildPayload(), error: new Error('all retries exhausted'), }) - expect(mockTaskReminderSentDelete).toHaveBeenCalledWith({ where: { id: 'ledger_1' } }) + expect(mockExecuteRaw).toHaveBeenCalledTimes(1) + // $executeRaw is a tagged template: calls[0] = [stringsArray, ...boundValues]. + expect(mockExecuteRaw.mock.calls[0][1]).toBe('ledger_1') }) it('captures the terminal failure to Sentry with task/recipient/reminder/workspace tags', async () => { - mockTaskReminderSentDelete.mockResolvedValueOnce({ id: 'ledger_1' }) + mockExecuteRaw.mockResolvedValueOnce(1) const error = new Error('copilot 500 after retries') await dispatchReminderEmailOnFailure({ payload: buildPayload(), error }) @@ -121,7 +124,7 @@ describe('dispatchReminderEmail', () => { }) it('does not throw if the ledger DELETE itself fails (logs and moves on)', async () => { - mockTaskReminderSentDelete.mockRejectedValueOnce(new Error('db blew up')) + mockExecuteRaw.mockRejectedValueOnce(new Error('db blew up')) await expect( dispatchReminderEmailOnFailure({ diff --git a/src/jobs/notifications/dispatch-reminder-email.ts b/src/jobs/notifications/dispatch-reminder-email.ts index a687006b8..b9fafbf67 100644 --- a/src/jobs/notifications/dispatch-reminder-email.ts +++ b/src/jobs/notifications/dispatch-reminder-email.ts @@ -64,7 +64,11 @@ export const dispatchReminderEmailOnFailure = async ({ payload, error }: { paylo }) const db = DBClient.getInstance() try { - await db.taskReminderSent.delete({ where: { id: ledgerId } }) + // Hard delete via raw SQL: the global softDelete Prisma extension rewrites .delete() into + // an update that sets deletedAt, but TaskReminderSents has no such column — so .delete() + // would throw and leave the row, and the unique constraint would then block every future + // re-send. Raw SQL bypasses the extension so the row truly clears for the next cron run. + await db.$executeRaw`DELETE FROM "TaskReminderSents" WHERE id::text = ${ledgerId}` } catch (deleteErr) { logger.error('dispatch-reminder-email: ledger compensation DELETE failed, reminder will not retry', { ledgerId, diff --git a/src/jobs/notifications/eligibility.integration.test.ts b/src/jobs/notifications/eligibility.integration.test.ts new file mode 100644 index 000000000..76207594c --- /dev/null +++ b/src/jobs/notifications/eligibility.integration.test.ts @@ -0,0 +1,164 @@ +import { AssigneeType, TaskReminderType } from '@prisma/client' + +import { getEligibleReminders } from '@/jobs/notifications/eligibility' + +import { dbToday, disconnectTestDb, getTestDb, seedTask, uuid, ymdOffset } from '../../../test/integration/db' + +// getEligibleReminders is typed against the extended DBClient; the plain test client exposes +// the same $queryRaw, so the cast is safe. +type DbArg = Parameters[0] +const run = () => getEligibleReminders(getTestDb() as unknown as DbArg) +const byTask = (rows: Awaited>) => new Map(rows.map((r) => [r.taskId, r])) + +const WS = 'ws_elig' +let today: string + +beforeEach(async () => { + await getTestDb().$executeRawUnsafe( + 'TRUNCATE TABLE "TaskReminderSents", "Tasks", "WorkflowStates" RESTART IDENTITY CASCADE', + ) + today = await dbToday() +}) + +afterAll(disconnectTestDb) + +// A client-assigned task that lands exactly on a window today. +const seedClientTask = (overrides: Partial[0]> = {}) => + seedTask({ + workspaceId: WS, + assigneeId: uuid(), + assigneeType: AssigneeType.client, + companyId: uuid(), + ...overrides, + }) + +describe('getEligibleReminders — windows', () => { + it('matches each of the six reminder windows on its exact day', async () => { + const ids = { + [TaskReminderType.NO_DUE_DATE_3D]: await seedClientTask({ assignedAtYmd: ymdOffset(today, -3), dueDate: null }), + [TaskReminderType.NO_DUE_DATE_7D]: await seedClientTask({ assignedAtYmd: ymdOffset(today, -7), dueDate: null }), + [TaskReminderType.DUE_DATE_BEFORE_3D]: await seedClientTask({ dueDate: ymdOffset(today, 3) }), + [TaskReminderType.DUE_DATE_TODAY]: await seedClientTask({ dueDate: today }), + [TaskReminderType.DUE_DATE_OVERDUE_3D]: await seedClientTask({ dueDate: ymdOffset(today, -3) }), + [TaskReminderType.DUE_DATE_OVERDUE_7D]: await seedClientTask({ dueDate: ymdOffset(today, -7) }), + } + + const rows = byTask(await run()) + + expect(rows.size).toBe(6) + for (const [reminderType, taskId] of Object.entries(ids)) { + expect(rows.get(taskId)?.reminderType).toBe(reminderType) + } + }) + + it('excludes tasks one day off either side of every window boundary', async () => { + // No-due-date windows are exactly -3 and -7; due-date windows are exactly -7/-3/0/+3. + await seedClientTask({ assignedAtYmd: ymdOffset(today, -2), dueDate: null }) + await seedClientTask({ assignedAtYmd: ymdOffset(today, -4), dueDate: null }) + await seedClientTask({ assignedAtYmd: ymdOffset(today, -6), dueDate: null }) + await seedClientTask({ assignedAtYmd: ymdOffset(today, -8), dueDate: null }) + await seedClientTask({ dueDate: ymdOffset(today, 1) }) + await seedClientTask({ dueDate: ymdOffset(today, 2) }) + await seedClientTask({ dueDate: ymdOffset(today, 4) }) + await seedClientTask({ dueDate: ymdOffset(today, -1) }) + await seedClientTask({ dueDate: ymdOffset(today, -2) }) + await seedClientTask({ dueDate: ymdOffset(today, -4) }) + + expect(await run()).toHaveLength(0) + }) +}) + +describe('getEligibleReminders — exclusions', () => { + it('excludes deleted, archived, and completed tasks but keeps an otherwise-identical control', async () => { + const window = { assignedAtYmd: ymdOffset(today, -3), dueDate: null } as const + const control = await seedClientTask(window) + await seedClientTask({ ...window, deletedAt: new Date() }) + await seedClientTask({ ...window, isArchived: true }) + await seedClientTask({ ...window, completedAt: new Date() }) + + const rows = await run() + + expect(rows.map((r) => r.taskId)).toEqual([control]) + }) +}) + +describe('getEligibleReminders — company assignment', () => { + it('emits a single company-level row (fan-out to members happens in the cron, not the SQL)', async () => { + const companyId = uuid() + const taskId = await seedTask({ + workspaceId: WS, + assigneeId: companyId, + assigneeType: AssigneeType.company, + assignedAtYmd: ymdOffset(today, -3), + dueDate: null, + }) + + const rows = await run() + + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + taskId, + assigneeType: AssigneeType.company, + companyId, // company tasks report companyId = assigneeId + reminderType: TaskReminderType.NO_DUE_DATE_3D, + }) + }) +}) + +describe('getEligibleReminders — subtasks', () => { + const aliveParent = (assigneeId: string | null) => + seedTask({ + workspaceId: WS, + assigneeId, + assigneeType: assigneeId ? AssigneeType.client : null, + assignedAtYmd: today, // parent itself is not in any window + dueDate: null, + }) + + it('includes a standalone subtask whose assignee differs from its parent', async () => { + const parentId = await aliveParent(uuid()) + const child = await seedClientTask({ parentId, assignedAtYmd: ymdOffset(today, -3), dueDate: null }) + + expect((await run()).map((r) => r.taskId)).toEqual([child]) + }) + + it("excludes a subtask that shares its alive parent's assignee", async () => { + const sharedAssignee = uuid() + const parentId = await aliveParent(sharedAssignee) + await seedClientTask({ + parentId, + assigneeId: sharedAssignee, + assignedAtYmd: ymdOffset(today, -3), + dueDate: null, + }) + + expect(await run()).toHaveLength(0) + }) + + it('includes a subtask whose parent has no assignee', async () => { + const parentId = await aliveParent(null) + const child = await seedClientTask({ parentId, assignedAtYmd: ymdOffset(today, -3), dueDate: null }) + + expect((await run()).map((r) => r.taskId)).toEqual([child]) + }) + + it('includes a same-assignee subtask when the parent is completed (dead parent treated as absent)', async () => { + const sharedAssignee = uuid() + const parentId = await seedTask({ + workspaceId: WS, + assigneeId: sharedAssignee, + assigneeType: AssigneeType.client, + assignedAtYmd: today, + dueDate: null, + completedAt: new Date(), // dead parent → does not join → carve-out does not apply + }) + const child = await seedClientTask({ + parentId, + assigneeId: sharedAssignee, + assignedAtYmd: ymdOffset(today, -3), + dueDate: null, + }) + + expect((await run()).map((r) => r.taskId)).toEqual([child]) + }) +}) diff --git a/src/jobs/notifications/reminder-idempotency.integration.test.ts b/src/jobs/notifications/reminder-idempotency.integration.test.ts new file mode 100644 index 000000000..7d615ed16 --- /dev/null +++ b/src/jobs/notifications/reminder-idempotency.integration.test.ts @@ -0,0 +1,143 @@ +import { AssigneeType, TaskReminderType } from '@prisma/client' + +import { WorkspaceResponse } from '@/types/common' + +import { dbToday, disconnectTestDb, getTestDb, seedTask, uuid, ymdOffset } from '../../../test/integration/db' + +// --- Doubles --------------------------------------------------------------- +// The DB is real (no @/lib/db mock). Copilot, Trigger.dev and Sentry are doubles: Copilot +// because we're not hitting a live API, Trigger.dev because there's no orchestrator in tests +// (batchTrigger fans out inline so the dispatcher's send + onFailure actually run), and +// Sentry to assert the capture without a transport. +const mockCreateNotification = jest.fn() +const mockGetWorkspace = jest.fn() +const mockGetCompanyClients = jest.fn() +const mockCaptureException = jest.fn() + +jest.mock('@trigger.dev/sdk/v3', () => { + type Handler = (args: { payload: unknown; error: unknown }) => Promise | void + // The dispatcher registers its onFailure at import time, before any module-scope const in + // this file is initialized (ES import hoisting), so the registry lives on globalThis — + // always initialized — instead of a const that would be in the temporal dead zone. + const g = globalThis as unknown as { __onFailureHandlers?: Record } + const store = (): Record => (g.__onFailureHandlers ??= {}) + return { + schedules: { task: ({ run }: { run: (p: unknown) => unknown }) => ({ run }) }, + // batchTrigger runs each dispatch synchronously; a thrown run simulates retry-exhaustion + // and invokes the task's registered onFailure (the real ledger-compensation path). + task: ({ id, run }: { id: string; run: (p: unknown) => Promise }) => ({ + id, + run, + batchTrigger: async (items: { payload: unknown }[]) => { + for (const item of items) { + try { + await run(item.payload) + } catch (error) { + await store()[id]?.({ payload: item.payload, error }) + } + } + return { batchId: 'test-batch' } + }, + }), + tasks: { + onFailure: (id: string, fn: Handler) => { + store()[id] = fn + }, + }, + logger: { log: jest.fn(), error: jest.fn(), warn: jest.fn() }, + } +}) + +jest.mock('@/utils/CopilotAPI', () => ({ + CopilotAPI: jest.fn().mockImplementation(() => ({ + getWorkspace: mockGetWorkspace, + getCompanyClients: mockGetCompanyClients, + createNotification: mockCreateNotification, + })), +})) + +jest.mock('@/jobs/sentry', () => ({ + Sentry: { captureException: (...args: unknown[]) => mockCaptureException(...args) }, +})) + +import { sendTaskReminders } from './send-task-reminders' + +// --- Helpers --------------------------------------------------------------- +const WS = 'ws_idem' +const workspace: WorkspaceResponse = { + id: WS, + brandName: 'Acme', + labels: { individualTerm: 'client', individualTermPlural: 'clients', groupTerm: 'company', groupTermPlural: 'companies' }, +} + +const runCron = () => + (sendTaskReminders as unknown as { run: (p: { timestamp: Date }) => Promise }).run({ timestamp: new Date() }) + +const seedEligibleClientTask = async () => { + const today = await dbToday() + const assigneeId = uuid() + const taskId = await seedTask({ + workspaceId: WS, + assigneeId, + assigneeType: AssigneeType.client, + companyId: uuid(), + assignedAtYmd: ymdOffset(today, -3), // NO_DUE_DATE_3D window + dueDate: null, + }) + return { taskId, assigneeId } +} + +beforeEach(async () => { + jest.clearAllMocks() + await getTestDb().$executeRawUnsafe( + 'TRUNCATE TABLE "TaskReminderSents", "Tasks", "WorkflowStates" RESTART IDENTITY CASCADE', + ) + mockGetWorkspace.mockResolvedValue(workspace) + mockCreateNotification.mockResolvedValue({ id: 'notif_1' }) +}) + +afterAll(disconnectTestDb) + +describe('reminder idempotency (real DB)', () => { + it('sends one email and writes one ledger row for an eligible task', async () => { + const { taskId } = await seedEligibleClientTask() + + await runCron() + + expect(mockCreateNotification).toHaveBeenCalledTimes(1) + const rows = await getTestDb().taskReminderSent.findMany({ where: { taskId } }) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ taskId, workspaceId: WS, reminderType: TaskReminderType.NO_DUE_DATE_3D }) + }) + + it('is idempotent: an immediate re-run adds no Copilot calls and no new ledger rows', async () => { + await seedEligibleClientTask() + + await runCron() + expect(mockCreateNotification).toHaveBeenCalledTimes(1) + expect(await getTestDb().taskReminderSent.count()).toBe(1) + + await runCron() // same day, same task — the unique constraint dedupes + expect(mockCreateNotification).toHaveBeenCalledTimes(1) + expect(await getTestDb().taskReminderSent.count()).toBe(1) + }) + + 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')) + + await runCron() + + // onFailure compensated the ledger so the next run can re-attempt. + expect(await getTestDb().taskReminderSent.count()).toBe(0) + expect(mockCaptureException).toHaveBeenCalledTimes(1) + const [, opts] = mockCaptureException.mock.calls[0] + expect((opts as { tags: Record }).tags).toMatchObject({ + job: 'dispatch-reminder-email', + taskId, + recipientId: assigneeId, + reminderType: TaskReminderType.NO_DUE_DATE_3D, + workspaceId: WS, + }) + }) +}) diff --git a/src/jobs/notifications/send-task-reminders.test.ts b/src/jobs/notifications/send-task-reminders.test.ts index 644b5dadd..3467eafe1 100644 --- a/src/jobs/notifications/send-task-reminders.test.ts +++ b/src/jobs/notifications/send-task-reminders.test.ts @@ -1,7 +1,7 @@ import { AssigneeType, TaskReminderType } from '@prisma/client' const mockTaskReminderSentCreateManyAndReturn = jest.fn() -const mockTaskReminderSentDeleteMany = jest.fn() +const mockExecuteRaw = jest.fn() const mockGetEligibleReminders = jest.fn() const mockBatchTrigger = jest.fn() const mockGetWorkspace = jest.fn() @@ -28,8 +28,9 @@ jest.mock('@/lib/db', () => ({ getInstance: () => ({ taskReminderSent: { createManyAndReturn: mockTaskReminderSentCreateManyAndReturn, - deleteMany: mockTaskReminderSentDeleteMany, }, + // Compensation hard-deletes via $executeRaw to bypass the softDelete extension. + $executeRaw: mockExecuteRaw, }), }, })) @@ -89,7 +90,7 @@ describe('sendTaskReminders', () => { beforeEach(() => { jest.clearAllMocks() mockTaskReminderSentCreateManyAndReturn.mockReset() - mockTaskReminderSentDeleteMany.mockReset() + mockExecuteRaw.mockReset() mockGetEligibleReminders.mockReset() mockBatchTrigger.mockReset() mockGetWorkspace.mockReset() @@ -227,7 +228,7 @@ describe('sendTaskReminders', () => { expect(mockBatchTrigger.mock.calls[0][0]).toHaveLength(500) expect(mockBatchTrigger.mock.calls[1][0]).toHaveLength(500) expect(mockBatchTrigger.mock.calls[2][0]).toHaveLength(200) - expect(mockTaskReminderSentDeleteMany).not.toHaveBeenCalled() + expect(mockExecuteRaw).not.toHaveBeenCalled() }) it('compensates the ledger when a batchTrigger chunk fails', async () => { @@ -249,11 +250,13 @@ describe('sendTaskReminders', () => { const result = await runJob() expect(result.enqueued).toBe(500) - expect(mockTaskReminderSentDeleteMany).toHaveBeenCalledTimes(1) - const deleteArgs = mockTaskReminderSentDeleteMany.mock.calls[0][0] - expect(deleteArgs.where.id.in).toHaveLength(300) // failed chunk's ledger rows - expect(deleteArgs.where.id.in[0]).toBe('l_500') - expect(deleteArgs.where.id.in[299]).toBe('l_799') + expect(mockExecuteRaw).toHaveBeenCalledTimes(1) + // $executeRaw is a tagged template: calls[0] = [stringsArray, ledgerIds] — the failed + // chunk's 300 ids passed to the DELETE ... WHERE id = ANY($1) compensation. + const ledgerIds = mockExecuteRaw.mock.calls[0][1] as string[] + expect(ledgerIds).toHaveLength(300) + expect(ledgerIds[0]).toBe('l_500') + expect(ledgerIds[299]).toBe('l_799') }) it('skips a single task whose getCompanyClients fails without dropping siblings', async () => { diff --git a/src/jobs/notifications/send-task-reminders.ts b/src/jobs/notifications/send-task-reminders.ts index 84607584b..3ed83d967 100644 --- a/src/jobs/notifications/send-task-reminders.ts +++ b/src/jobs/notifications/send-task-reminders.ts @@ -190,9 +190,12 @@ const processWorkspace = async ( error: serializeError(err), }) try { - await db.taskReminderSent.deleteMany({ where: { id: { in: ledgerIds } } }) + // Hard delete via raw SQL: the global softDelete extension would rewrite deleteMany() + // into a deletedAt update, but TaskReminderSents has no such column. Raw SQL bypasses + // it so the orphaned ledger rows truly clear and the next cron run can re-enqueue them. + await db.$executeRaw`DELETE FROM "TaskReminderSents" WHERE id::text = ANY(${ledgerIds})` } catch (deleteErr) { - logger.error('send-task-reminders: ledger compensation deleteMany failed, ledger rows orphaned', { + logger.error('send-task-reminders: ledger compensation delete failed, ledger rows orphaned', { workspaceId, ledgerIds, error: serializeError(deleteErr), diff --git a/test/integration/db.ts b/test/integration/db.ts new file mode 100644 index 000000000..e32da25cb --- /dev/null +++ b/test/integration/db.ts @@ -0,0 +1,121 @@ +import crypto from 'node:crypto' +import { readFileSync } from 'node:fs' + +import { AssigneeType, PrismaClient, StateType } from '@prisma/client' + +import { DB_URL_FILE } from './paths' + +export const uuid = (): string => crypto.randomUUID() + +let client: PrismaClient | undefined + +// A plain client with NO soft-delete extensions, so seeds can set deletedAt/isArchived +// freely and assertions see exactly what's in the table. The URL is read from the temp file +// (never the ambient env) and guarded: a destructive TRUNCATE must only ever hit the +// ephemeral testcontainer, never a real DB the dev .env might point at. +export const getTestDb = (): PrismaClient => { + if (client) return client + const url = readFileSync(DB_URL_FILE, 'utf8').trim() + if (!/@(localhost|127\.0\.0\.1)[:/]/.test(url)) { + throw new Error(`Refusing non-local DB for integration tests: ${url.replace(/:\/\/[^@]*@/, '://***@')}`) + } + client = new PrismaClient({ datasources: { db: { url } } }) + return client +} + +export const disconnectTestDb = async (): Promise => { + await client?.$disconnect() + client = undefined +} + +export const truncateAll = async (): Promise => { + await getTestDb().$executeRawUnsafe( + 'TRUNCATE TABLE "TaskReminderSents", "Tasks", "WorkflowStates" RESTART IDENTITY CASCADE', + ) +} + +// Anchor all date math to the DB clock (UTC), not JS now, so window/boundary assertions +// can't flake across a UTC midnight boundary. +export const dbToday = async (): Promise => { + const rows = await getTestDb().$queryRaw<{ today: string }[]>`SELECT CURRENT_DATE::text AS today` + return rows[0].today +} + +export const ymdOffset = (baseYmd: string, days: number): string => { + const d = new Date(`${baseYmd}T00:00:00Z`) + d.setUTCDate(d.getUTCDate() + days) + return d.toISOString().slice(0, 10) +} + +// assignedAt is a `timestamp without time zone`; storing at noon UTC keeps its ::date cast +// on the intended day regardless of the small UTC offset Prisma applies. +const noonUtc = (ymd: string): Date => new Date(`${ymd}T12:00:00Z`) + +export const seedWorkflowState = async (workspaceId: string, type: StateType = StateType.started): Promise => { + const id = uuid() + await getTestDb().workflowState.create({ + data: { id, workspaceId, type, name: 'State', key: `state-${id.slice(0, 8)}` }, + }) + return id +} + +export type SeedTaskInput = { + workspaceId: string + workflowStateId?: string + assigneeId?: string | null + assigneeType?: AssigneeType | null + companyId?: string | null + internalUserId?: string | null + clientId?: string | null + dueDate?: string | null + assignedAtYmd?: string | null + completedAt?: Date | null + isArchived?: boolean + deletedAt?: Date | null + parentId?: string | null + title?: string + createdById?: string +} + +// The Tasks table has an `assignee_to_user_id_mapping` CHECK that ties assigneeType to which +// of internalUserId/clientId/companyId must be (non-)null. Derive them from assigneeType so +// callers only specify the assignee, not the bookkeeping columns. +const assigneeColumns = (input: SeedTaskInput) => { + const { assigneeId, assigneeType } = input + if (!assigneeId) return { internalUserId: null, clientId: null, companyId: null } + switch (assigneeType) { + case AssigneeType.internalUser: + return { internalUserId: input.internalUserId ?? assigneeId, clientId: null, companyId: null } + case AssigneeType.client: + return { internalUserId: null, clientId: input.clientId ?? assigneeId, companyId: input.companyId ?? uuid() } + case AssigneeType.company: + return { internalUserId: null, clientId: null, companyId: input.companyId ?? assigneeId } + default: + return { internalUserId: null, clientId: null, companyId: null } + } +} + +export const seedTask = async (input: SeedTaskInput): Promise => { + const id = uuid() + const workflowStateId = input.workflowStateId ?? (await seedWorkflowState(input.workspaceId)) + await getTestDb().task.create({ + data: { + id, + label: `T-${id.slice(0, 8)}`, + title: input.title ?? 'Reminder task', + workspaceId: input.workspaceId, + createdById: input.createdById ?? uuid(), + workflowStateId, + assigneeId: input.assigneeId ?? null, + assigneeType: input.assigneeType ?? null, + ...assigneeColumns(input), + dueDate: input.dueDate ?? null, + assignedAt: input.assignedAtYmd ? noonUtc(input.assignedAtYmd) : null, + completedAt: input.completedAt ?? null, + isArchived: input.isArchived ?? false, + deletedAt: input.deletedAt ?? null, + parentId: input.parentId ?? null, + }, + }) + return id +} diff --git a/test/integration/globalSetup.ts b/test/integration/globalSetup.ts new file mode 100644 index 000000000..e79e19895 --- /dev/null +++ b/test/integration/globalSetup.ts @@ -0,0 +1,26 @@ +import { execSync } from 'node:child_process' +import { writeFileSync } from 'node:fs' + +import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql' + +import { DB_URL_FILE } from './paths' + +// Boots an ephemeral Postgres, applies all migrations, and publishes its URL so the SUT +// (which reads process.env.DATABASE_URL via DBClient) and the test client both hit it. +export default async function globalSetup(): Promise { + const container = await new PostgreSqlContainer('postgres:16-alpine').start() + const url = container.getConnectionUri() + + // `prisma migrate deploy` runs the real migration history (incl. CREATE EXTENSION ltree), + // so the schema matches prod exactly. dotenv won't override the env we pass explicitly, + // so the container URL wins over the dev .env DATABASE_URL/DIRECT_URL. + execSync('npx prisma migrate deploy', { + stdio: 'inherit', + env: { ...process.env, DATABASE_URL: url, DIRECT_URL: url }, + }) + + writeFileSync(DB_URL_FILE, url, 'utf8') + process.env.DATABASE_URL = url + process.env.DIRECT_URL = url + ;(globalThis as unknown as { __PG__?: StartedPostgreSqlContainer }).__PG__ = container +} diff --git a/test/integration/globalTeardown.ts b/test/integration/globalTeardown.ts new file mode 100644 index 000000000..4bb41f902 --- /dev/null +++ b/test/integration/globalTeardown.ts @@ -0,0 +1,15 @@ +import { rmSync } from 'node:fs' + +import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql' + +import { DB_URL_FILE } from './paths' + +export default async function globalTeardown(): Promise { + const container = (globalThis as unknown as { __PG__?: StartedPostgreSqlContainer }).__PG__ + await container?.stop() + try { + rmSync(DB_URL_FILE) + } catch { + /* already gone */ + } +} diff --git a/test/integration/paths.ts b/test/integration/paths.ts new file mode 100644 index 000000000..880b818ec --- /dev/null +++ b/test/integration/paths.ts @@ -0,0 +1,6 @@ +import os from 'node:os' +import path from 'node:path' + +// globalSetup writes the testcontainer's connection URL here; globalTeardown removes it and +// each worker's setup-env reads it. Kept out of the repo tree (os.tmpdir) on purpose. +export const DB_URL_FILE = path.join(os.tmpdir(), 'tasks-app-integration-db-url') diff --git a/test/integration/setup-env.ts b/test/integration/setup-env.ts new file mode 100644 index 000000000..4d94889bb --- /dev/null +++ b/test/integration/setup-env.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs' + +import DBClient from '@/lib/db' + +import { disconnectTestDb } from './db' +import { DB_URL_FILE } from './paths' + +// Runs in every worker before any test. next/jest loads the dev .env (pointing DATABASE_URL +// at a real DB) into the worker, so we override it here — last word before DBClient lazily +// reads process.env — to guarantee the SUT only ever touches the ephemeral container. +const url = readFileSync(DB_URL_FILE, 'utf8').trim() +process.env.DATABASE_URL = url +process.env.DIRECT_URL = url + +// DBClient registers a beforeExit handler that calls process.exit(); under jest that trips the +// "process.exit called" guard. Disconnect both clients and strip the handler so the worker +// exits on its own. +afterAll(async () => { + await disconnectTestDb() + try { + await DBClient.getInstance().$disconnect() + } catch { + /* DBClient was never instantiated in this file */ + } + process.removeAllListeners('beforeExit') +}) diff --git a/yarn.lock b/yarn.lock index b362168cf..d24836b03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1056,6 +1056,11 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@balena/dockerignore@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d" + integrity sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q== + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1571,6 +1576,34 @@ resolved "https://registry.yarnpkg.com/@google-cloud/precise-date/-/precise-date-4.0.0.tgz#e179893a3ad628b17a6fabdfcc9d468753aac11a" integrity sha512-1TUx3KdaU3cN7nfCdNf+UVqA/PSX29Cjcox3fZZBtINlRrXVTmUkQnCKv2MbBUbCopbK4olAT1IHl76uZyCiVA== +"@grpc/grpc-js@^1.11.1": + version "1.14.4" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.14.4.tgz#e73ff57d97802f063999545f43ebb2b1eca65d9d" + integrity sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ== + dependencies: + "@grpc/proto-loader" "^0.8.0" + "@js-sdsl/ordered-map" "^4.4.2" + +"@grpc/proto-loader@^0.7.13": + version "0.7.15" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.15.tgz#4cdfbf35a35461fc843abe8b9e2c0770b5095e60" + integrity sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.2.5" + yargs "^17.7.2" + +"@grpc/proto-loader@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.8.1.tgz#5a6b290ccbfb1ae2f6775afb74e9898bd8c5d4e8" + integrity sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.5.5" + yargs "^17.7.2" + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" @@ -2010,11 +2043,23 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@js-sdsl/ordered-map@^4.4.2": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c" + integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw== + "@jsonhero/path@^1.0.21": version "1.0.21" resolved "https://registry.yarnpkg.com/@jsonhero/path/-/path-1.0.21.tgz#fa80d6bb58a1e5c3d4f67b09f004bd4d797ba4b2" integrity sha512-gVUDj/92acpVoJwsVJ/RuWOaHyG4oFzn898WNGQItLCTQ+hOaVlEaImhwE1WqOTf+l3dGOUkbSiVKlb3q1hd1Q== +"@kwsites/file-exists@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@kwsites/file-exists/-/file-exists-1.1.1.tgz#ad1efcac13e1987d8dbaf235ef3be5b0d96faa99" + integrity sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw== + dependencies: + debug "^4.1.1" + "@microsoft/fetch-event-source@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz#9ceecc94b49fbaa15666e38ae8587f64acce007d" @@ -2828,11 +2873,21 @@ resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== +"@protobufjs/codegen@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.5.tgz#d9315ad7cf3f30aac70bda3c068443dc6f143659" + integrity sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g== + "@protobufjs/eventemitter@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== +"@protobufjs/eventemitter@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz#d512cb26c0ae026091ee2c1167f1be6faf5c842a" + integrity sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg== + "@protobufjs/fetch@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" @@ -2841,6 +2896,13 @@ "@protobufjs/aspromise" "^1.1.1" "@protobufjs/inquire" "^1.1.0" +"@protobufjs/fetch@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.1.tgz#4d6fc00c8fb64016a5c81b469d549046350f1065" + integrity sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/float@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" @@ -2851,6 +2913,11 @@ resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089" integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q== +"@protobufjs/inquire@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.2.tgz#ae64fbc014ff44c8bfad03dd4c93cd2d6a4c82db" + integrity sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw== + "@protobufjs/path@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" @@ -2866,6 +2933,11 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@protobufjs/utf8@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.1.tgz#eaee5900122c110a3dbcb728c0597014a2621774" + integrity sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg== + "@radix-ui/primitive@1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba" @@ -3642,6 +3714,13 @@ resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz#1dff176df9cc8f93c78c5e46bcea11079b397578" integrity sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA== +"@testcontainers/postgresql@^12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@testcontainers/postgresql/-/postgresql-12.0.0.tgz#3509f27c217253234122a745cebf5e78490c2bd8" + integrity sha512-mqGQHwmY+xLKFvFd3XQYaa0vDJRaJAOUfFWYbgjd4wb6hOlrK7xhszaXB7KuGCGTIJf5jvtoEB8/56oVB5s55w== + dependencies: + testcontainers "^12.0.0" + "@tiptap/core@^3.20.5": version "3.20.5" resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.20.5.tgz#edf98b45f98463b12ed59357ea9b4bf155e3e194" @@ -3891,6 +3970,23 @@ resolved "https://registry.yarnpkg.com/@types/deep-equal/-/deep-equal-1.0.4.tgz#c0a854be62d6b9fae665137a6639aab53389a147" integrity sha512-tqdiS4otQP4KmY0PR3u6KbZ5EWvhNdUoS/jc93UuK23C220lOZ/9TvjfxdPcKvqwwDVtmtSCrnr0p/2dirAxkA== +"@types/docker-modem@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/docker-modem/-/docker-modem-3.0.6.tgz#1f9262fcf85425b158ca725699a03eb23cddbf87" + integrity sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg== + dependencies: + "@types/node" "*" + "@types/ssh2" "*" + +"@types/dockerode@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/dockerode/-/dockerode-4.0.1.tgz#26a44995a86322b4489090efd97890a5585a63a5" + integrity sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q== + dependencies: + "@types/docker-modem" "*" + "@types/node" "*" + "@types/ssh2" "*" + "@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.6": version "1.0.8" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" @@ -3998,6 +4094,13 @@ dependencies: undici-types "~7.16.0" +"@types/node@^18.11.18": + version "18.19.130" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.130.tgz#da4c6324793a79defb7a62cba3947ec5add00d59" + integrity sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg== + dependencies: + undici-types "~5.26.4" + "@types/node@^20": version "20.19.16" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.16.tgz#2393d2757a91a536967bfe3935448a525e187ea6" @@ -4097,6 +4200,28 @@ resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.2.tgz#ed279a64fa438bb69f2480eda44937912bb7480a" integrity sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow== +"@types/ssh2-streams@*": + version "0.1.13" + resolved "https://registry.yarnpkg.com/@types/ssh2-streams/-/ssh2-streams-0.1.13.tgz#f8d34a22be50fb8dbafbb2bbc289add0d22daa51" + integrity sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA== + dependencies: + "@types/node" "*" + +"@types/ssh2@*": + version "1.15.5" + resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-1.15.5.tgz#6d8f45db2f39519b8d9377268fa71ed77d969686" + integrity sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ== + dependencies: + "@types/node" "^18.11.18" + +"@types/ssh2@^0.5.48": + version "0.5.52" + resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-0.5.52.tgz#9dbd8084e2a976e551d5e5e70b978ed8b5965741" + integrity sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg== + dependencies: + "@types/node" "*" + "@types/ssh2-streams" "*" + "@types/stack-utils@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" @@ -4357,6 +4482,13 @@ utf-8-validate "6.0.3" ws "8.14.2" +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + accepts@~1.3.4: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -4471,6 +4603,32 @@ anymatch@^3.0.3, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +archiver-utils@^5.0.0, archiver-utils@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-5.0.2.tgz#63bc719d951803efc72cf961a56ef810760dd14d" + integrity sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA== + dependencies: + glob "^10.0.0" + graceful-fs "^4.2.0" + is-stream "^2.0.1" + lazystream "^1.0.0" + lodash "^4.17.15" + normalize-path "^3.0.0" + readable-stream "^4.0.0" + +archiver@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/archiver/-/archiver-7.0.1.tgz#c9d91c350362040b8927379c7aa69c0655122f61" + integrity sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ== + dependencies: + archiver-utils "^5.0.2" + async "^3.2.4" + buffer-crc32 "^1.0.0" + readable-stream "^4.0.0" + readdir-glob "^1.1.2" + tar-stream "^3.0.0" + zip-stream "^6.0.1" + arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" @@ -4589,6 +4747,13 @@ arraybuffer.prototype.slice@^1.0.4: get-intrinsic "^1.2.6" is-array-buffer "^3.0.4" +asn1@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" + integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== + dependencies: + safer-buffer "~2.1.0" + ast-types-flow@^0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.8.tgz#0a85e1c92695769ac13a428bb653e7538bea27d6" @@ -4599,6 +4764,11 @@ async-function@^1.0.0: resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== +async-lock@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.1.tgz#56b8718915a9b68b10fce2f2a9a3dddf765ef53f" + integrity sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ== + async-retry@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/async-retry/-/async-retry-1.3.3.tgz#0e7f36c04d8478e7a58bdbed80cedf977785f280" @@ -4606,6 +4776,11 @@ async-retry@^1.3.3: dependencies: retry "0.13.1" +async@^3.2.4: + version "3.2.6" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" + integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -4640,6 +4815,11 @@ axobject-query@^4.1.0: resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee" integrity sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ== +b4a@^1.6.4: + version "1.8.1" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.8.1.tgz#7f16334ca80127aeb26064a28841acbf174840a4" + integrity sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw== + babel-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" @@ -4746,6 +4926,49 @@ balanced-match@^4.0.2: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== +bare-events@^2.5.4, bare-events@^2.7.0: + version "2.8.3" + resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.8.3.tgz#ed26c87a24ece41c69dd4d2d0891c2c04a949e13" + integrity sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw== + +bare-fs@^4.0.1, bare-fs@^4.5.5: + version "4.7.1" + resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.7.1.tgz#6e81f784761102867c13f0823aa48c942d160f00" + integrity sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw== + dependencies: + bare-events "^2.5.4" + bare-path "^3.0.0" + bare-stream "^2.6.4" + bare-url "^2.2.2" + fast-fifo "^1.3.2" + +bare-os@^3.0.1: + version "3.9.1" + resolved "https://registry.yarnpkg.com/bare-os/-/bare-os-3.9.1.tgz#660228ca7ffc47a72e96b6047cdd9d8342994e2f" + integrity sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ== + +bare-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.0.0.tgz#b59d18130ba52a6af9276db3e96a2e3d3ea52178" + integrity sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw== + dependencies: + bare-os "^3.0.1" + +bare-stream@^2.6.4: + version "2.13.1" + resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.13.1.tgz#acfd787a2983f5feb182ffe4c37ecc2c55b6ec85" + integrity sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow== + dependencies: + streamx "^2.25.0" + teex "^1.0.1" + +bare-url@^2.2.2: + version "2.4.3" + resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.4.3.tgz#99aedf87519225669f15ecc0b910db11cad46930" + integrity sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ== + dependencies: + bare-path "^3.0.0" + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -4766,6 +4989,13 @@ baseline-browser-mapping@^2.9.19: resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz#47bdc13027af28d341f367a4f35a07ce872e27b4" integrity sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ== +bcrypt-pbkdf@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== + dependencies: + tweetnacl "^0.14.3" + bin-links@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/bin-links/-/bin-links-5.0.0.tgz#2b0605b62dd5e1ddab3b92a3c4e24221cae06cca" @@ -4853,6 +5083,11 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" +buffer-crc32@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-1.0.0.tgz#a10993b9055081d55304bd9feb4a072de179f405" + integrity sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w== + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -4866,6 +5101,14 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + bufferutil@4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-4.0.8.tgz#1de6a71092d65d7766c4d8a522b261a6e787e8ea" @@ -4873,6 +5116,11 @@ bufferutil@4.0.8: dependencies: node-gyp-build "^4.3.0" +buildcheck@~0.0.6: + version "0.0.7" + resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.7.tgz#07a5e76c10ead8fa67d9e4c587b68f49e8f29d61" + integrity sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA== + bundle-name@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/bundle-name/-/bundle-name-4.1.0.tgz#f3b96b34160d6431a19d7688135af7cfb8797889" @@ -4887,6 +5135,11 @@ busboy@1.6.0: dependencies: streamsearch "^1.1.0" +byline@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/byline/-/byline-5.0.0.tgz#741c5216468eadc457b03410118ad77de8c1ddb1" + integrity sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q== + bytes@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" @@ -5114,6 +5367,17 @@ commondir@^1.0.1: resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== +compress-commons@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-6.0.2.tgz#26d31251a66b9d6ba23a84064ecd3a6a71d2609e" + integrity sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg== + dependencies: + crc-32 "^1.2.0" + crc32-stream "^6.0.0" + is-stream "^2.0.1" + normalize-path "^3.0.0" + readable-stream "^4.0.0" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -5189,6 +5453,11 @@ core-js@^3.37.1: resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.45.1.tgz#5810e04a1b4e9bc5ddaa4dd12e702ff67300634d" integrity sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg== +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + cors@~2.8.5: version "2.8.5" resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" @@ -5218,6 +5487,27 @@ cosmiconfig@^8.1.3: parse-json "^5.2.0" path-type "^4.0.0" +cpu-features@~0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.10.tgz#9aae536db2710c7254d7ed67cb3cbc7d29ad79c5" + integrity sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA== + dependencies: + buildcheck "~0.0.6" + nan "^2.19.0" + +crc-32@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" + integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== + +crc32-stream@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-6.0.0.tgz#8529a3868f8b27abb915f6c3617c0fadedbf9430" + integrity sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g== + dependencies: + crc-32 "^1.2.0" + readable-stream "^4.0.0" + create-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" @@ -5393,7 +5683,7 @@ dayjs@^1.11.10: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.18.tgz#835fa712aac52ab9dec8b1494098774ed7070a11" integrity sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA== -debug@4, debug@^4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.1, debug@~4.4.1: +debug@4, debug@^4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -5566,6 +5856,35 @@ dlv@^1.1.3: resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== +docker-compose@^1.4.2: + version "1.4.2" + resolved "https://registry.yarnpkg.com/docker-compose/-/docker-compose-1.4.2.tgz#a389b9ab754c722bccf97fba6206859098edf835" + integrity sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww== + dependencies: + yaml "^2.2.2" + +docker-modem@^5.0.7: + version "5.0.7" + resolved "https://registry.yarnpkg.com/docker-modem/-/docker-modem-5.0.7.tgz#57f3f0e2c7a893e66a0d4a626f9cbc933d77157b" + integrity sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA== + dependencies: + debug "^4.1.1" + readable-stream "^3.5.0" + split-ca "^1.0.1" + ssh2 "^1.15.0" + +dockerode@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/dockerode/-/dockerode-5.0.0.tgz#9def098b2f7d33c0a68d4aecd47587af7c3ce223" + integrity sha512-C52mvJ+7lcyhWNfrzVfFsbTrBfy/ezE9FGEYLpu17FUeBcCkxERk9nN7uDl/478ynDiQ4U+5DbQC2vENHkVEtQ== + dependencies: + "@balena/dockerignore" "^1.0.2" + "@grpc/grpc-js" "^1.11.1" + "@grpc/proto-loader" "^0.7.13" + docker-modem "^5.0.7" + protobufjs "^7.3.2" + tar-fs "^2.1.4" + doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -6140,6 +6459,23 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +events-universal@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/events-universal/-/events-universal-1.0.1.tgz#b56a84fd611b6610e0a2d0f09f80fdf931e2dfe6" + integrity sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw== + dependencies: + bare-events "^2.7.0" + +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + eventsource-parser@^3.0.0, eventsource-parser@^3.0.1: version "3.0.6" resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz#292e165e34cacbc936c3c92719ef326d4aeb4e90" @@ -6234,6 +6570,11 @@ fast-equals@^5.3.3: resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-5.4.0.tgz#b60073b8764f27029598447f05773c7534ba7f1e" integrity sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw== +fast-fifo@^1.2.0, fast-fifo@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" + integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== + fast-glob@3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4" @@ -6462,6 +6803,11 @@ get-package-type@^0.1.0: resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== +get-port@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/get-port/-/get-port-7.2.0.tgz#db0d52eb2d89890cdc010ed0e9a6f2d4b78cbbe7" + integrity sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg== + get-proto@^1.0.0, get-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" @@ -6527,6 +6873,18 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob@^10.0.0: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + glob@^10.3.10: version "10.4.5" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" @@ -6583,7 +6941,7 @@ gopd@^1.0.1, gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -graceful-fs@^4.2.11, graceful-fs@^4.2.9: +graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -6723,7 +7081,7 @@ iconv-lite@0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -ieee754@^1.1.13: +ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -6817,7 +7175,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3, inherits@^2.0.4: +inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -7050,7 +7408,7 @@ is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.4: dependencies: call-bound "^1.0.3" -is-stream@^2.0.0: +is-stream@^2.0.0, is-stream@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== @@ -7121,6 +7479,11 @@ isarray@^2.0.5: resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -7721,6 +8084,13 @@ language-tags@^1.0.9: dependencies: language-subtag-registry "^0.3.20" +lazystream@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.1.tgz#494c831062f1f9408251ec44db1cba29242a2638" + integrity sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw== + dependencies: + readable-stream "^2.0.5" + leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -7779,6 +8149,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -7789,7 +8164,12 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -long@^5.0.0: +lodash@^4.17.15: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== + +long@^5.0.0, long@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== @@ -7979,6 +8359,13 @@ minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" +minimatch@^5.1.0: + version "5.1.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.9.tgz#1293ef15db0098b394540e8f9f744f9fda8dee4b" + integrity sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw== + dependencies: + brace-expansion "^2.0.1" + minimatch@^9.0.4: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" @@ -8052,6 +8439,11 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nan@^2.19.0, nan@^2.23.0: + version "2.27.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.27.0.tgz#804e389f4c0e39b729a17eca85c80ebc4355c4c4" + integrity sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ== + nanoid@3.3.8: version "3.3.8" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" @@ -8774,6 +9166,16 @@ proc-log@^5.0.0: resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-5.0.0.tgz#e6c93cf37aef33f835c53485f314f50ea906a9d8" integrity sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ== +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + progress@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" @@ -8804,6 +9206,23 @@ prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +proper-lockfile@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" + integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA== + dependencies: + graceful-fs "^4.2.4" + retry "^0.12.0" + signal-exit "^3.0.2" + +properties-reader@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/properties-reader/-/properties-reader-3.0.1.tgz#576af69708759bb75672bfc162b80cc8a3d1bdb2" + integrity sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g== + dependencies: + "@kwsites/file-exists" "^1.1.1" + mkdirp "^3.0.1" + prosemirror-changeset@^2.3.0: version "2.4.0" resolved "https://registry.yarnpkg.com/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz#8d8ea0290cb9545c298ec427ac3a8f298c39170f" @@ -8958,6 +9377,24 @@ prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, pros prosemirror-state "^1.0.0" prosemirror-transform "^1.1.0" +protobufjs@^7.2.5, protobufjs@^7.3.2, protobufjs@^7.5.5: + version "7.6.1" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.6.1.tgz#6320bb08c3be7dcfc6f9193ee03d3a4643f1eb37" + integrity sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.5" + "@protobufjs/eventemitter" "^1.1.1" + "@protobufjs/fetch" "^1.1.1" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.2" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.1" + "@types/node" ">=13.7.0" + long "^5.3.2" + protobufjs@^7.3.0: version "7.5.4" resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" @@ -9128,7 +9565,20 @@ read-cmd-shim@^5.0.0: resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-5.0.0.tgz#6e5450492187a0749f6c80dcbef0debc1117acca" integrity sha512-SEbJV7tohp3DAAILbEMPXavBjAnMN0tVnh4+9G8ihV4Pq3HYF9h8QNez9zkJ1ILkv9G2BjdzwctznGZXgu/HGw== -readable-stream@^3.1.1, readable-stream@^3.4.0: +readable-stream@^2.0.5: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -9137,6 +9587,24 @@ readable-stream@^3.1.1, readable-stream@^3.4.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@^4.0.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.7.0.tgz#cedbd8a1146c13dfff8dab14068028d58c15ac91" + integrity sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + string_decoder "^1.3.0" + +readdir-glob@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/readdir-glob/-/readdir-glob-1.1.3.tgz#c3d831f51f5e7bfa62fa2ffbe4b508c640f09584" + integrity sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA== + dependencies: + minimatch "^5.1.0" + readdirp@^4.0.1: version "4.1.2" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" @@ -9303,6 +9771,11 @@ retry@0.13.1, retry@^0.13.1: resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== + reusify@^1.0.4: version "1.1.0" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" @@ -9392,6 +9865,11 @@ safe-buffer@^5.0.1, safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + safe-push-apply@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" @@ -9409,7 +9887,7 @@ safe-regex-test@^1.0.3, safe-regex-test@^1.1.0: es-errors "^1.3.0" is-regex "^1.2.1" -"safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -9568,7 +10046,7 @@ side-channel@^1.0.4, side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" -signal-exit@^3.0.3, signal-exit@^3.0.7: +signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== @@ -9684,11 +10162,35 @@ source-map@^0.6.0, source-map@^0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +split-ca@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/split-ca/-/split-ca-1.0.1.tgz#6c83aff3692fa61256e0cd197e05e9de157691a6" + integrity sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== +ssh-remote-port-forward@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz#72b0c5df8ec27ca300c75805cc6b266dee07e298" + integrity sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ== + dependencies: + "@types/ssh2" "^0.5.48" + ssh2 "^1.4.0" + +ssh2@^1.15.0, ssh2@^1.4.0: + version "1.17.0" + resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.17.0.tgz#dc686e8e3abdbd4ad95d46fa139615903c12258c" + integrity sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ== + dependencies: + asn1 "^0.2.6" + bcrypt-pbkdf "^1.0.2" + optionalDependencies: + cpu-features "~0.0.10" + nan "^2.23.0" + stable-hash@^0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/stable-hash/-/stable-hash-0.0.5.tgz#94e8837aaeac5b4d0f631d2972adef2924b40269" @@ -9726,6 +10228,15 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== +streamx@^2.12.5, streamx@^2.15.0, streamx@^2.25.0: + version "2.26.0" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.26.0.tgz#4d187aaefbed6d499388072a95c846259bc9d335" + integrity sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A== + dependencies: + events-universal "^1.0.0" + fast-fifo "^1.3.2" + text-decoder "^1.1.0" + string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" @@ -9829,13 +10340,20 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -string_decoder@^1.1.1: +string_decoder@^1.1.1, string_decoder@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== dependencies: safe-buffer "~5.2.0" +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -10059,7 +10577,7 @@ tapwrite@1.2.0: re-resizable "^6.10.0" tippy.js "^6.3.7" -tar-fs@^2.0.0: +tar-fs@^2.0.0, tar-fs@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930" integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== @@ -10069,6 +10587,17 @@ tar-fs@^2.0.0: pump "^3.0.0" tar-stream "^2.1.4" +tar-fs@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.1.2.tgz#114b012f54796f31e62f3e57792820a80b83ae6e" + integrity sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw== + dependencies: + pump "^3.0.0" + tar-stream "^3.1.5" + optionalDependencies: + bare-fs "^4.0.1" + bare-path "^3.0.0" + tar-stream@^2.1.4: version "2.2.0" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287" @@ -10080,6 +10609,16 @@ tar-stream@^2.1.4: inherits "^2.0.3" readable-stream "^3.1.1" +tar-stream@^3.0.0, tar-stream@^3.1.5: + version "3.2.0" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.2.0.tgz#0d0064d9b67ea3c9f5abde155e35faab0df37591" + integrity sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg== + dependencies: + b4a "^1.6.4" + bare-fs "^4.5.5" + fast-fifo "^1.2.0" + streamx "^2.15.0" + tar@7.4.3: version "7.4.3" resolved "https://registry.yarnpkg.com/tar/-/tar-7.4.3.tgz#88bbe9286a3fcd900e94592cda7a22b192e80571" @@ -10099,6 +10638,13 @@ tdigest@^0.1.1: dependencies: bintrees "1.0.2" +teex@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/teex/-/teex-1.0.1.tgz#b8fa7245ef8e8effa8078281946c85ab780a0b12" + integrity sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg== + dependencies: + streamx "^2.12.5" + test-exclude@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" @@ -10108,6 +10654,34 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +testcontainers@^12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/testcontainers/-/testcontainers-12.0.0.tgz#8df16157c0562ea5797b0cf5898583925d2258e4" + integrity sha512-/PdRvFvuHPwX126HR7RO0cEgLD3Nr8sWZyWSv54ei92TT79BubUkOCU5uwTc8ufTsTGQf0v6nyvZJVVVyR9Uqw== + dependencies: + "@balena/dockerignore" "^1.0.2" + "@types/dockerode" "^4.0.1" + archiver "^7.0.1" + async-lock "^1.4.1" + byline "^5.0.0" + debug "^4.4.3" + docker-compose "^1.4.2" + dockerode "^5.0.0" + get-port "^7.2.0" + proper-lockfile "^4.1.2" + properties-reader "^3.0.1" + ssh-remote-port-forward "^1.0.4" + tar-fs "^3.1.2" + tmp "^0.2.5" + undici "^7.24.7" + +text-decoder@^1.1.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/text-decoder/-/text-decoder-1.2.7.tgz#5d073a9a74b9c0a9d28dfadcab96b604af57d8ba" + integrity sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ== + dependencies: + b4a "^1.6.4" + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -10169,6 +10743,11 @@ tldts@^6.1.32: dependencies: tldts-core "^6.1.86" +tmp@^0.2.5: + version "0.2.6" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.6.tgz#0dfac10fd09a9319288eb0e8f0ed524604e183b4" + integrity sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA== + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" @@ -10281,6 +10860,11 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" +tweetnacl@^0.14.3: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -10398,6 +10982,11 @@ undefsafe@^2.0.5: resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + undici-types@~6.21.0: version "6.21.0" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" @@ -10420,6 +11009,11 @@ undici@^5.28.4: dependencies: "@fastify/busboy" "^2.0.0" +undici@^7.24.7: + version "7.26.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-7.26.0.tgz#d413a2b5752e3e71e003bb268dec32b9a0ad0ce7" + integrity sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2" @@ -10502,7 +11096,7 @@ utf-8-validate@6.0.3: dependencies: node-gyp-build "^4.3.0" -util-deprecate@^1.0.1, util-deprecate@^1.0.2: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== @@ -10785,6 +11379,11 @@ yaml@^1.10.0: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== +yaml@^2.2.2: + version "2.9.0" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4" + integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== + yaml@^2.3.4: version "2.8.1" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.1.tgz#1870aa02b631f7e8328b93f8bc574fac5d6c4d79" @@ -10795,7 +11394,7 @@ yargs-parser@^21.1.1: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== -yargs@^17.3.1: +yargs@^17.3.1, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== @@ -10818,6 +11417,15 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +zip-stream@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-6.0.1.tgz#e141b930ed60ccaf5d7fa9c8260e0d1748a2bbfb" + integrity sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA== + dependencies: + archiver-utils "^5.0.0" + compress-commons "^6.0.2" + readable-stream "^4.0.0" + zod-error@1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/zod-error/-/zod-error-1.5.0.tgz#bfdc20532746d564c88c51bd36267d6b7d9b9a5d" From 945aff98538fe832edb5f96a7be8500d5f218051 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 27 May 2026 21:55:21 +0545 Subject: [PATCH 24/27] ci(OUT-3731): read CI node from .nvmrc (20.19.1) testcontainers pulls in undici@7.x, which requires node >=20.18.1; the workflow's hardcoded 20.18.0 failed `yarn install` with an engine incompatibility. .nvmrc already pins 20.19.1, so point setup-node at it to match local dev and stop the drift. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/lint.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6fe5e0c2d..ccaafb255 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,9 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v3 with: - node-version: 20.18.0 + # Read from .nvmrc (20.19.1) so CI matches local dev. The previous hardcoded + # 20.18.0 was below testcontainers' undici requirement (node >=20.18.1). + node-version-file: '.nvmrc' cache: yarn cache-dependency-path: './yarn.lock' From e28ec2b9369a639517f7d59b5cf2babcd1d8c3ac Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Wed, 27 May 2026 23:58:04 +0545 Subject: [PATCH 25/27] feat(OUT-3038): email shared client users when an IU completes a task When an IU marks a shared task (isShared + associations) as done, email the client users it's shared with (viewers): a single client, or every client in a shared company. Mirrors the existing email-only "shared with you" pattern (disableInProduct), so viewers get an email and no in-product badge. - Add CompletedToSharedCU / CompletedToSharedCompany notification actions - getNotificationParties: sender is the completing IU; recipients resolved from associations - Email + (unused) in-product copy in notification.helpers - Case 5b in sendTaskUpdateNotifications routes shared-task completion to the new dispatchers Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/core/types/tasks.ts | 3 ++ .../api/notification/notification.helpers.ts | 24 +++++++++++ .../api/notification/notification.service.ts | 13 ++++++ .../api/tasks/task-notifications.service.ts | 42 +++++++++++++++++++ 4 files changed, 82 insertions(+) diff --git a/src/app/api/core/types/tasks.ts b/src/app/api/core/types/tasks.ts index 686ee1cd5..d0eb59f03 100644 --- a/src/app/api/core/types/tasks.ts +++ b/src/app/api/core/types/tasks.ts @@ -8,6 +8,9 @@ export enum NotificationTaskActions { CompletedForCompanyByIU = 'completedForCompanyByIu', Completed = 'completed', CompletedByIU = 'completedByIu', + // Completion notifications for client users a task is *shared* with (viewers), not assignees + CompletedToSharedCU = 'completedToSharedCU', + CompletedToSharedCompany = 'completedToSharedCompany', Commented = 'commented', // these two comment actions below are sub actions of Commented. // Its used to handle the cases for CU vs IU being notified of comments appropriately diff --git a/src/app/api/notification/notification.helpers.ts b/src/app/api/notification/notification.helpers.ts index 1c66f651d..fe9fd8eeb 100644 --- a/src/app/api/notification/notification.helpers.ts +++ b/src/app/api/notification/notification.helpers.ts @@ -82,6 +82,16 @@ export const getInProductNotificationDetails = ( body: `The task ‘${task?.title}’ was completed by ${actionUser}.`, ctaParams, }, + [NotificationTaskActions.CompletedToSharedCU]: { + title: 'A task has been completed', + body: `The task ‘${task?.title}’ has been marked as done by ${actionUser}.`, + ctaParams, + }, + [NotificationTaskActions.CompletedToSharedCompany]: { + title: 'A task has been completed', + body: `The task ‘${task?.title}’ has been marked as done by ${actionUser}.`, + ctaParams, + }, [NotificationTaskActions.Commented]: commentDetail, [NotificationTaskActions.CommentToCU]: commentDetail, @@ -192,6 +202,20 @@ export const getEmailDetails = ( body: `${actionUser} shared the task '${task?.title}'. View the task below to see updates and leave comments.`, ctaParams, }, + [NotificationTaskActions.CompletedToSharedCU]: { + subject: 'Task marked as done', + header: 'A task has been completed', + title: 'View task', + body: `The task ‘${task?.title}’ has been marked as done by ${actionUser}.\n\nTo see details about the task, open it below.`, + ctaParams, + }, + [NotificationTaskActions.CompletedToSharedCompany]: { + subject: 'Task marked as done', + header: 'A task has been completed', + title: 'View task', + body: `The task ‘${task?.title}’ has been marked as done by ${actionUser}.\n\nTo see details about the task, open it below.`, + ctaParams, + }, [NotificationTaskActions.SharedToCompany]: { subject: `A task has been shared with you`, header: `A task was shared with you by ${actionUser}`, diff --git a/src/app/api/notification/notification.service.ts b/src/app/api/notification/notification.service.ts index f9ee3bf89..8bebbe651 100644 --- a/src/app/api/notification/notification.service.ts +++ b/src/app/api/notification/notification.service.ts @@ -435,6 +435,19 @@ export class NotificationService extends BaseService { recipientId = task.createdById actionTrigger = await this.copilot.getInternalUser(senderId) break + case NotificationTaskActions.CompletedToSharedCU: + // Shared task is IU-assigned and only an IU can complete it, so the sender is the completing IU + senderId = z.string().parse(this.user.internalUserId) + recipientId = !!associations?.length ? z.string().parse(associations[0].clientId) : '' + actionTrigger = await this.copilot.getInternalUser(senderId) + break + case NotificationTaskActions.CompletedToSharedCompany: + senderId = z.string().parse(this.user.internalUserId) + recipientIds = !!associations?.length + ? (await this.copilot.getCompanyClients(z.string().parse(associations[0].companyId))).map((client) => client.id) + : [] + actionTrigger = await this.copilot.getInternalUser(senderId) + break case NotificationTaskActions.CommentToCU: if (task.assigneeType === AssigneeType.client && task.assigneeId) { // the client is the assignee, they are part of the task diff --git a/src/app/api/tasks/task-notifications.service.ts b/src/app/api/tasks/task-notifications.service.ts index 396077a3a..7a21ed51e 100644 --- a/src/app/api/tasks/task-notifications.service.ts +++ b/src/app/api/tasks/task-notifications.service.ts @@ -203,6 +203,23 @@ export class TaskNotificationsService extends BaseService { await this.handleTaskCompletionNotifications(prevTask, updatedTask) } + // Case 5b (OUT-3038) + // -- Shared tasks are IU-assigned and only an IU can complete them. When that happens, email the + // client users the task is shared with (viewers): a single client, or every client in a company. + if ( + prevTask?.workflowState?.type !== StateType.completed && + updatedTask?.workflowState?.type === StateType.completed && + updatedTask.isShared + ) { + const completedAssociations = getTaskAssociations(updatedTask) + if (completedAssociations) { + const sendCompletedSharedNotification = completedAssociations.clientId + ? this.sendUserTaskCompletedSharedNotification + : this.sendCompanyTaskCompletedSharedNotification + await sendCompletedSharedNotification(updatedTask) + } + } + // Case 6 // -- Handle task moved from completed to incomplete IU logic const isSelfAssignedIU = @@ -394,6 +411,31 @@ export class TaskNotificationsService extends BaseService { }) } + // Email-only notification to a single client user a task is shared with, when an IU completes it. + private sendUserTaskCompletedSharedNotification = async (task: Task) => { + const notification = await this.notificationService.create(NotificationTaskActions.CompletedToSharedCU, task, { + disableInProduct: true, + disableEmail: false, + }) + if (!notification) { + console.error('Completed-shared notification failed to trigger for task:', task) + } + } + + // Email-only notification to every client user in a company a task is shared with, when an IU completes it. + private sendCompanyTaskCompletedSharedNotification = async (task: Task) => { + const { recipientIds } = await this.notificationService.getNotificationParties( + task, + NotificationTaskActions.CompletedToSharedCompany, + ) + await this.notificationService.createBulkNotification( + NotificationTaskActions.CompletedToSharedCompany, + task, + recipientIds, + { email: true, disableInProduct: true }, + ) + } + private sendUserTaskNotification = async (task: Task, isReassigned = false) => { if (!task.assigneeType) return From d27fd42821a1a80ceefe713eee80d7f87810c437 Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Thu, 28 May 2026 13:26:40 +0545 Subject: [PATCH 26/27] fix(OUT-3038): gate Case 5b on updatedTask.assigneeId Mirrors Case 5's assigneeId guard so the IU-only invariant for shared tasks is explicit. Without it, an edge-case shared task with no assignee would reach getNotificationParties and throw a ZodError on senderId parsing that the create() try/catch silently swallows. Per Greptile review feedback on PR #1265. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/tasks/task-notifications.service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/api/tasks/task-notifications.service.ts b/src/app/api/tasks/task-notifications.service.ts index 7a21ed51e..7facd9c98 100644 --- a/src/app/api/tasks/task-notifications.service.ts +++ b/src/app/api/tasks/task-notifications.service.ts @@ -206,10 +206,13 @@ export class TaskNotificationsService extends BaseService { // Case 5b (OUT-3038) // -- Shared tasks are IU-assigned and only an IU can complete them. When that happens, email the // client users the task is shared with (viewers): a single client, or every client in a company. + // -- assigneeId guard mirrors Case 5 and makes the IU-only invariant explicit; without it, an + // edge-case shared task with no assignee would throw a swallowed ZodError on senderId parsing. if ( prevTask?.workflowState?.type !== StateType.completed && updatedTask?.workflowState?.type === StateType.completed && - updatedTask.isShared + updatedTask.isShared && + updatedTask.assigneeId ) { const completedAssociations = getTaskAssociations(updatedTask) if (completedAssociations) { From 3f3633e2d1a9236100228a01b68e0eb5e927f0ae Mon Sep 17 00:00:00 2001 From: arpandhakal-lgtm Date: Thu, 28 May 2026 20:08:12 +0545 Subject: [PATCH 27/27] refactor(OUT-3038): inline shared-completion notification dispatch Drop the intermediate function reference; branch the ternary directly to the awaited call. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/tasks/task-notifications.service.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/api/tasks/task-notifications.service.ts b/src/app/api/tasks/task-notifications.service.ts index 7facd9c98..9e98dbc4c 100644 --- a/src/app/api/tasks/task-notifications.service.ts +++ b/src/app/api/tasks/task-notifications.service.ts @@ -216,10 +216,9 @@ export class TaskNotificationsService extends BaseService { ) { const completedAssociations = getTaskAssociations(updatedTask) if (completedAssociations) { - const sendCompletedSharedNotification = completedAssociations.clientId - ? this.sendUserTaskCompletedSharedNotification - : this.sendCompanyTaskCompletedSharedNotification - await sendCompletedSharedNotification(updatedTask) + completedAssociations.clientId + ? await this.sendUserTaskCompletedSharedNotification(updatedTask) + : await this.sendCompanyTaskCompletedSharedNotification(updatedTask) } }