Skip to content

OUT-3737 | Send flow: email-only delivery via NotificationService#1257

Open
arpandhakal wants to merge 1 commit into
feature/email-remindersfrom
OUT-3737
Open

OUT-3737 | Send flow: email-only delivery via NotificationService#1257
arpandhakal wants to merge 1 commit into
feature/email-remindersfrom
OUT-3737

Conversation

@arpandhakal
Copy link
Copy Markdown
Collaborator

Changes

  • Add src/jobs/notifications/send-reminder-email.tssendReminderEmail() helper that dispatches a single reminder email via copilot.createNotification with deliveryTargets.email only.
  • Add src/jobs/notifications/send-reminder-email.test.ts — covers payload shape, return value, company-recipient header branch, null recipientCompanyId, and error propagation.

Implements OUT-3737.

Why not reuse NotificationService.create

That method assumes a NotificationTaskActions action and writes to ClientNotification — which tracks read-state for in-product notifications that reminders don't create. Reminder dedupe state lives in TaskReminderSent (OUT-3734), inserted by the caller (OUT-3730 cron) on success as the idempotency primitive.

What the helper deliberately does NOT do

  • No write to ClientNotification / InternalUserNotification.
  • No write to TaskReminderSent (caller's responsibility on success).
  • No internal Copilot client init — caller passes in a per-workspace CopilotAPI so the cron can amortize one client across many tasks per workspace, per the ticket's "do not rely on request-scoped this.copilot".

Testing Criteria

  • yarn test src/jobs/notifications/send-reminder-email.test.ts — 5/5 passing.
  • yarn lint:check — 0 errors on new files.
  • yarn prettier:check — clean.
  • End-to-end verification deferred to OUT-3730 (cron that calls this) + OUT-3732 (staging dry-run).

Notes

  • Base branch is feature/email-reminders, not main. Bundles into PR feature/email-reminders  #1256.
  • Depends on OUT-3734 (TaskReminderType enum), OUT-3735 (getReminderEmailDetails) — both already merged into feature/email-reminders.
  • Unblocks OUT-3730 (Trigger.dev scheduled task) which will import sendReminderEmail and own the per-workspace fan-out, company member resolution via getCompanyClients, and ledger inserts.
  • senderId = task.createdById per the ticket. Flag in review if reminders should use a different sender identity (e.g. a system user).

Impact & Surface Area of Change

  • Net new code only — no existing notification flow (assign, comment, reassign, etc.) is touched. NotificationService.create is untouched, as is getEmailDetails. No DB schema change. No Trigger.dev registration.
  • The helper is not wired up anywhere yet — it will only run once OUT-3730 lands and calls it. Until then there is zero production behavior change from this PR.

🤖 Generated with Claude Code

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>
@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 25, 2026

OUT-3737

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tasks-app Ready Ready Preview, Comment May 25, 2026 8:59am

Request Review

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 25, 2026

Greptile Summary

Adds sendReminderEmail, a thin helper that builds an email-only Copilot notification payload for task reminders and returns the notification ID, leaving TaskReminderSent ledger writes to the caller. No existing notification paths are touched.

  • send-reminder-email.ts: Constructs the payload from getReminderEmailDetails, calls copilot.createNotification, and returns notification.id. The recipientCompanyId ?? undefined conversion silently drops a field that the Copilot API requires whenever recipientClientId is present (confirmed by validateNotificationRecipient and the eligibility.ts inline comment referencing notification.service.ts:558). Clients whose task has a null companyId will hit a Copilot API error on every cron run.
  • send-reminder-email.test.ts: Five tests cover return value, payload shape, the isCompanyRecipient header branch, null recipientCompanyId, and error propagation — all using a mocked createNotification, so the null-company API rejection is not caught.

Confidence Score: 3/5

Safe to merge in isolation since nothing is wired up yet, but the null-company case will cause every affected reminder to fail permanently once OUT-3730 lands.

The helper drops recipientCompanyId when it is null but always includes recipientClientId. The Copilot API (and the local validateNotificationRecipient schema that mirrors it) requires both fields together for email delivery. For any client task where companyId is NULL in the database, the notification call will be rejected on every cron iteration — the error propagates, TaskReminderSent is never written, so the cron retries forever without making progress.

src/jobs/notifications/send-reminder-email.ts — specifically the recipientCompanyId ?? undefined line and the lack of an early guard when recipientCompanyId is null.

Important Files Changed

Filename Overview
src/jobs/notifications/send-reminder-email.ts New helper dispatching reminder emails via Copilot's notification API. The recipientCompanyId ?? undefined conversion on line 45 silently drops a field that the Copilot API requires whenever recipientClientId is present, meaning reminders for clients with no company will always fail at the API level.
src/jobs/notifications/send-reminder-email.test.ts Good unit-test coverage for payload shape and branching. The null-recipientCompanyId test (line 86) verifies the field is omitted from the payload but does not exercise real API validation, so it misses the pairing constraint between recipientClientId and recipientCompanyId.

Sequence Diagram

sequenceDiagram
    participant Cron as Cron Job
    participant Helper as sendReminderEmail
    participant EmailDetails as getReminderEmailDetails
    participant Copilot as CopilotAPI

    Cron->>Helper: task, recipientClientId, recipientCompanyId, reminderType
    Helper->>EmailDetails: workspace, task, isCompanyRecipient
    EmailDetails-->>Helper: subject, header, title, body
    Helper->>Helper: build email-only payload
    Helper->>Copilot: createNotification
    alt recipientCompanyId is null
        Copilot-->>Helper: API error
        Helper-->>Cron: throws, no TaskReminderSent insert
    else recipientCompanyId present
        Copilot-->>Helper: notification id
        Helper-->>Cron: notification.id
    end
Loading

Reviews (1): Last reviewed commit: "feat(OUT-3737): add sendReminderEmail he..." | Re-trigger Greptile

senderId: task.createdById,
senderType: 'internalUser',
recipientClientId,
recipientCompanyId: recipientCompanyId ?? undefined,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 recipientCompanyId: null will cause Copilot API rejection when recipientClientId is set

When recipientCompanyId is null, the payload drops the field (line 45) but keeps recipientClientId. validateNotificationRecipient (in src/utils/notifications.ts) enforces that recipientClientId must always be paired with recipientCompanyId — and eligibility.ts line 17 confirms this is a Copilot API-level requirement for email-bearing notifications. Any client task where task.companyId is NULL in the database will produce a payload that the API rejects on every cron run, permanently blocking reminder delivery for that recipient. The test at line 86 of the test file mocks createNotification so this is not caught by the test suite. Consider throwing early when recipientCompanyId is null so the caller (OUT-3730) can skip or log the ineligible row rather than silently eating a 4xx on every run.

Copy link
Copy Markdown
Collaborator

@priosshrsth priosshrsth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants