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' 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/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") +} 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/__snapshots__/notification.helpers.test.ts.snap b/src/app/api/notification/__snapshots__/notification.helpers.test.ts.snap new file mode 100644 index 000000000..81f1621a7 --- /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": "[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": "[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": "[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": "[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": "[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": "[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": "[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": "[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": "[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": "[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": "[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": "[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..5874c181e --- /dev/null +++ b/src/app/api/notification/notification.helpers.test.ts @@ -0,0 +1,55 @@ +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('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', () => { + 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..ac21239d4 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 @@ -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}`, @@ -201,3 +225,70 @@ export const getEmailDetails = ( }, } } + +// 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, + isCompanyRecipient: boolean, +): Record< + TaskReminderType, + { + title: string + subject: string + header: string + body: string + ctaParams: { taskId: string } + } +> => { + 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: '[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: '[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: '[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: '[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: '[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: '[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, + }, + } +} 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..9e98dbc4c 100644 --- a/src/app/api/tasks/task-notifications.service.ts +++ b/src/app/api/tasks/task-notifications.service.ts @@ -203,6 +203,25 @@ 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. + // -- 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.assigneeId + ) { + const completedAssociations = getTaskAssociations(updatedTask) + if (completedAssociations) { + completedAssociations.clientId + ? await this.sendUserTaskCompletedSharedNotification(updatedTask) + : await this.sendCompanyTaskCompletedSharedNotification(updatedTask) + } + } + // Case 6 // -- Handle task moved from completed to incomplete IU logic const isSelfAssignedIU = @@ -394,6 +413,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 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..94fb44da5 --- /dev/null +++ b/src/jobs/notifications/dispatch-reminder-email.test.ts @@ -0,0 +1,137 @@ +import { TaskReminderType } from '@prisma/client' + +const mockSendReminderEmail = jest.fn() +const mockExecuteRaw = jest.fn() +const mockCopilotApiCtor = jest.fn() +const mockCaptureException = 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('@/jobs/sentry', () => ({ + Sentry: { captureException: (...args: unknown[]) => mockCaptureException(...args) }, +})) + +jest.mock('@/lib/db', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + // Compensation hard-deletes via $executeRaw to bypass the softDelete extension. + $executeRaw: mockExecuteRaw, + }), + }, +})) + +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() + mockExecuteRaw.mockReset() + mockCopilotApiCtor.mockReset() + mockCaptureException.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(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('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(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 () => { + mockExecuteRaw.mockResolvedValueOnce(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 () => { + mockExecuteRaw.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..b9fafbf67 --- /dev/null +++ b/src/jobs/notifications/dispatch-reminder-email.ts @@ -0,0 +1,88 @@ +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' +import { serializeError } from '@/utils/serializeError' +import { Task, TaskReminderType } from '@prisma/client' +import { logger, task, tasks } from '@trigger.dev/sdk/v3' + +import { sendReminderEmail } from './send-reminder-email' + +export type DispatchReminderEmailPayload = { + ledgerId: string + workspaceId: string + task: Pick + recipientClientId: string + recipientCompanyId: string | null + reminderType: TaskReminderType + isCompanyRecipient: boolean + workspace: WorkspaceResponse +} + +const TASK_ID = 'dispatch-reminder-email' + +export const dispatchReminderEmailRun = async (payload: DispatchReminderEmailPayload) => { + const copilot = new CopilotAPI('', `${payload.workspaceId}/${copilotAPIKey}`) + const notificationId = await sendReminderEmail({ + task: payload.task, + recipientClientId: payload.recipientClientId, + recipientCompanyId: payload.recipientCompanyId, + reminderType: payload.reminderType, + isCompanyRecipient: payload.isCompanyRecipient, + workspace: payload.workspace, + copilot, + }) + return { ledgerId: payload.ledgerId, notificationId, sent: true as const } +} + +// Fires after Trigger.dev exhausts all retries. Compensating here (instead of inside run's +// catch) avoids dropping the ledger row on transient failures a retry would have recovered. +// The SDK types the hook's payload as `unknown`; we cast once via destructure. +export const dispatchReminderEmailOnFailure = async ({ payload, error }: { payload: unknown; error: unknown }) => { + const { ledgerId, workspaceId, task, recipientClientId, reminderType } = payload as DispatchReminderEmailPayload + // 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, + taskId: task.id, + recipientClientId, + reminderType, + error: serializeError(error), + }) + const db = DBClient.getInstance() + try { + // 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, + 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/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/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..028dde4fe --- /dev/null +++ b/src/jobs/notifications/eligibility.ts @@ -0,0 +1,65 @@ +import DBClient from '@/lib/db' +import { AssigneeType, TaskReminderType } from '@prisma/client' + +export type EligibilityRow = { + taskId: string + workspaceId: string + title: string + createdById: string + assigneeId: string + assigneeType: AssigneeType + companyId: string | null + reminderType: TaskReminderType +} + +// 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 + t.id::text AS "taskId", + t."workspaceId", + t."title", + t."createdById"::text AS "createdById", + 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 + -- Join only alive parents so dead parents act as if absent (NULL assigneeId), + -- letting same-assignee subtasks under them emit their own reminder. + LEFT JOIN "Tasks" parent + ON parent.id = t."parentId" + AND parent."deletedAt" IS NULL + AND parent."isArchived" = false + AND parent."completedAt" IS NULL + 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's reminder. + -- IS DISTINCT FROM treats a NULL parent as "different" so standalone subtasks still match. + AND (t."parentId" IS NULL OR parent."assigneeId" IS DISTINCT FROM t."assigneeId") + -- Regex + ::date cast must share a CASE WHEN: Postgres doesn't guarantee AND + -- predicate order, so a separate regex guard could be reordered after the cast. + AND ( + (t."dueDate" IS NULL AND t."assignedAt"::date IN (CURRENT_DATE - 3, CURRENT_DATE - 7)) + OR (CASE WHEN t."dueDate" ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' + THEN t."dueDate"::date IN (CURRENT_DATE - 7, CURRENT_DATE - 3, CURRENT_DATE, CURRENT_DATE + 3) + ELSE FALSE END) + ) + ` +} diff --git a/src/jobs/notifications/index.ts b/src/jobs/notifications/index.ts index 1bbf77b3d..8046314bc 100644 --- a/src/jobs/notifications/index.ts +++ b/src/jobs/notifications/index.ts @@ -2,3 +2,5 @@ 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' +export { dispatchReminderEmail } from './dispatch-reminder-email' 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-reminder-email.test.ts b/src/jobs/notifications/send-reminder-email.test.ts new file mode 100644 index 000000000..1728420f1 --- /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: '[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('[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..7b247d4d5 --- /dev/null +++ b/src/jobs/notifications/send-reminder-email.ts @@ -0,0 +1,48 @@ +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 +} + +// 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, + 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 +} 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..3467eafe1 --- /dev/null +++ b/src/jobs/notifications/send-task-reminders.test.ts @@ -0,0 +1,314 @@ +import { AssigneeType, TaskReminderType } from '@prisma/client' + +const mockTaskReminderSentCreateManyAndReturn = jest.fn() +const mockExecuteRaw = jest.fn() +const mockGetEligibleReminders = jest.fn() +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: { + task: ({ run }: { run: (payload: unknown, ctx?: unknown) => unknown }) => ({ run }), + }, + logger: { log: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})) + +jest.mock('@/config', () => ({ copilotAPIKey: 'test-api-key' })) + +jest.mock('@/jobs/sentry', () => ({ + Sentry: { captureException: (...args: unknown[]) => mockCaptureException(...args) }, +})) + +jest.mock('@/lib/db', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + taskReminderSent: { + createManyAndReturn: mockTaskReminderSentCreateManyAndReturn, + }, + // Compensation hard-deletes via $executeRaw to bypass the softDelete extension. + $executeRaw: mockExecuteRaw, + }), + }, +})) + +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('./dispatch-reminder-email', () => ({ + dispatchReminderEmail: { batchTrigger: (...args: unknown[]) => mockBatchTrigger(...args) }, +})) + +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 = { enqueued: 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', + title: 'Submit timesheet', + createdById: 'iu_1', + assigneeId: 'client_1', + assigneeType: AssigneeType.client, + companyId: 'company_1', + reminderType: TaskReminderType.NO_DUE_DATE_3D, + ...overrides, +}) + +describe('sendTaskReminders', () => { + beforeEach(() => { + jest.clearAllMocks() + mockTaskReminderSentCreateManyAndReturn.mockReset() + mockExecuteRaw.mockReset() + mockGetEligibleReminders.mockReset() + mockBatchTrigger.mockReset() + mockGetWorkspace.mockReset() + mockGetCompanyClients.mockReset() + mockCopilotApiCtor.mockReset() + mockCaptureException.mockReset() + mockGetWorkspace.mockResolvedValue(workspace) + mockBatchTrigger.mockResolvedValue({ batchId: 'b1' }) + }) + + it('exits cleanly when no rows are eligible', async () => { + mockGetEligibleReminders.mockResolvedValueOnce([]) + + const result = await runJob() + + expect(result).toEqual({ enqueued: 0, skipped: 0, workspaceCount: 0 }) + expect(mockTaskReminderSentCreateManyAndReturn).not.toHaveBeenCalled() + expect(mockBatchTrigger).not.toHaveBeenCalled() + expect(mockCopilotApiCtor).not.toHaveBeenCalled() + }) + + it('filters out internalUser rows before any Copilot work', async () => { + mockGetEligibleReminders.mockResolvedValueOnce([ + buildRow({ assigneeType: AssigneeType.internalUser, assigneeId: 'iu_1', companyId: null }), + ]) + + const result = await runJob() + + expect(result.workspaceCount).toBe(0) + expect(mockBatchTrigger).not.toHaveBeenCalled() + }) + + 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 }, + ]) + + const result = await runJob() + + 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', + reminderType: TaskReminderType.NO_DUE_DATE_3D, + isCompanyRecipient: false, + workspace, + }) + }) + + 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 }, + ]) + + 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()]) + mockTaskReminderSentCreateManyAndReturn.mockResolvedValueOnce([]) + + const result = await runJob() + + 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 () => { + mockGetEligibleReminders.mockResolvedValueOnce([ + buildRow({ assigneeType: AssigneeType.company, assigneeId: 'company_1', companyId: 'company_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 }, + { 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 }, + ]) + + const result = await runJob() + + 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', + ]) + 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(mockExecuteRaw).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(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 () => { + // 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' }), + buildRow({ workspaceId: 'ws_good', taskId: 'task_good', assigneeId: 'client_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.enqueued).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..3ed83d967 --- /dev/null +++ b/src/jobs/notifications/send-task-reminders.ts @@ -0,0 +1,223 @@ +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' +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' + +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 } + +type Recipient = { clientId: string; companyId: string | null } + +type LedgerPlanEntry = { + task: EligibilityRow + recipient: Recipient +} + +export const sendTaskReminders = schedules.task({ + id: 'send-task-reminders', + cron: '0 0 * * *', + maxDuration: 3000, + run: async (payload) => { + const db = DBClient.getInstance() + + 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() + 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: 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 = tasksByWorkspace.size + + const workspaceBottleneck = new Bottleneck({ maxConcurrent: WORKSPACE_CONCURRENCY }) + + await Promise.allSettled( + Array.from(tasksByWorkspace.entries()).map(([workspaceId, workspaceTasks]) => + workspaceBottleneck.schedule(async () => { + let wsTotals: WorkspaceTotals = { enqueued: 0, skipped: 0 } + try { + wsTotals = await processWorkspace(db, workspaceId, workspaceTasks) + } catch (err) { + logger.error('send-task-reminders: workspace failed', { + workspaceId, + error: serializeError(err), + }) + } finally { + totals.enqueued += wsTotals.enqueued + totals.skipped += wsTotals.skipped + processed += 1 + logger.log( + `[${processed}/${workspaceCount}] workspace ${workspaceId}: enqueued ${wsTotals.enqueued}, skipped ${wsTotals.skipped}`, + { workspaceId, ...wsTotals, processed, eligibleWorkspaces: workspaceCount }, + ) + } + }), + ), + ) + + // 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 } + }, +}) + +const processWorkspace = async ( + db: ReturnType, + workspaceId: string, + 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. + const copilot = new CopilotAPI('', `${workspaceId}/${copilotAPIKey}`) + const workspace = await copilot.getWorkspace() + + const plan: LedgerPlanEntry[] = [] + for (const task of tasks) { + 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 }) + } + } + + 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({ + data: plan.map((entry) => ({ + taskId: entry.task.taskId, + workspaceId, + recipientId: entry.recipient.clientId, + 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.task.taskId, e.recipient.clientId, e.task.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.task.taskId, title: entry.task.title, createdById: entry.task.createdById }, + recipientClientId: entry.recipient.clientId, + recipientCompanyId: entry.recipient.companyId, + reminderType: entry.task.reminderType, + isCompanyRecipient: entry.task.assigneeType === AssigneeType.company, + workspace, + }, + }) + } + + // 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) + return chunk.length + } catch (err) { + const ledgerIds = chunk.map((t) => t.payload.ledgerId) + logger.error('send-task-reminders: batchTrigger failed, compensating ledger', { + workspaceId, + chunkSize: chunk.length, + error: serializeError(err), + }) + try { + // 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 delete failed, ledger rows orphaned', { + workspaceId, + ledgerIds, + 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 } +} + +// IU rows are filtered upstream so only client/company assignees reach here. +const resolveRecipients = async (copilot: CopilotAPI, task: EligibilityRow): Promise => { + if (task.assigneeType !== AssigneeType.company) { + return [{ clientId: task.assigneeId, companyId: task.companyId }] + } + const members = await copilot.getCompanyClients(task.assigneeId) + return members.map((m) => ({ clientId: m.id, companyId: task.assigneeId })) +} 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 } 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) 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"