Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
d3b94ad
feat(OUT-3734): add TaskReminderSents table and TaskReminderType enum
arpandhakal May 15, 2026
ff14b29
feat(OUT-3735): add getReminderEmailDetails copy helper
arpandhakal May 15, 2026
a371892
feat(OUT-3736): add eligibility SQL for single-day reminder query
arpandhakal May 18, 2026
5d68cc1
fix(OUT-3736): apply parent lifecycle filter to subtask carve-out join
arpandhakal May 18, 2026
73c6caa
Merge pull request #1229 from assemblycom/arpandhakal/out-3734-schema…
arpandhakal May 25, 2026
d89aaa7
Merge branch 'feature/email-reminders' into OUT-3735-reminder-copy-he…
arpandhakal May 25, 2026
7fe64ff
Merge pull request #1230 from assemblycom/OUT-3735-reminder-copy-helper
arpandhakal May 25, 2026
e092c47
Merge branch 'feature/email-reminders' into OUT-3736
arpandhakal May 25, 2026
9fa6f0e
Merge pull request #1236 from assemblycom/OUT-3736
arpandhakal May 25, 2026
d475762
fix(OUT-3736): wrap dueDate regex+cast in CASE WHEN to enforce eval o…
arpandhakal May 25, 2026
956dd66
feat(OUT-3737): add sendReminderEmail helper for email-only reminders
arpandhakal May 25, 2026
b37755b
feat(OUT-3730): add send-task-reminders scheduled task
arpandhakal May 25, 2026
67ac802
refactor(OUT-3730): init per-workspace CopilotAPI via workspace-scope…
arpandhakal May 25, 2026
abef42e
refactor(OUT-3730): swap raw INSERT for prisma createManyAndReturn
arpandhakal May 25, 2026
2e4ced4
chore(OUT-3730): trim comments in reminder cron + helper
arpandhakal May 25, 2026
b34ab8c
refactor(OUT-3730): fold title + createdById into EligibilityRow
arpandhakal May 25, 2026
84f82d2
chore(OUT-3736): trim comments in eligibility.ts
arpandhakal May 25, 2026
03667ec
Merge pull request #1257 from assemblycom/OUT-3737
arpandhakal May 25, 2026
ace17f1
Merge branch 'feature/email-reminders' into OUT-3730
arpandhakal May 25, 2026
c1cf5f7
fix(OUT-3735): drop `<brand> portal:` prefix from reminder subjects
arpandhakal May 26, 2026
29d5a00
fix(OUT-3735): use <br><br> for paragraph breaks in reminder bodies
arpandhakal May 26, 2026
a93fdaf
revert(OUT-3735): restore \n\n separator in reminder bodies
arpandhakal May 26, 2026
cbab0eb
perf(OUT-3730): offload reminder sends to dispatchReminderEmail task
arpandhakal May 26, 2026
7c99164
fix(OUT-3730): chunk batchTrigger at 500 and compensate ledger on fai…
arpandhakal May 26, 2026
d340458
refactor(OUT-3730): rename row → task per PR review (priosshrsth)
arpandhakal May 26, 2026
b0b1337
fix(OUT-3730): contain getCompanyClients failure to the failing task
arpandhakal May 26, 2026
4961236
refactor(OUT-3730): address PR review feedback (priosshrsth)
arpandhakal May 26, 2026
0f1adb0
refactor(OUT-3730): extract dispatchChunk helper
arpandhakal May 26, 2026
5b7ee1d
Merge branch 'main' of https://github.com/assemblycom/tasks-app into …
arpandhakal May 26, 2026
1ddb459
Merge branch 'feature/email-reminders' of https://github.com/assembly…
arpandhakal May 26, 2026
0b03697
Merge pull request #1258 from assemblycom/OUT-3730
arpandhakal May 27, 2026
b5ccb17
Merge branch 'main' of https://github.com/assemblycom/tasks-app into …
arpandhakal May 27, 2026
eb67c03
feat(OUT-3738): structured logging + Sentry for reminder cron
arpandhakal May 27, 2026
c227eaa
Merge pull request #1261 from assemblycom/OUT-3738-observability
arpandhakal May 27, 2026
815fa64
test(OUT-3731): reminder eligibility + idempotency tests (real DB)
arpandhakal May 27, 2026
945aff9
ci(OUT-3731): read CI node from .nvmrc (20.19.1)
arpandhakal May 27, 2026
e28ec2b
feat(OUT-3038): email shared client users when an IU completes a task
arpandhakal May 27, 2026
d27fd42
fix(OUT-3038): gate Case 5b on updatedTask.assigneeId
arpandhakal May 28, 2026
6a3ae75
Merge branch 'feature/email-reminders' into OUT-3731-reminder-tests
arpandhakal May 28, 2026
3f3633e
refactor(OUT-3038): inline shared-completion notification dispatch
arpandhakal May 28, 2026
8ceaed7
Merge branch 'feature/email-reminders' into OUT-3038-cu-completed-ema…
arpandhakal May 28, 2026
2e03e9e
Merge pull request #1265 from assemblycom/OUT-3038-cu-completed-email…
arpandhakal May 28, 2026
558ce56
Merge branch 'feature/email-reminders' into OUT-3731-reminder-tests
arpandhakal May 28, 2026
0525256
Merge pull request #1266 from assemblycom/OUT-3731-reminder-tests
arpandhakal May 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
6 changes: 2 additions & 4 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
24 changes: 24 additions & 0 deletions jest.integration.config.ts
Original file line number Diff line number Diff line change
@@ -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: '<rootDir>/test/integration/globalSetup.ts',
globalTeardown: '<rootDir>/test/integration/globalTeardown.ts',
setupFilesAfterEnv: ['<rootDir>/test/integration/setup-env.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^@api/(.*)$': '<rootDir>/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)
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions prisma/schema/task.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ model Task {
deletedBy String? @db.Uuid

taskUpdateBacklogs TaskUpdateBacklog[]
taskReminderSents TaskReminderSent[]

associations Json @db.JsonB @default("[]")
isShared Boolean @default(false)
Expand Down
21 changes: 21 additions & 0 deletions prisma/schema/taskReminderSent.prisma
Original file line number Diff line number Diff line change
@@ -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")
}
3 changes: 3 additions & 0 deletions src/app/api/core/types/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
},
}
`;
55 changes: 55 additions & 0 deletions src/app/api/notification/notification.helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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 `<brand> portal:` prefix from subjects (Copilot prepends it server-side)', () => {
const result = getReminderEmailDetails(workspace, task, false)
for (const variant of Object.values(TaskReminderType)) {
expect(result[variant].subject).not.toMatch(/portal:/i)
}
})

it('emits ctaParams with the task id for every variant', () => {
const result = getReminderEmailDetails(workspace, task, false)
for (const variant of Object.values(TaskReminderType)) {
expect(result[variant].ctaParams).toEqual({ taskId: 'task_1' })
}
})
})
Loading
Loading