feature/email-reminders #1256
Conversation
Adds a minimal ledger to enforce reminder idempotency at the DB level. Unique constraint on (taskId, recipientId, reminderType) is the dedupe primitive so retries and manual re-triggers cannot double-send. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Returns subject/header/title/body/ctaParams for each of the six TaskReminderType variants. Header branches on whether the recipient is a company (uses workspace groupTerm) or an individual. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds getEligibleReminders() that returns one row per (task, assignee, reminderType) eligible for a task reminder today, across the six exact-day windows defined in the Reminder Emails PRD. The EligibilityRow carries the companyId derived per assigneeType so the future sender can stamp ClientNotifications.companyId and Copilot's recipientCompanyId without a follow-up task lookup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without this, a same-assignee subtask under a soft-deleted (or archived / completed) parent is silently dropped: the parent itself is filtered out by the main WHERE, but the LEFT JOIN still returns its assigneeId, which fails IS DISTINCT FROM and drops the subtask. Filtering parent lifecycle in the JOIN makes parent.assigneeId come back NULL for dead parents, so subtasks correctly emit their own reminder. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-add-taskremindersent-table-taskremindertype-enum OUT-3734 | Schema: add TaskReminderSent table + TaskReminderType enum
OUT-3735 | Reminder copy helper: getReminderEmailDetails
OUT-3736 | Eligibility SQL: single-day reminder query
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR introduces a task email reminder system: a new
Confidence Score: 3/5The eligibility query's dueDate cast guard is structurally incorrect — a single malformed row in the Tasks table could cause the entire daily cron run to throw a PostgreSQL cast error, silently skipping all reminders for that day. The developer explicitly documented the risk of malformed VARCHAR dueDate values poisoning the query, but the chosen mitigation (a separate AND condition) does not guarantee evaluation order in PostgreSQL. If any task row contains a dueDate that passes the regex format check but fails the ::date cast (e.g. '2023-13-99'), or if the planner evaluates the cast condition before the regex, the entire getEligibleReminders call fails and no reminders go out that day. This is the critical path of the new feature and would be completely silent in normal monitoring. src/jobs/notifications/eligibility.ts — the WHERE clause combining the regex guard and the ::date cast needs the most attention before merge. Important Files Changed
Sequence DiagramsequenceDiagram
participant Cron as Cron Job
participant Eligibility as getEligibleReminders
participant DB as PostgreSQL (Tasks / TaskReminderSents)
participant Helper as getReminderEmailDetails
participant Email as Email Service
Cron->>Eligibility: getEligibleReminders(db)
Eligibility->>DB: $queryRaw — SELECT eligible (task, assignee, reminderType) rows
DB-->>Eligibility: EligibilityRow[]
Eligibility-->>Cron: EligibilityRow[]
loop For each EligibilityRow
Cron->>Helper: getReminderEmailDetails(workspace, task, isCompanyRecipient)
Helper-->>Cron: email content keyed by TaskReminderType
Cron->>Email: Send reminder email (subject, header, body, ctaParams)
Cron->>DB: INSERT INTO TaskReminderSents (taskId, recipientId, reminderType) ON CONFLICT DO NOTHING
end
Reviews (1): Last reviewed commit: "Merge pull request #1236 from assemblyco..." | Re-trigger Greptile |
| -- Guard against malformed VARCHAR(10) dueDate values: only cast when the string | ||
| -- looks like ISO YYYY-MM-DD. Without this, a single bad row poisons the whole query. | ||
| AND (t."dueDate" IS NULL OR t."dueDate" ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$') | ||
| AND ( | ||
| (t."dueDate" IS NULL AND t."assignedAt"::date IN (CURRENT_DATE - 3, CURRENT_DATE - 7)) | ||
| OR t."dueDate"::date IN (CURRENT_DATE - 7, CURRENT_DATE - 3, CURRENT_DATE, CURRENT_DATE + 3) | ||
| ) |
There was a problem hiding this comment.
The regex guard and the
::date cast are in two separate AND conditions. PostgreSQL's query planner is free to reorder AND predicates, so it can attempt t."dueDate"::date on a malformed value before the regex check has filtered it out, throwing a cast error that poisons the entire query for that day. The developer's comment confirms this risk is known; the fix is to use a CASE WHEN expression, which PostgreSQL guarantees evaluates sequentially and short-circuits.
| -- Guard against malformed VARCHAR(10) dueDate values: only cast when the string | |
| -- looks like ISO YYYY-MM-DD. Without this, a single bad row poisons the whole query. | |
| AND (t."dueDate" IS NULL OR t."dueDate" ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$') | |
| AND ( | |
| (t."dueDate" IS NULL AND t."assignedAt"::date IN (CURRENT_DATE - 3, CURRENT_DATE - 7)) | |
| OR t."dueDate"::date IN (CURRENT_DATE - 7, CURRENT_DATE - 3, CURRENT_DATE, CURRENT_DATE + 3) | |
| ) | |
| -- Guard against malformed VARCHAR(10) dueDate values: use CASE WHEN to guarantee | |
| -- the regex is evaluated before the ::date cast (AND predicate order is not guaranteed). | |
| 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) | |
| ) |
| body: `This is a friendly reminder that you have a task ‘${task.title}’ assigned to you that's still pending completion.\n\nIf you've already completed this task, please mark it as done in the portal.`, | ||
| ctaParams, | ||
| }, | ||
| [TaskReminderType.NO_DUE_DATE_7D]: { | ||
| subject: `${portalPrefix} [Reminder] Task still pending`, | ||
| header, | ||
| title, | ||
| body: `This is a friendly reminder that you have a task ‘${task.title}’ that was assigned to you a week ago and is still pending.\n\nIf you've already completed this task, please mark it as done in the portal.`, | ||
| ctaParams, |
There was a problem hiding this comment.
NO_DUE_DATE bodies say "assigned to you" for company recipients
The NO_DUE_DATE_3D and NO_DUE_DATE_7D bodies hard-code the phrase "assigned to you" regardless of isCompanyRecipient. When a company is the assignee and isCompanyRecipient=true, the header correctly reads "A task was assigned to your company" but the body still says "assigned to you", sending an inconsistent message to recipients of company tasks. Adding a derived phrase — e.g. const assignedTo = isCompanyRecipient ? 'your ' + labels.groupTerm : 'you' — and using it in both NO_DUE_DATE bodies would align them with the already-correct header logic.
…rder Postgres does not guarantee AND-predicate evaluation order, so the separate regex guard could be reordered after the ::date cast and the cron would crash on any malformed dueDate. CASE WHEN evaluates sequentially and short-circuits, which is the documented-safe pattern for this. Behavior is unchanged for valid rows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Deployment failed with the following error: Learn More: https://vercel.link/multiple-function-regions |
Plumbs the reminder email payload through copilot.createNotification with deliveryTargets.email only. Deliberately does not write to ClientNotification (that table tracks in-product read-state, which reminders don't create) and does not write to TaskReminderSent (the caller owns the ledger insert as the dedupe primitive on success). Throws on Copilot failure so callers can skip the ledger and let the next cron run retry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OUT-3737 | Send flow: email-only delivery via NotificationService
Changes