diff --git a/.env.example b/.env.example index b7a5abc1..6f896546 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,10 @@ RESEND_FROM_EMAIL= # Signing secret for the Resend webhook's endpoint (Resend dashboard → the webhook). RESEND_WEBHOOK_SECRET= +# Shared secret Vercel Cron sends as `Authorization: Bearer ` — verified +# by the manager-digest cron routes (app/api/cron/manager-{daily,weekly}-digest). +CRON_SECRET= + # Dev-bypass login (see lib/utils.ts#isBypassAllowed) is disabled by default # on any host, including production deployments not on Vercel. This repo's # `dev` script is plain `next dev --turbopack` (no Vercel CLI), so VERCEL_ENV diff --git a/CLAUDE.md b/CLAUDE.md index 9dc9cef5..1e5446a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ Next.js 16 (App Router, React 19) · Prisma 7 · Tailwind CSS 4 · shadcn/ui (Ra - **IMPORTANT: routes under `app/api/` are forbidden except for the ones allowlisted here.** Mutations are Server Actions; a route earns a line below only when it needs something an action cannot have, and a new route is a rule change that appends to this list. - `app/api/auth/[...path]/route.ts` — Better Auth needs a reachable HTTP endpoint. - `app/api/webhooks/resend/route.ts` — Resend signs the **raw** request body, which a server action never sees. + - `app/api/cron/manager-daily-digest/route.ts` and `app/api/cron/manager-weekly-digest/route.ts` — Vercel Cron needs an HTTP trigger on a schedule, which a server action cannot have. **Each route runs exactly the one digest its path names — these are not a job runner; a third job needs its own route, its own line, and its own justification.** - **Mutations are Server Actions** in `prisma/actions/`, each with `'use server'`, an auth check, and zod validation. They return **`void` / the relevant data on success, `{ error }` for a user-facing failure, and `throw` for unexpected ones — never `{ ok }`** (`docs/ENGINEERING.md` §4). Decision test: _would you show this exact sentence to the user, and can they act on it?_ **yes → `{ error }`, no → throw**. - **Data fetching is server-side** — server components call data-fetching functions in `prisma/data/`; Prisma never runs in a client component. **Avoid `useEffect`** — almost every use is a mistake here, and an empty-deps `useEffect` is essentially never right. - **Default to server components**; add `'use client'` only for interactivity/hooks/browser APIs, on the smallest leaf possible. diff --git a/README.md b/README.md index e5c3e88f..840cdfa8 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,16 @@ cp .env.example .env.local Open `.env.local` and fill in the required variables: -| Variable | Description | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `DATABASE_URL` | Postgres connection string (pooled). Local Docker: `postgresql://admin:admin@localhost:5432/aplio` | -| `DIRECT_URL` | Direct (non-pooled) connection string. Local Docker: same as `DATABASE_URL` | -| `BETTER_AUTH_SECRET` | Signs session cookies. At least 32 characters: `openssl rand -base64 32` | -| `BETTER_AUTH_URL` | Production only, pinned to the real domain. Preview/local derive it from `VERCEL_URL`, else `http://localhost:3000`. Also governs the absolute URLs (logo, sign-in link) in outgoing email. | -| `RESEND_API_KEY` | Resend API key for transactional email delivery | -| `RESEND_FROM_EMAIL` | Verified sender address in Resend (e.g. `noreply@yourdomain.com`) | -| `RESEND_WEBHOOK_SECRET` | Signing secret for the Resend webhook that reports delivery events (Resend dashboard → the webhook) | +| Variable | Description | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DATABASE_URL` | Postgres connection string (pooled). Local Docker: `postgresql://admin:admin@localhost:5432/aplio` | +| `DIRECT_URL` | Direct (non-pooled) connection string. Local Docker: same as `DATABASE_URL` | +| `BETTER_AUTH_SECRET` | Signs session cookies. At least 32 characters: `openssl rand -base64 32` | +| `BETTER_AUTH_URL` | Production only, pinned to the real domain. Preview/local derive it from `VERCEL_URL`, else `http://localhost:3000`. Also governs the absolute URLs (logo, sign-in link) in outgoing email. | +| `RESEND_API_KEY` | Resend API key for transactional email delivery | +| `RESEND_FROM_EMAIL` | Verified sender address in Resend (e.g. `noreply@yourdomain.com`) | +| `RESEND_WEBHOOK_SECRET` | Signing secret for the Resend webhook that reports delivery events (Resend dashboard → the webhook) | +| `CRON_SECRET` | Bearer secret Vercel Cron sends as `Authorization: Bearer …`; verified by the manager-digest cron routes. Set it in the Vercel project env (`openssl rand -base64 32`) or both routes reject every call. | > **Note:** Prisma CLI commands (`prisma:migrate`, `prisma:seed`) read from `.env`; Next.js reads `.env.local`. Both files are gitignored. For local development you can keep the same values in both. diff --git a/app/api/cron/manager-daily-digest/route.ts b/app/api/cron/manager-daily-digest/route.ts new file mode 100644 index 00000000..e0ad389f --- /dev/null +++ b/app/api/cron/manager-daily-digest/route.ts @@ -0,0 +1,11 @@ +import { rejectUnauthorizedCron } from '@/lib/cron'; +import { dispatchDailyManagerDigests } from '@/lib/email/manager-digests'; + +export const maxDuration = 300; + +export async function GET(request: Request): Promise { + const denied = rejectUnauthorizedCron(request); + if (denied) return denied; + + return Response.json(await dispatchDailyManagerDigests()); +} diff --git a/app/api/cron/manager-weekly-digest/route.ts b/app/api/cron/manager-weekly-digest/route.ts new file mode 100644 index 00000000..db50145f --- /dev/null +++ b/app/api/cron/manager-weekly-digest/route.ts @@ -0,0 +1,11 @@ +import { rejectUnauthorizedCron } from '@/lib/cron'; +import { dispatchWeeklyManagerDigests } from '@/lib/email/manager-digests'; + +export const maxDuration = 300; + +export async function GET(request: Request): Promise { + const denied = rejectUnauthorizedCron(request); + if (denied) return denied; + + return Response.json(await dispatchWeeklyManagerDigests()); +} diff --git a/docs/ENGINEERING.md b/docs/ENGINEERING.md index 65ee63ef..84ad2d05 100644 --- a/docs/ENGINEERING.md +++ b/docs/ENGINEERING.md @@ -149,6 +149,7 @@ A thrown error reaches Vercel's runtime logs with a stack and request context; a - **A client `catch` that also toasts** — the §3-mandated wrapper around a server action, where the log keeps a real bug distinguishable from a stale-permission denial. Logging with **no** toast is not exempt. - **A server-side cause the browser can't see** (`prisma/actions/auth.ts`) — log the upstream error, return `{ error }` with safe copy. - **Best-effort side effects get no automatic exemption.** Where the caller can retry (a webhook whose 500 is retried), **throw** — `lib/email/resend.ts` still does, for both the single send and the batch send. The one exemption is a domain email already dispatched from `after()`: `lib/email/application-emails.ts` is the named swallow site, since the mutation it follows has already committed and the `EmailLog` row is the record that makes swallowing correct. + - **A second, differently-justified swallow site: `lib/email/manager-digests.ts`.** No mutation sits behind a digest send — the caller is Vercel Cron, which does not retry a 200 — so throwing on one bad address would abandon every later manager in the run rather than protect anything. Each recipient's send is its own `try`/`catch`; the `EmailLog` row is still the record. ## 5. Accessibility diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index ca14b5c4..c4be1b9d 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -44,6 +44,7 @@ Four principals, each derived rather than stored as a single role field: - **Draft visibility is the question two audit passes answered differently: it is visible to its managers and admins only.** `resolvePositionView` (`app/(main)/positions/[id]/page.tsx`) returns `null` — and the page calls `notFound()` — when `position.status === 'draft' && !canManage`. A manager opening a draft position's public detail page sees it; a signed-in non-manager or anonymous visitor 404s identically. - **The two shapes of applicant denial on the review routes are both correct, deliberately.** `/manage/applications` denies with the page guard `requireManagerOrAdminOr404` — the list itself is off-limits to a plain applicant. `/manage/applications/[id]` denies by **query scoping** (`getCurrentUser` + `buildApplicationWhere(user, 'listable')` → `notFound()`) — the detail route must 404 identically for "not yours" and "doesn't exist", so it can't gate at the top with a role check. - `app/(main)/(auth)/layout.tsx` is the group-level gate: `getCurrentUser()` + `requireName()`. It wraps `manage/applications`, `manage/applications/[id]`, `global-questions`, `applications`, `applications/[id]`, `positions/[id]/apply`, `manage/positions`, `manage/positions/[id]/edit`, and `users`. `/`, `/positions`, `/positions/[id]`, and `/profile` sit outside that group and self-call `requireName` on a signed-in caller. +- **`/api/cron/manager-daily-digest` and `/api/cron/manager-weekly-digest` carry no principal.** They are machine-authenticated — `rejectUnauthorizedCron` (`lib/cron.ts`) checks a bearer secret (`CRON_SECRET`) against the request header before any DB access — not one of the four principals above, so they don't fit this table's columns. ## Server-action authorization diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index 0850f219..d53c7533 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -21,7 +21,7 @@ Behaviour shared by many workflows is stated once under [Cross-cutting behaviour ## Table of contents -**[Cross-cutting behaviours](#cross-cutting-behaviours)** — [XC-1](#xc-1-sign-in-gate-and-the-redirectto-round-trip) · [XC-2](#xc-2-name-gate) · [XC-3](#xc-3-profile-completeness) · [XC-4](#xc-4-denial-shape) · [XC-5](#xc-5-errors-and-feedback) · [XC-6](#xc-6-rate-limiting) · [XC-7](#xc-7-deactivated-account) · [XC-8](#xc-8-applicant-facing-status-grouping) · [XC-9](#xc-9-applicant-email) · [XC-10](#xc-10-activity-panel) +**[Cross-cutting behaviours](#cross-cutting-behaviours)** — [XC-1](#xc-1-sign-in-gate-and-the-redirectto-round-trip) · [XC-2](#xc-2-name-gate) · [XC-3](#xc-3-profile-completeness) · [XC-4](#xc-4-denial-shape) · [XC-5](#xc-5-errors-and-feedback) · [XC-6](#xc-6-rate-limiting) · [XC-7](#xc-7-deactivated-account) · [XC-8](#xc-8-applicant-facing-status-grouping) · [XC-9](#xc-9-applicant-email) · [XC-10](#xc-10-manager-digests) · [XC-11](#xc-11-activity-panel) **[Anonymous (AN)](#anonymous-an)** — [AN-1](#an-1-browse-positions) · [AN-2](#an-2-view-a-position) · [AN-3](#an-3-start-applying-from-a-position) · [AN-4](#an-4-sign-in-with-an-email-code) · [AN-5](#an-5-request-a-new-code) · [AN-6](#an-6-set-your-name-on-first-sign-in) · [AN-7](#an-7-read-the-legal-pages) · [AN-8](#an-8-dev-bypass-sign-in) @@ -89,10 +89,21 @@ Three applicant-facing email _events_, over two rendered templates, all through - **Decision, bulk** (`updateApplicationStatuses`) — the same self-managed delay+undo as the single path, just one shared wait for the whole batch: every eligible row logs `scheduled` immediately, the wait elapses once, then only the rows still `scheduled` go out together through Resend's batch endpoint (chunked at `RESEND_BATCH_MAX_EMAILS` (100) with permissive validation so one bad address can't sink the rest), upgrading to `sent` with their own provider id. Bulk eligibility isn't forward-only, so a bulk move can land on a row that still holds a pending single-decision send; `dispatchBulkDecisionEmails` cancels it first, same as the single path — always a DB flip, never a provider call. Undo reverts **each** application to **its own individual** prior status rather than one shared target, since a batch can mix forward, backward, and final-decision rows — `updateApplicationStatuses` returns each updated row's prior status for the client to call `updateApplicationStatus` per row. Available to every reviewer who can bulk-change status at all — there is no email-specific permission gate, so a manager's bulk accept/reject emails exactly as an admin's does ([PM-12](#pm-12-move-several-applications-at-once)). - **One decision email ever, per application.** Once a decision email for an application has reached `sent` or later (`sent` / `delivered` / `bounced` / `complained` / `suppressed` — anything past `scheduled`), no later status change — single or bulk, any number of flips back and forth (accept → reviewing → reject → reviewing → accept, …) — schedules or sends another. The status write itself still succeeds; only the email is suppressed. This is permanent and independent of the 10-second window above. - **No email at all** on any in-group move (`reached_out` / `interview_scheduled` / `reviewing`) or on withdrawal — only a decision or a submission ever emails the applicant. + +### XC-10 Manager digests + +Two cron-triggered emails on independent cadences that never gate each other, neither backed by a stored "last sent" column. + +- **Daily** (`manager_daily_digest`) — `GET /api/cron/manager-daily-digest`, Vercel Cron at `0 12 * * *` UTC (08:00 ET). Windowed **since each manager's own last digest** (any status, success or failure — a failed run isn't retried), falling back to `DAILY_DIGEST_LOOKBACK_MS` (24h) for a manager's first-ever digest. This is deliberately gap-safe rather than calendar-day-bounded: a missed or delayed cron fire never drops an application, and in steady daily operation the window is always ~24h anyway. A manager with at least one new application on any managed position gets one email covering every such position, grouped, each linking to that position's filtered queue (`/manage/applications?positionId=`) and naming the exact since-instant (not a calendar day, since the window can span more after a gap); a manager with nothing new in their window gets nothing. +- **Weekly** (`manager_weekly_digest`) — `GET /api/cron/manager-weekly-digest`, Vercel Cron at `0 13 * * 1` UTC, Mondays, gated per org calendar week (an `EmailLog` row already existing this week is a no-op). A reminder, not a recap: it has no time window on its own, and reports **every application still short of a terminal status** (not `accepted`/`rejected`/`withdrawn`/`draft`) across the manager's positions, however long it's been sitting there — a status count box per unresolved status (`/manage/applications?status=`), plus the manager's currently-open positions as context. Sent only when that total is greater than zero; a manager with nothing outstanding gets nothing, regardless of how much happened that week. +- **A manager who manages several positions always gets one email per cadence, never one per position.** +- **Machine-authenticated, no principal.** Both routes reject any request whose `Authorization` header isn't `Bearer ${CRON_SECRET}` before touching the database (`lib/cron.ts`) — see `PERMISSIONS.md` → "Route access". +- **Swallowed per recipient, like [XC-9](#xc-9-applicant-email) but for a different reason.** `lib/email/manager-digests.ts` catches each manager's send individually — Vercel Cron never retries a 200, so a thrown failure would only abandon every manager after the first bad address, not protect a mutation (there isn't one behind a digest). The `EmailLog` row, `failed` included, is the record; a failed digest is not retried within its period. +- **Not a general job runner.** Each route computes and sends exactly the one digest its path names (`CLAUDE.md`'s allowlist). - **A failed send never fails the mutation.** The status write (or the submission) has already committed by the time the email is attempted; the send is a side effect dispatched in `after()`, and a provider failure is logged to `EmailLog` as `failed` and surfaced nowhere — not to the reviewer, not to the applicant. - **No opt-out, no preferences, no per-position copy.** -### XC-10 Activity panel +### XC-11 Activity panel A `Sheet` reachable from every authenticated page, not just the dashboard — top-right of the sidebar's `h-14` header bar on desktop, and immediately left of the hamburger menu on mobile, so the control lands in the same visual position at both breakpoints. Hidden entirely for anonymous visitors. Opens from the right (`side="right"`), so it never reads as the same surface as `MobileNav`'s `side="left"` menu drawer. @@ -512,7 +523,7 @@ A user who manages at least one non-deleted position. Manager status is **derive ### PM-9 Open an application for review - **Trigger** — a row on `/manage/applications` (`/manage/applications/[id]`). -- **Happy path** — `getApplicationForReview(id, user)` uses the `listable` scope — withdrawn rows are kept, drafts are not — and `getApplicationStatusHistory(id, user)` fetches alongside it. The page shows a "Back to Applications" link, then a header row with the applicant's snapshotted name and status badge together, their email underneath, and a header action appropriate to status, right-aligned on that same row — a split button for the four unresolved statuses (its caret dropdown ends in **See more**, which opens the status dialog), or for terminal decisions and non-reviewable statuses alike, the same explanatory note plus a standalone caret whose dropdown menu also ends in **See more** — there is no separate standalone `⋯` for any status, since that would be a second control shape doing the same job as the caret; below it, a linked position title and the applied date, then an "Other Applications" section (`getApplicantOtherApplications`) followed by the profile and position answer groups, then an "Email History" section (`getApplicationEmailHistory`) last, each full width. The "Other Applications" section lists this applicant's other applications platform-wide — including positions the viewer doesn't manage — with precise status, applied date, and the position title linked to `/positions/[id]`; a row links to `/manage/applications/[id]` only when the viewer can actually open it (admin, or a manager of that position) — otherwise the row shows no link at all. The position-answers group lists **every** live position question, in the position's own question order, whether it was answered or not: an answered row renders from its snapshotted `questionLabel`/`value`/`type`, so a question retyped or relabeled after submission still shows the original label and value; an unanswered one shows the current question's label with "No answer" in the value slot. An answer to a since-deleted question still renders, appended after the live questions. The "Email History" section lists every `EmailLog` row for this application — subject, a status badge, and a meta line of `{Template label} · {timestamp} · {status description}` (`getEmailLogOccurredAt`) — through the same `listable` scope as the rest of the page, applied through the `application` relation; OTP rows never appear, since they're written with no `applicationId` and so can never match the equality filter (no template filter exists to be forgotten or bypassed). The template label is what tells `application_accepted` and `application_rejected` apart here, since both now render an identical subject ([XC-9](#xc-9-applicant-email)). `sent` is shown distinctly from `delivered` — a sentence under the timestamp states that delivery isn't confirmed yet, since a provider hand-off is not proof of receipt. See `PERMISSIONS.md` → "Cross-scope disclosure" for the authorization rule. +- **Happy path** — `getApplicationForReview(id, user)` uses the `listable` scope — withdrawn rows are kept, drafts are not — and `getApplicationStatusHistory(id, user)` fetches alongside it. The page shows a "Back to Applications" link, then a header row with the applicant's snapshotted name and status badge together, their email underneath, and a header action appropriate to status, right-aligned on that same row — a split button for the four unresolved statuses (its caret dropdown ends in **See more**, which opens the status dialog), or for terminal decisions and non-reviewable statuses alike, the same explanatory note plus a standalone caret whose dropdown menu also ends in **See more** — there is no separate standalone `⋯` for any status, since that would be a second control shape doing the same job as the caret; below it, a linked position title and the applied date, then an "Other Applications" section (`getApplicantOtherApplications`) followed by the profile and position answer groups, then an "Email History" section (`getApplicationEmailHistory`) last, each full width. The "Other Applications" section lists this applicant's other applications platform-wide — including positions the viewer doesn't manage — with precise status, applied date, and the position title linked to `/positions/[id]`; a row links to `/manage/applications/[id]` only when the viewer can actually open it (admin, or a manager of that position) — otherwise the row shows no link at all. The position-answers group lists **every** live position question, in the position's own question order, whether it was answered or not: an answered row renders from its snapshotted `questionLabel`/`value`/`type`, so a question retyped or relabeled after submission still shows the original label and value; an unanswered one shows the current question's label with "No answer" in the value slot. An answer to a since-deleted question still renders, appended after the live questions. The "Email History" section lists every `EmailLog` row for this application — subject, a status badge, and a meta line of `{Template label} · {timestamp} · {status description}` (`getEmailLogOccurredAt`) — through the same `listable` scope as the rest of the page, applied through the `application` relation; OTP rows never appear, since they're written with no `applicationId` and so can never match the equality filter (no template filter exists to be forgotten or bypassed). The template label is what tells `application_accepted` and `application_rejected` apart here, since both now render an identical subject ([XC-9](#xc-9-applicant-email)). `sent` is shown distinctly from `delivered` — a sentence under the timestamp states that delivery isn't confirmed yet, since a provider hand-off is not proof of receipt. See `PERMISSIONS.md` → "Cross-scope disclosure" for the authorization rule. A manager working this queue may separately receive a daily digest of new arrivals or a weekly reminder of everything still unresolved across their positions ([XC-10](#xc-10-manager-digests)) — neither email is tied to any one application, so neither appears in this page's Email History section. - **Failure / edge** - Outside the caller's scope, a draft, or missing → `notFound()`; unauthorized and missing are indistinguishable. - The applicant renamed themselves since submitting → the heading reads " ()". @@ -700,7 +711,7 @@ An admin is a **manager on every position**: every [Position manager](#position- ### AD-12 Look up an email - **Trigger** — `/emails`, or a failure-strip card's link (e.g. `/emails?status=bounced`). -- **Happy path** — the page opens with a **Delivery failures** strip (bounced, complained, failed counts over the last 7 days; each card links into the matching status filter) above a **Status** / **Template** / **Search by recipient address** toolbar and a newest-first table. Search is debounced and case-insensitive over the recipient address. A bounced row shows its `bounceType` (**Permanent** vs **Transient**) alongside the status badge, plus the provider's error text. A legend line above the table reads **"Sent means Resend accepted the message — only Delivered confirms it reached the inbox. Newest first."** — `sent` never renders with the success badge variant. Pagination round-trips through the URL exactly like [`/manage/applications`](#pm-8-work-the-application-queue); a filter change lands on page 1, and a stale `?page=` clamps to the last page instead of rendering blank. +- **Happy path** — the page opens with a **Delivery failures** strip (bounced, complained, failed counts over the last 7 days; each card links into the matching status filter) above a **Status** / **Template** / **Search by recipient address** toolbar and a newest-first table. Search is debounced and case-insensitive over the recipient address. A bounced row shows its `bounceType` (**Permanent** vs **Transient**) alongside the status badge, plus the provider's error text. A legend line above the table reads **"Sent means Resend accepted the message — only Delivered confirms it reached the inbox. Newest first."** — `sent` never renders with the success badge variant. Pagination round-trips through the URL exactly like [`/manage/applications`](#pm-8-work-the-application-queue); a filter change lands on page 1, and a stale `?page=` clamps to the last page instead of rendering blank. The **Template** filter includes `Manager daily digest` and `Manager weekly digest` ([XC-10](#xc-10-manager-digests)) alongside the applicant-facing templates; a digest row's recipient is a manager, not an applicant, and its `applicationId` is always null, so it never appears in any application's **Email history** ([PM-9](#pm-9-open-an-application-for-review)). - **Failure / edge** - A manager or applicant visiting `/emails` directly → 404, same as any other admin-only route; no **Email Log** nav entry for either. - No emails match the current filters → the table's "No emails match your filters." row/card; **Clear filters** resets search, status and template together. diff --git a/lib/constants.ts b/lib/constants.ts index 053811e7..09e17daa 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -422,7 +422,7 @@ export const EMAIL_STATUS_DESCRIPTIONS: Record< sent: 'Handed off to the email provider — delivery not confirmed yet.', delivered: null, bounced: null, - complained: 'The applicant marked this as spam.', + complained: 'The recipient marked this as spam.', suppressed: "Blocked before sending because the address is on the provider's suppression list.", failed: 'This was never sent.', @@ -1192,9 +1192,18 @@ export const EMAIL_TEMPLATE_VALUES = [ 'application_received', 'application_accepted', 'application_rejected', - 'manager_digest', + 'manager_daily_digest', + 'manager_weekly_digest', ] as const satisfies $Enums.EmailTemplateKey[]; +// Keeps a digest run under Resend's 2 req/s (docs.resend.com/api-reference/introduction#rate-limit). +export const MANAGER_DIGEST_SEND_SPACING_MS = 600; + +// Daily digest fallback window for a manager with no prior digest — every +// later run instead windows from that manager's own last digest, so a +// missed/delayed cron fire never drops a gap of applications. +export const DAILY_DIGEST_LOOKBACK_MS = 24 * 60 * 60 * 1000; + export const EMAIL_STATUS_LABELS: Record<$Enums.EmailStatus, string> = { scheduled: 'Scheduled', sent: 'Sent', @@ -1226,7 +1235,8 @@ export const EMAIL_TEMPLATE_LABELS: Record<$Enums.EmailTemplateKey, string> = { application_received: 'Application received', application_accepted: 'Application accepted', application_rejected: 'Application rejected', - manager_digest: 'Manager digest', + manager_daily_digest: 'Manager daily digest', + manager_weekly_digest: 'Manager weekly digest', }; export const EMAIL_STATUS_OPTIONS: { diff --git a/lib/cron.ts b/lib/cron.ts new file mode 100644 index 00000000..4b0e0416 --- /dev/null +++ b/lib/cron.ts @@ -0,0 +1,14 @@ +import 'server-only'; + +// Misconfiguration, not a request problem — throwing surfaces it in Vercel's +// logs instead of silently rejecting every cron call as unauthorized. +export function rejectUnauthorizedCron(request: Request): Response | null { + if (!process.env.CRON_SECRET) + throw new Error('CRON_SECRET is not configured'); + + const authorization = request.headers.get('authorization'); + if (authorization !== `Bearer ${process.env.CRON_SECRET}`) + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + + return null; +} diff --git a/lib/dates.ts b/lib/dates.ts index 4092230b..96775d53 100644 --- a/lib/dates.ts +++ b/lib/dates.ts @@ -73,6 +73,37 @@ export function toOrgDayString(date: Date): string { }).format(date); } +// Calendar-date arithmetic on the Y/M/D triple — never on a resolved instant, +// so a DST transition inside the shifted range can't shift the day count. +function shiftOrgDay(day: string, deltaDays: number): string { + const [year, month, date] = parseOrgDay(day); + const shifted = new Date(Date.UTC(year, month - 1, date + deltaDays)); + return [ + shifted.getUTCFullYear(), + String(shifted.getUTCMonth() + 1).padStart(2, '0'), + String(shifted.getUTCDate()).padStart(2, '0'), + ].join('-'); +} + +/** The org day before `now`'s org day, with its instant bounds. */ +export function previousOrgDay(now: Date): { + day: string; + start: Date; + end: Date; +} { + const day = shiftOrgDay(toOrgDayString(now), -1); + return { day, start: orgDayStart(day), end: orgDayEnd(day) }; +} + +/** `YYYY-MM-DD` of the Monday of `now`'s org-local week. */ +export function currentOrgWeekStart(now: Date): string { + const day = toOrgDayString(now); + const [year, month, date] = parseOrgDay(day); + const dow = new Date(Date.UTC(year, month - 1, date)).getUTCDay(); + const daysSinceMonday = (dow + 6) % 7; + return shiftOrgDay(day, -daysSinceMonday); +} + export function formatInstant( date: Date, { precision, timeZone }: { precision: DatePrecision; timeZone: string }, diff --git a/lib/email/manager-digests.ts b/lib/email/manager-digests.ts new file mode 100644 index 00000000..91b9783f --- /dev/null +++ b/lib/email/manager-digests.ts @@ -0,0 +1,91 @@ +import 'server-only'; + +import { + getDailyDigestRecipients, + getWeeklyDigestRecipients, +} from '@/prisma/data/digests'; + +import { MANAGER_DIGEST_SEND_SPACING_MS } from '@/lib/constants'; +import { delay } from '@/lib/delay'; +import { sendEmail } from '@/lib/email/resend'; +import { + managerDailyDigestEmail, + managerWeeklyDigestEmail, +} from '@/lib/email/templates'; +import { getFirstName } from '@/lib/utils'; + +export interface DigestDispatchResult { + sent: number; + failed: number; + skipped: number; +} + +export async function dispatchDailyManagerDigests( + now: Date = new Date(), +): Promise { + const { recipients, skipped } = await getDailyDigestRecipients(now); + + let sent = 0; + let failed = 0; + for (const recipient of recipients) { + try { + const { subject, html, text } = managerDailyDigestEmail({ + firstName: getFirstName(recipient.name), + since: recipient.since, + positions: recipient.positions, + total: recipient.total, + }); + await sendEmail({ + to: recipient.email, + subject, + html, + text, + template: 'manager_daily_digest', + userId: recipient.userId, + }); + sent += 1; + } catch { + // Already recorded as an EmailLog row by sendEmail — Vercel Cron does + // not retry, so one bad address must not stop the rest of the run. + failed += 1; + } + await delay(MANAGER_DIGEST_SEND_SPACING_MS); + } + + return { sent, failed, skipped }; +} + +export async function dispatchWeeklyManagerDigests( + now: Date = new Date(), +): Promise { + const { recipients, skipped } = await getWeeklyDigestRecipients(now); + + let sent = 0; + let failed = 0; + for (const recipient of recipients) { + try { + const { subject, html, text } = managerWeeklyDigestEmail({ + firstName: getFirstName(recipient.name), + asOfDay: recipient.asOfDay, + statusCounts: recipient.statusCounts, + openPositions: recipient.openPositions, + }); + await sendEmail({ + to: recipient.email, + subject, + html, + text, + template: 'manager_weekly_digest', + userId: recipient.userId, + }); + sent += 1; + } catch { + // Already recorded as an EmailLog row by sendEmail — Vercel Cron does + // not retry, so one bad address must not stop the rest of the run. + failed += 1; + } + await delay(MANAGER_DIGEST_SEND_SPACING_MS); + } + + return { sent, failed, skipped }; +} diff --git a/lib/email/templates.ts b/lib/email/templates.ts index 28c39421..fc5b0c4d 100644 --- a/lib/email/templates.ts +++ b/lib/email/templates.ts @@ -1,6 +1,14 @@ import 'server-only'; +import { type $Enums } from '@/prisma/client'; + import { getBaseUrl } from '@/lib/base-url'; +import { APPLICATION_STATUS_LABELS, ORG_TIMEZONE } from '@/lib/constants'; +import { formatInstant, orgDayStart } from '@/lib/dates'; +import { + type ManagerDigestPosition, + type WeeklyDigestStatusCount, +} from '@/lib/types'; // Inline styles throughout: email clients ignore Tailwind classes. @@ -248,3 +256,216 @@ export function applicationDecisionEmail({ text, }; } + +export const MANAGER_EMAIL_FOOTER = + 'You're receiving this because you manage one or more positions on Aplio.'; + +function pluralize(count: number, singular: string): string { + return count === 1 ? singular : `${singular}s`; +} + +function formatDigestDay(day: string): string { + return new Intl.DateTimeFormat('en-US', { + timeZone: ORG_TIMEZONE, + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(orgDayStart(day)); +} + +export interface ManagerDailyDigestEmailOptions { + firstName?: string; + since: Date; + positions: ManagerDigestPosition[]; + total: number; +} + +// `since` is an instant, not a calendar day — the window is "since your last +// digest," which can span more than a day after a missed/delayed run, so the +// copy names the exact moment rather than a single date. +export function managerDailyDigestEmail({ + firstName, + since, + positions, + total, +}: ManagerDailyDigestEmailOptions): EmailTemplate { + const baseUrl = getBaseUrl(); + const allApplicationsUrl = `${baseUrl}/manage/applications`; + const sinceLabel = formatInstant(since, { + precision: 'datetime', + timeZone: ORG_TIMEZONE, + }); + const safeGreeting = escapeHtml(greeting(firstName)); + + const subject = + positions.length === 1 + ? `${total} new ${pluralize(total, 'application')} for ${positions[0]!.title}` + : `${total} new applications across ${positions.length} positions`; + + const positionRows = positions + .map((position) => { + const url = `${allApplicationsUrl}?positionId=${position.positionId}`; + return `

${escapeHtml(position.title)} — ${position.newApplications} new ${pluralize(position.newApplications, 'application')}

`; + }) + .join('\n'); + + const content = ` +

${safeGreeting}

+

New applications on the positions you manage, since ${escapeHtml(sinceLabel)}.

+
${positionRows}
+ ${primaryButton(allApplicationsUrl, 'Review all applications')} + `; + + const text = [ + greeting(firstName), + '', + `New applications on the positions you manage, since ${sinceLabel}.`, + '', + ...positions.map( + (position) => + `${position.title} — ${position.newApplications} new ${pluralize(position.newApplications, 'application')}: ${allApplicationsUrl}?positionId=${position.positionId}`, + ), + '', + `Review all applications: ${allApplicationsUrl}`, + ].join('\n'); + + return { + subject, + html: emailLayout({ + title: 'Manager daily digest', + content, + footer: MANAGER_EMAIL_FOOTER, + }), + text, + }; +} + +// Matches the in-app status-dot palette — reviewing is the sole 'warning' +// (APPLICATION_STATUS_BADGE_VARIANT), the rest are 'info'. +const DIGEST_STATUS_DOT_COLOR: Partial< + Record<$Enums.ApplicationStatus, string> +> = { + applied: '#2563eb', + reached_out: '#2563eb', + interview_scheduled: '#2563eb', + reviewing: '#d97706', +}; + +// A single stat box: dot + big number + label, linking to the filtered queue. +// Table-based, not flex/grid — Outlook's Word engine only renders tables reliably. +function statBox( + url: string, + color: string, + count: number, + label: string, +): string { + const safeUrl = escapeHtml(url); + return ` + +
+ ${count} + ${escapeHtml(label)} +
+ `; +} + +// Two boxes per row (UNRESOLVED_APPLICATION_STATUSES never exceeds four), a +// blank spacer cell keeps the last row's alignment when the count is odd. +function statBoxGrid( + entries: { url: string; color: string; count: number; label: string }[], +): string { + const rows: string[] = []; + for (let i = 0; i < entries.length; i += 2) { + const a = entries[i]!; + const b = entries[i + 1]; + rows.push( + `${statBox(a.url, a.color, a.count, a.label)}${b ? statBox(b.url, b.color, b.count, b.label) : ' '}`, + ); + } + return `${rows.join('')}
`; +} + +export interface ManagerWeeklyDigestEmailOptions { + firstName?: string; + asOfDay: string; + statusCounts: WeeklyDigestStatusCount[]; + openPositions: Pick[]; +} + +// statusCounts is always unresolved-only (WeeklyDigestRecipient's contract) — +// this is a reminder of outstanding review work, never a terminal-decision recap. +export function managerWeeklyDigestEmail({ + firstName, + asOfDay, + statusCounts, + openPositions, +}: ManagerWeeklyDigestEmailOptions): EmailTemplate { + const baseUrl = getBaseUrl(); + const allApplicationsUrl = `${baseUrl}/manage/applications`; + const asOfLabel = formatDigestDay(asOfDay); + const safeGreeting = escapeHtml(greeting(firstName)); + const total = statusCounts.reduce((sum, entry) => sum + entry.count, 0); + + const subject = `${total} ${pluralize(total, 'application')} awaiting your review`; + + const statBoxesHtml = statBoxGrid( + statusCounts.map((entry) => ({ + url: `${allApplicationsUrl}?status=${entry.status}`, + color: DIGEST_STATUS_DOT_COLOR[entry.status] ?? '#71717a', + count: entry.count, + label: APPLICATION_STATUS_LABELS[entry.status], + })), + ); + + const openPositionsLine = + openPositions.length > 0 + ? openPositions + .map((position) => { + const url = `${allApplicationsUrl}?positionId=${position.positionId}`; + return `${escapeHtml(position.title)}`; + }) + .join(' · ') + : 'You have no positions open right now.'; + + const content = ` +

${safeGreeting}

+

As of ${escapeHtml(asOfLabel)}, you have ${total} ${pluralize(total, 'application')} awaiting review across the positions you manage.

+ ${statBoxesHtml} +

Your open positions

+

${openPositionsLine}

+ ${primaryButton(allApplicationsUrl, 'Review all applications')} + `; + + const text = [ + greeting(firstName), + '', + `As of ${asOfLabel}, you have ${total} ${pluralize(total, 'application')} awaiting review across the positions you manage.`, + '', + ...statusCounts.map( + (entry) => + `${APPLICATION_STATUS_LABELS[entry.status]}: ${entry.count} — ${allApplicationsUrl}?status=${entry.status}`, + ), + '', + 'Your open positions', + openPositions.length > 0 + ? openPositions + .map( + (position) => + `${position.title}: ${allApplicationsUrl}?positionId=${position.positionId}`, + ) + .join('\n') + : 'You have no positions open right now.', + '', + `Review all applications: ${allApplicationsUrl}`, + ].join('\n'); + + return { + subject, + html: emailLayout({ + title: 'Manager weekly digest', + content, + footer: MANAGER_EMAIL_FOOTER, + }), + text, + }; +} diff --git a/lib/types.ts b/lib/types.ts index 76b94b7e..aecb391a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -574,3 +574,33 @@ export type EmailLogListItem = Prisma.EmailLogGetPayload<{ export type EmailFailureStatus = (typeof EMAIL_FAILURE_STATUSES)[number]; export type EmailFailureCounts = Record; + +export type ManagerDigestPosition = { + positionId: string; + title: string; + newApplications: number; +}; + +export type DailyDigestRecipient = { + userId: string; + email: string; + name: string | null; + since: Date; + positions: ManagerDigestPosition[]; + total: number; +}; + +// status is always one of UNRESOLVED_APPLICATION_STATUSES — never a terminal decision. +export type WeeklyDigestStatusCount = { + status: $Enums.ApplicationStatus; + count: number; +}; + +export type WeeklyDigestRecipient = { + userId: string; + email: string; + name: string | null; + asOfDay: string; + statusCounts: WeeklyDigestStatusCount[]; + openPositions: Pick[]; +}; diff --git a/lib/utils.ts b/lib/utils.ts index 2edbaaa6..8934f118 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -94,9 +94,9 @@ export function getEmailLogDescription(entry: { return EMAIL_STATUS_DESCRIPTIONS[entry.status]; if (entry.bounceType === 'Permanent') - return 'The address rejected it permanently — the applicant did not receive this.'; + return 'The address rejected it permanently — the recipient did not receive this.'; if (entry.bounceType === 'Transient') - return 'Temporarily undeliverable — the applicant did not receive this.'; + return 'Temporarily undeliverable — the recipient did not receive this.'; return 'This could not be delivered.'; } diff --git a/prisma/data/digests.ts b/prisma/data/digests.ts new file mode 100644 index 00000000..67fef5ea --- /dev/null +++ b/prisma/data/digests.ts @@ -0,0 +1,282 @@ +import 'server-only'; + +import { type $Enums } from '@/prisma/client'; + +import { + DAILY_DIGEST_LOOKBACK_MS, + NON_REVIEWABLE_APPLICATION_STATUSES, + PUBLISHED_POSITION_WHERE, + UNRESOLVED_APPLICATION_STATUSES, +} from '@/lib/constants'; +import { currentOrgWeekStart, orgDayStart, toOrgDayString } from '@/lib/dates'; +import { prisma } from '@/lib/prisma'; +import { + type DailyDigestRecipient, + type ManagerDigestPosition, + type WeeklyDigestRecipient, +} from '@/lib/types'; +import { isAcceptingApplications } from '@/lib/utils'; + +type ManagerCandidate = { + id: string; + email: string; + name: string | null; + managedPositions: { + id: string; + title: string; + status: $Enums.PositionStatus; + opensAt: Date | null; + closesAt: Date | null; + }[]; +}; + +// Scoped by the `managers` relation only — never buildReviewablePositionWhere, +// whose admin short-circuit would hand an admin every position. +async function getManagerCandidates(): Promise { + return prisma.user.findMany({ + where: { + deletedAt: null, + managedPositions: { some: PUBLISHED_POSITION_WHERE }, + }, + select: { + id: true, + email: true, + name: true, + managedPositions: { + where: PUBLISHED_POSITION_WHERE, + select: { + id: true, + title: true, + status: true, + opensAt: true, + closesAt: true, + }, + }, + }, + }); +} + +// Raw rows, not a groupBy count — the daily digest windows per manager (each +// manager's own last-digest cutoff), so the count per position can't be +// pre-aggregated until each manager's cutoff is known. +async function fetchNewApplications( + positionIds: string[], + since: Date, + until: Date, +): Promise<{ positionId: string; submittedAt: Date }[]> { + if (positionIds.length === 0) return []; + + const rows = await prisma.application.findMany({ + where: { + positionId: { in: positionIds }, + deletedAt: null, + status: { notIn: NON_REVIEWABLE_APPLICATION_STATUSES }, + submittedAt: { gt: since, lte: until }, + position: PUBLISHED_POSITION_WHERE, + }, + select: { positionId: true, submittedAt: true }, + }); + + // Only a draft has a null submittedAt, and drafts are already excluded by + // status above — this narrows the type back from the nullable column. + return rows.filter( + (row): row is { positionId: string; submittedAt: Date } => + row.submittedAt !== null, + ); +} + +// A manager's own last `manager_daily_digest` row (success or failure — a +// failed run isn't retried, same as before) anchors their next window, so a +// missed/delayed cron fire can never drop a gap; a manager never digested +// before falls back to DAILY_DIGEST_LOOKBACK_MS. +async function getDigestSinceByManager( + managerIds: string[], + fallback: Date, +): Promise> { + if (managerIds.length === 0) return new Map(); + + const rows = await prisma.emailLog.groupBy({ + by: ['userId'], + where: { template: 'manager_daily_digest', userId: { in: managerIds } }, + _max: { createdAt: true }, + }); + + const since = new Map(); + for (const row of rows) + if (row.userId !== null) + since.set(row.userId, row._max.createdAt ?? fallback); + return since; +} + +async function tallyStatusBreakdown( + positionIds: string[], + statuses: readonly $Enums.ApplicationStatus[], +): Promise>> { + const map = new Map>(); + if (positionIds.length === 0) return map; + + const rows = await prisma.application.groupBy({ + by: ['positionId', 'status'], + where: { + positionId: { in: positionIds }, + deletedAt: null, + status: { in: [...statuses] }, + position: PUBLISHED_POSITION_WHERE, + }, + _count: true, + }); + + for (const row of rows) { + const existing = map.get(row.positionId) ?? new Map(); + existing.set(row.status, row._count); + map.set(row.positionId, existing); + } + + return map; +} + +/** + * Managers with new applications since their own last daily digest (or the + * lookback fallback, for a first-ever digest). A manager with nothing new in + * their window is counted in `skipped`, whether that's genuinely no activity + * or a repeat call shortly after a successful send — both collapse to the + * same "nothing since last time" outcome. + */ +export async function getDailyDigestRecipients( + now: Date = new Date(), +): Promise<{ recipients: DailyDigestRecipient[]; skipped: number }> { + const managers = await getManagerCandidates(); + if (managers.length === 0) return { recipients: [], skipped: 0 }; + + const managerIds = managers.map((manager) => manager.id); + const fallbackSince = new Date(now.getTime() - DAILY_DIGEST_LOOKBACK_MS); + const sinceByManager = await getDigestSinceByManager( + managerIds, + fallbackSince, + ); + + const positionIds = managers.flatMap((manager) => + manager.managedPositions.map((position) => position.id), + ); + const earliestSince = managers.reduce((earliest, manager) => { + const since = sinceByManager.get(manager.id) ?? fallbackSince; + return since < earliest ? since : earliest; + }, fallbackSince); + + const applications = await fetchNewApplications( + positionIds, + earliestSince, + now, + ); + const submittedAtByPosition = new Map(); + for (const application of applications) { + const list = submittedAtByPosition.get(application.positionId) ?? []; + list.push(application.submittedAt); + submittedAtByPosition.set(application.positionId, list); + } + + const recipients: DailyDigestRecipient[] = []; + let skipped = 0; + for (const manager of managers) { + const since = sinceByManager.get(manager.id) ?? fallbackSince; + + const positions: ManagerDigestPosition[] = manager.managedPositions + .map((position) => ({ + positionId: position.id, + title: position.title, + newApplications: (submittedAtByPosition.get(position.id) ?? []).filter( + (submittedAt) => submittedAt > since, + ).length, + })) + .filter((position) => position.newApplications > 0) + .sort((a, b) => a.title.localeCompare(b.title)); + + const total = positions.reduce( + (sum, position) => sum + position.newApplications, + 0, + ); + if (total === 0) { + skipped += 1; + continue; + } + + recipients.push({ + userId: manager.id, + email: manager.email, + name: manager.name, + since, + positions, + total, + }); + } + + return { recipients, skipped }; +} + +/** Managers with any application still short of a terminal status, gated per org week. */ +export async function getWeeklyDigestRecipients( + now: Date = new Date(), +): Promise<{ recipients: WeeklyDigestRecipient[]; skipped: number }> { + const managers = await getManagerCandidates(); + if (managers.length === 0) return { recipients: [], skipped: 0 }; + + const managerIds = managers.map((manager) => manager.id); + + const alreadyDigested = await prisma.emailLog.findMany({ + where: { + template: 'manager_weekly_digest', + userId: { in: managerIds }, + createdAt: { gte: orgDayStart(currentOrgWeekStart(now)) }, + }, + select: { userId: true }, + }); + const gatedIds = new Set( + alreadyDigested.map((row) => row.userId).filter((id) => id !== null), + ); + + const candidates = managers.filter((manager) => !gatedIds.has(manager.id)); + if (candidates.length === 0) + return { recipients: [], skipped: gatedIds.size }; + + const positionIds = candidates.flatMap((manager) => + manager.managedPositions.map((position) => position.id), + ); + const statusTallies = await tallyStatusBreakdown( + positionIds, + UNRESOLVED_APPLICATION_STATUSES, + ); + + const recipients: WeeklyDigestRecipient[] = []; + for (const manager of candidates) { + const managerPositionIds = manager.managedPositions.map( + (position) => position.id, + ); + + const statusCounts = UNRESOLVED_APPLICATION_STATUSES.map((status) => ({ + status, + count: managerPositionIds.reduce( + (sum, id) => sum + (statusTallies.get(id)?.get(status) ?? 0), + 0, + ), + })).filter((entry) => entry.count > 0); + + const total = statusCounts.reduce((sum, entry) => sum + entry.count, 0); + if (total === 0) continue; + + const openPositions = manager.managedPositions + .filter((position) => isAcceptingApplications(position)) + .map((position) => ({ positionId: position.id, title: position.title })) + .sort((a, b) => a.title.localeCompare(b.title)); + + recipients.push({ + userId: manager.id, + email: manager.email, + name: manager.name, + asOfDay: toOrgDayString(now), + statusCounts, + openPositions, + }); + } + + return { recipients, skipped: gatedIds.size }; +} diff --git a/prisma/migrations/20260911222818_manager_digest_cadences/migration.sql b/prisma/migrations/20260911222818_manager_digest_cadences/migration.sql new file mode 100644 index 00000000..abd083f5 --- /dev/null +++ b/prisma/migrations/20260911222818_manager_digest_cadences/migration.sql @@ -0,0 +1,2 @@ +ALTER TYPE "EmailTemplateKey" RENAME VALUE 'manager_digest' TO 'manager_daily_digest'; +ALTER TYPE "EmailTemplateKey" ADD VALUE 'manager_weekly_digest'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0a86ae63..0e81d129 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -55,7 +55,8 @@ enum EmailTemplateKey { application_received application_accepted application_rejected - manager_digest + manager_daily_digest + manager_weekly_digest } model User { diff --git a/tests/db/email-log-queries.test.ts b/tests/db/email-log-queries.test.ts index 0e749840..904a81a5 100644 --- a/tests/db/email-log-queries.test.ts +++ b/tests/db/email-log-queries.test.ts @@ -81,12 +81,15 @@ describe('getEmailLogs / getEmailLogsCount filtering', () => { }); await seedRow({ to: `${TEST_PREFIX}${marker}-digest@example.com`, - template: 'manager_digest', + template: 'manager_daily_digest', }); - const rows = await getEmailLogs({ q: marker, template: 'manager_digest' }); + const rows = await getEmailLogs({ + q: marker, + template: 'manager_daily_digest', + }); expect(rows).toHaveLength(1); - expect(rows[0]?.template).toBe('manager_digest'); + expect(rows[0]?.template).toBe('manager_daily_digest'); }); it('status and template filters compose', async () => { @@ -99,7 +102,7 @@ describe('getEmailLogs / getEmailLogsCount filtering', () => { await seedRow({ to: `${TEST_PREFIX}${marker}-wrong-template@example.com`, status: 'bounced', - template: 'manager_digest', + template: 'manager_daily_digest', }); await seedRow({ to: `${TEST_PREFIX}${marker}-wrong-status@example.com`, diff --git a/tests/db/manager-digests.test.ts b/tests/db/manager-digests.test.ts new file mode 100644 index 00000000..df3f4874 --- /dev/null +++ b/tests/db/manager-digests.test.ts @@ -0,0 +1,389 @@ +import { + TEST_PREFIX, + cleanupFixtures, + createTestApplication, + createTestPosition, + createTestUser, +} from '@/tests/helpers/fixtures'; +import { randomUUID } from 'node:crypto'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import type { Position, User } from '@/prisma/client'; + +import { prisma } from '@/lib/prisma'; + +const mockSend = vi.fn(); + +vi.mock('resend', () => ({ + Resend: class { + emails = { send: (...args: unknown[]) => mockSend(...args) }; + }, +})); + +const { GET: dailyGET } = + await import('@/app/api/cron/manager-daily-digest/route'); +const { GET: weeklyGET } = + await import('@/app/api/cron/manager-weekly-digest/route'); + +const DAILY_URL = 'http://localhost/api/cron/manager-daily-digest'; +const WEEKLY_URL = 'http://localhost/api/cron/manager-weekly-digest'; +const CRON_SECRET = 'test-cron-secret'; + +function makeRequest(url: string, authorization?: string): Request { + return new Request(url, { headers: authorization ? { authorization } : {} }); +} + +function sentTo( + email: string, +): { to: string; subject: string; html: string } | undefined { + return mockSend.mock.calls + .map(([args]) => args as { to: string; subject: string; html: string }) + .find((args) => args.to === email); +} + +let creator: User; + +beforeAll(async () => { + vi.stubEnv('RESEND_API_KEY', 'test-key'); + vi.stubEnv('RESEND_FROM_EMAIL', 'noreply@example.com'); + vi.stubEnv('CRON_SECRET', CRON_SECRET); + creator = await createTestUser({ isAdmin: true }); +}); + +afterAll(async () => { + vi.unstubAllEnvs(); + await cleanupFixtures(); +}); + +beforeEach(() => { + mockSend.mockReset(); + mockSend.mockResolvedValue({ data: { id: randomUUID() }, error: null }); +}); + +describe('cron authorization', () => { + it('rejects a request with no Authorization header on both routes', async () => { + const dailyRes = await dailyGET(makeRequest(DAILY_URL)); + expect(dailyRes.status).toBe(401); + expect(await dailyRes.json()).toEqual({ error: 'Unauthorized' }); + + const weeklyRes = await weeklyGET(makeRequest(WEEKLY_URL)); + expect(weeklyRes.status).toBe(401); + expect(await weeklyRes.json()).toEqual({ error: 'Unauthorized' }); + + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('rejects a request with the wrong bearer token on both routes', async () => { + const dailyRes = await dailyGET(makeRequest(DAILY_URL, 'Bearer wrong')); + expect(dailyRes.status).toBe(401); + + const weeklyRes = await weeklyGET(makeRequest(WEEKLY_URL, 'Bearer wrong')); + expect(weeklyRes.status).toBe(401); + + expect(mockSend).not.toHaveBeenCalled(); + }); +}); + +describe('daily digest', () => { + let manager: User; + let position1: Position; + let position2: Position; + + beforeEach(async () => { + manager = await createTestUser(); + position1 = await createTestPosition(creator, { managers: [manager] }); + position2 = await createTestPosition(creator, { managers: [manager] }); + }); + + it('sends one email covering two positions with recent applications, logged once', async () => { + const applicantA = await createTestUser(); + const applicantB = await createTestUser(); + await createTestApplication(applicantA, position1, {}); + await createTestApplication(applicantB, position1, {}); + await createTestApplication(applicantA, position2, {}); + + const res = await dailyGET(makeRequest(DAILY_URL, `Bearer ${CRON_SECRET}`)); + expect(res.status).toBe(200); + + const call = sentTo(manager.email); + expect(call).toBeDefined(); + expect(call?.subject).toBe('3 new applications across 2 positions'); + expect(call?.html).toContain(`?positionId=${position1.id}`); + expect(call?.html).toContain(`?positionId=${position2.id}`); + + const logs = await prisma.emailLog.findMany({ + where: { userId: manager.id, template: 'manager_daily_digest' }, + }); + expect(logs).toHaveLength(1); + expect(logs[0]?.applicationId).toBeNull(); + }); + + it('only looks back the fallback window for a manager never digested before', async () => { + const now = new Date(); + const tooOld = new Date(now.getTime() - 25 * 60 * 60 * 1000); + const withinLookback = new Date(now.getTime() - 23 * 60 * 60 * 1000); + + const applicantOld = await createTestUser(); + const applicantRecent = await createTestUser(); + await createTestApplication(applicantOld, position1, { + submittedAt: tooOld, + }); + await createTestApplication(applicantRecent, position1, { + submittedAt: withinLookback, + }); + + const res = await dailyGET(makeRequest(DAILY_URL, `Bearer ${CRON_SECRET}`)); + expect(res.status).toBe(200); + + const call = sentTo(manager.email); + expect(call).toBeDefined(); + expect(call?.subject).toBe(`1 new application for ${position1.title}`); + }); + + it('sends nothing to a manager with no new activity', async () => { + const res = await dailyGET(makeRequest(DAILY_URL, `Bearer ${CRON_SECRET}`)); + expect(res.status).toBe(200); + expect(sentTo(manager.email)).toBeUndefined(); + }); + + it('finds nothing new when called again immediately after a successful send', async () => { + const applicant = await createTestUser(); + await createTestApplication(applicant, position1, {}); + + const first = await dailyGET( + makeRequest(DAILY_URL, `Bearer ${CRON_SECRET}`), + ); + expect(first.status).toBe(200); + expect(sentTo(manager.email)).toBeDefined(); + + mockSend.mockClear(); + + const second = await dailyGET( + makeRequest(DAILY_URL, `Bearer ${CRON_SECRET}`), + ); + const body = (await second.json()) as { skipped: number }; + expect(body.skipped).toBeGreaterThanOrEqual(1); + expect(sentTo(manager.email)).toBeUndefined(); + + const logs = await prisma.emailLog.findMany({ + where: { userId: manager.id, template: 'manager_daily_digest' }, + }); + expect(logs).toHaveLength(1); + }); + + it('still reports an application submitted after the previous send, even later the same day', async () => { + const firstApplicant = await createTestUser(); + await createTestApplication(firstApplicant, position1, {}); + + const first = await dailyGET( + makeRequest(DAILY_URL, `Bearer ${CRON_SECRET}`), + ); + expect(first.status).toBe(200); + expect(sentTo(manager.email)).toBeDefined(); + + mockSend.mockClear(); + + const secondApplicant = await createTestUser(); + await createTestApplication(secondApplicant, position1, {}); + + const second = await dailyGET( + makeRequest(DAILY_URL, `Bearer ${CRON_SECRET}`), + ); + expect(second.status).toBe(200); + const call = sentTo(manager.email); + expect(call).toBeDefined(); + expect(call?.subject).toBe(`1 new application for ${position1.title}`); + }); + + it('does not stop the run when one recipient send fails, and records it as failed', async () => { + const applicant = await createTestUser(); + await createTestApplication(applicant, position1, {}); + + const managerOk = await createTestUser(); + const positionOk = await createTestPosition(creator, { + managers: [managerOk], + }); + const applicantOk = await createTestUser(); + await createTestApplication(applicantOk, positionOk, {}); + + mockSend.mockImplementation(async (args: { to: string }) => { + if (args.to === manager.email) throw new Error('simulated send failure'); + return { data: { id: randomUUID() }, error: null }; + }); + + const res = await dailyGET(makeRequest(DAILY_URL, `Bearer ${CRON_SECRET}`)); + const body = (await res.json()) as { sent: number; failed: number }; + expect(body.failed).toBeGreaterThanOrEqual(1); + expect(sentTo(managerOk.email)).toBeDefined(); + + const failedLogs = await prisma.emailLog.findMany({ + where: { + userId: manager.id, + template: 'manager_daily_digest', + status: 'failed', + }, + }); + expect(failedLogs).toHaveLength(1); + }); + + it('excludes a deactivated manager and a manager who only manages a draft position', async () => { + const deactivatedManager = await createTestUser({ deletedAt: new Date() }); + const deactivatedManagerPosition = await createTestPosition(creator, { + managers: [deactivatedManager], + }); + const applicantA = await createTestUser(); + await createTestApplication(applicantA, deactivatedManagerPosition, {}); + + const draftOnlyManager = await createTestUser(); + const draftPosition = await createTestPosition(creator, { + managers: [draftOnlyManager], + status: 'draft', + }); + const applicantB = await createTestUser(); + await createTestApplication(applicantB, draftPosition, {}); + + const res = await dailyGET(makeRequest(DAILY_URL, `Bearer ${CRON_SECRET}`)); + expect(res.status).toBe(200); + expect(sentTo(deactivatedManager.email)).toBeUndefined(); + expect(sentTo(draftOnlyManager.email)).toBeUndefined(); + }); +}); + +describe('weekly digest', () => { + let manager: User; + let openPosition: Position; + let closedByDatePosition: Position; + + beforeEach(async () => { + manager = await createTestUser(); + openPosition = await createTestPosition(creator, { managers: [manager] }); + closedByDatePosition = await createTestPosition(creator, { + managers: [manager], + closesAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + }); + }); + + it('sends one email with a status breakdown excluding terminal/withdrawn/draft, and open positions excluding a closed-by-date one', async () => { + const applicantApplied = await createTestUser(); + const applicantReviewing = await createTestUser(); + const applicantAccepted = await createTestUser(); + const applicantWithdrawn = await createTestUser(); + const applicantDraft = await createTestUser(); + + await createTestApplication(applicantApplied, openPosition, { + status: 'applied', + }); + await createTestApplication(applicantReviewing, openPosition, { + status: 'reviewing', + }); + await createTestApplication(applicantAccepted, closedByDatePosition, { + status: 'accepted', + }); + await createTestApplication(applicantWithdrawn, openPosition, { + status: 'withdrawn', + }); + await createTestApplication(applicantDraft, openPosition, { + status: 'draft', + }); + + const res = await weeklyGET( + makeRequest(WEEKLY_URL, `Bearer ${CRON_SECRET}`), + ); + expect(res.status).toBe(200); + + const call = sentTo(manager.email); + expect(call).toBeDefined(); + expect(call?.subject).toBe('2 applications awaiting your review'); + expect(call?.html).toContain('?status=applied'); + expect(call?.html).toContain('?status=reviewing'); + expect(call?.html).not.toContain('?status=accepted'); + expect(call?.html).toContain(`?positionId=${openPosition.id}`); + expect(call?.html).not.toContain(`?positionId=${closedByDatePosition.id}`); + + const logs = await prisma.emailLog.findMany({ + where: { userId: manager.id, template: 'manager_weekly_digest' }, + }); + expect(logs).toHaveLength(1); + expect(logs[0]?.applicationId).toBeNull(); + }); + + it('sends nothing when there is nothing unresolved', async () => { + const applicant = await createTestUser(); + await createTestApplication(applicant, openPosition, { + status: 'accepted', + }); + + const res = await weeklyGET( + makeRequest(WEEKLY_URL, `Bearer ${CRON_SECRET}`), + ); + expect(res.status).toBe(200); + expect(sentTo(manager.email)).toBeUndefined(); + }); + + it('sends the reminder regardless of how long an application has been unresolved', async () => { + const applicant = await createTestUser(); + const longAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + await createTestApplication(applicant, openPosition, { + submittedAt: longAgo, + status: 'applied', + }); + + const res = await weeklyGET( + makeRequest(WEEKLY_URL, `Bearer ${CRON_SECRET}`), + ); + expect(res.status).toBe(200); + + const call = sentTo(manager.email); + expect(call).toBeDefined(); + expect(call?.subject).toBe('1 application awaiting your review'); + }); + + it('gates a repeat call the same week', async () => { + const applicant = await createTestUser(); + await createTestApplication(applicant, openPosition, { status: 'applied' }); + + const first = await weeklyGET( + makeRequest(WEEKLY_URL, `Bearer ${CRON_SECRET}`), + ); + expect(first.status).toBe(200); + expect(sentTo(manager.email)).toBeDefined(); + + mockSend.mockClear(); + + const second = await weeklyGET( + makeRequest(WEEKLY_URL, `Bearer ${CRON_SECRET}`), + ); + const body = (await second.json()) as { skipped: number }; + expect(body.skipped).toBeGreaterThanOrEqual(1); + expect(sentTo(manager.email)).toBeUndefined(); + }); + + it('is not gated by an existing daily-digest row for the same manager', async () => { + const applicant = await createTestUser(); + await createTestApplication(applicant, openPosition, { status: 'applied' }); + + await prisma.emailLog.create({ + data: { + to: manager.email, + userId: manager.id, + template: 'manager_daily_digest', + subject: `${TEST_PREFIX}unrelated daily digest`, + status: 'sent', + }, + }); + + const res = await weeklyGET( + makeRequest(WEEKLY_URL, `Bearer ${CRON_SECRET}`), + ); + expect(res.status).toBe(200); + expect(sentTo(manager.email)).toBeDefined(); + }); +}); diff --git a/tests/unit/dates.test.ts b/tests/unit/dates.test.ts index a8b09077..6940cee1 100644 --- a/tests/unit/dates.test.ts +++ b/tests/unit/dates.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it } from 'vitest'; import { + currentOrgWeekStart, formatInstant, formatRelativeTime, orgDayEnd, orgDayStart, + previousOrgDay, toOrgDayString, } from '@/lib/dates'; +function noonOn(day: string): Date { + return new Date(orgDayStart(day).getTime() + 12 * 60 * 60 * 1000); +} + describe('orgDayStart', () => { it('resolves a summer day to EDT (UTC-4)', () => { expect(orgDayStart('2026-06-30').toISOString()).toBe( @@ -82,6 +88,36 @@ describe('toOrgDayString', () => { }); }); +describe('previousOrgDay', () => { + it('returns the org day before now, across the spring-forward transition', () => { + const { day, start, end } = previousOrgDay(noonOn('2026-03-09')); + expect(day).toBe('2026-03-08'); + expect(start).toEqual(orgDayStart('2026-03-08')); + expect(end).toEqual(orgDayEnd('2026-03-08')); + }); + + it('returns the org day before now, across the fall-back transition', () => { + const { day, start, end } = previousOrgDay(noonOn('2026-11-02')); + expect(day).toBe('2026-11-01'); + expect(start).toEqual(orgDayStart('2026-11-01')); + expect(end).toEqual(orgDayEnd('2026-11-01')); + }); + + it('rolls back across a month boundary', () => { + expect(previousOrgDay(noonOn('2026-04-01')).day).toBe('2026-03-31'); + }); +}); + +describe('currentOrgWeekStart', () => { + it('resolves a Sunday to the Monday that started its week', () => { + expect(currentOrgWeekStart(noonOn('2026-03-08'))).toBe('2026-03-02'); + }); + + it('resolves a Monday to itself', () => { + expect(currentOrgWeekStart(noonOn('2026-03-09'))).toBe('2026-03-09'); + }); +}); + describe('formatInstant', () => { const date = new Date('2026-06-30T23:30:00.000Z'); diff --git a/tests/unit/email-templates.test.ts b/tests/unit/email-templates.test.ts index c2c55062..7aba4737 100644 --- a/tests/unit/email-templates.test.ts +++ b/tests/unit/email-templates.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest'; import { APPLICANT_EMAIL_FOOTER, + MANAGER_EMAIL_FOOTER, applicationDecisionEmail, applicationReceivedEmail, emailLayout, escapeHtml, + managerDailyDigestEmail, + managerWeeklyDigestEmail, otpEmail, } from '@/lib/email/templates'; @@ -175,3 +178,151 @@ describe('applicationDecisionEmail', () => { expect(result.html).toContain(APPLICANT_EMAIL_FOOTER); }); }); + +describe('managerDailyDigestEmail', () => { + it('singularizes the subject and body at exactly 1', () => { + const result = managerDailyDigestEmail({ + firstName: 'Jane', + since: new Date('2026-03-02T14:00:00.000Z'), + positions: [ + { positionId: 'pos-1', title: 'Treasurer', newApplications: 1 }, + ], + total: 1, + }); + expect(result.subject).toBe('1 new application for Treasurer'); + expect(result.html).toContain('1 new application<'); + }); + + it('names the single position in the subject, pluralized', () => { + const result = managerDailyDigestEmail({ + since: new Date('2026-03-02T14:00:00.000Z'), + positions: [ + { positionId: 'pos-1', title: 'Treasurer', newApplications: 3 }, + ], + total: 3, + }); + expect(result.subject).toBe('3 new applications for Treasurer'); + }); + + it('summarizes across positions in the subject when there is more than one', () => { + const result = managerDailyDigestEmail({ + since: new Date('2026-03-02T14:00:00.000Z'), + positions: [ + { positionId: 'pos-1', title: 'Senator', newApplications: 3 }, + { positionId: 'pos-2', title: 'Treasurer', newApplications: 2 }, + ], + total: 5, + }); + expect(result.subject).toBe('5 new applications across 2 positions'); + }); + + it('escapes a dangerous position title in the html but leaves the subject raw', () => { + const result = managerDailyDigestEmail({ + since: new Date('2026-03-02T14:00:00.000Z'), + positions: [ + { positionId: 'pos-1', title: DANGEROUS_TITLE, newApplications: 1 }, + ], + total: 1, + }); + expect(result.subject).toBe(`1 new application for ${DANGEROUS_TITLE}`); + expect(result.html).toContain(escapeHtml(DANGEROUS_TITLE)); + expect(result.html).not.toContain(DANGEROUS_TITLE); + expect(result.text).toContain(DANGEROUS_TITLE); + }); + + it('links every position row to its own positionId', () => { + const result = managerDailyDigestEmail({ + since: new Date('2026-03-02T14:00:00.000Z'), + positions: [ + { positionId: 'pos-1', title: 'Senator', newApplications: 3 }, + { positionId: 'pos-2', title: 'Treasurer', newApplications: 2 }, + ], + total: 5, + }); + expect(result.html).toContain('?positionId=pos-1'); + expect(result.html).toContain('?positionId=pos-2'); + expect(result.text).toContain('?positionId=pos-1'); + expect(result.text).toContain('?positionId=pos-2'); + }); + + it('names the exact since-instant and includes the manager footer', () => { + const result = managerDailyDigestEmail({ + since: new Date('2026-03-02T14:00:00.000Z'), + positions: [ + { positionId: 'pos-1', title: 'Treasurer', newApplications: 1 }, + ], + total: 1, + }); + expect(result.html).toContain('Mar 2, 2026, 9:00 AM EST'); + expect(result.html).toContain(MANAGER_EMAIL_FOOTER); + }); +}); + +describe('managerWeeklyDigestEmail', () => { + const base = { asOfDay: '2026-03-09', openPositions: [] }; + + it('sums the unresolved statuses into the subject, pluralized', () => { + const result = managerWeeklyDigestEmail({ + ...base, + statusCounts: [ + { status: 'applied', count: 7 }, + { status: 'reviewing', count: 3 }, + ], + }); + expect(result.subject).toBe('10 applications awaiting your review'); + }); + + it('singularizes the subject at exactly 1', () => { + const result = managerWeeklyDigestEmail({ + ...base, + statusCounts: [{ status: 'applied', count: 1 }], + }); + expect(result.subject).toBe('1 application awaiting your review'); + }); + + it('renders a stat box per status, linked by status value', () => { + const result = managerWeeklyDigestEmail({ + ...base, + statusCounts: [ + { status: 'applied', count: 7 }, + { status: 'reviewing', count: 3 }, + ], + }); + expect(result.html).toContain('?status=applied'); + expect(result.html).toContain('?status=reviewing'); + expect(result.html).toContain('>7<'); + expect(result.html).toContain('>3<'); + expect(result.html).toContain('Applied'); + expect(result.html).toContain('Reviewing'); + }); + + it('shows the empty open-positions line with none open', () => { + const result = managerWeeklyDigestEmail({ + ...base, + statusCounts: [{ status: 'applied', count: 1 }], + }); + expect(result.html).toContain('You have no positions open right now.'); + }); + + it('links open positions by positionId', () => { + const result = managerWeeklyDigestEmail({ + ...base, + statusCounts: [{ status: 'applied', count: 1 }], + openPositions: [ + { positionId: 'pos-1', title: 'Senator' }, + { positionId: 'pos-2', title: 'Treasurer' }, + ], + }); + expect(result.html).toContain('?positionId=pos-1'); + expect(result.html).toContain('?positionId=pos-2'); + }); + + it('includes the as-of date and the manager footer', () => { + const result = managerWeeklyDigestEmail({ + ...base, + statusCounts: [{ status: 'applied', count: 1 }], + }); + expect(result.html).toContain('Mar 9, 2026'); + expect(result.html).toContain(MANAGER_EMAIL_FOOTER); + }); +}); diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index 7ba3b78f..ebd9cd83 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -1690,14 +1690,14 @@ describe('getEmailLogDescription', () => { expect( getEmailLogDescription({ status: 'bounced', bounceType: 'Permanent' }), ).toBe( - 'The address rejected it permanently — the applicant did not receive this.', + 'The address rejected it permanently — the recipient did not receive this.', ); }); it('describes a transient bounce', () => { expect( getEmailLogDescription({ status: 'bounced', bounceType: 'Transient' }), - ).toBe('Temporarily undeliverable — the applicant did not receive this.'); + ).toBe('Temporarily undeliverable — the recipient did not receive this.'); }); it('describes a bounce with no bounceType', () => { diff --git a/vercel.json b/vercel.json index 149fcce3..f0ea9074 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,8 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", - "buildCommand": "if [ \"$VERCEL_ENV\" = \"preview\" ]; then DATABASE_URL=\"$DATABASE_URL_UNPOOLED\" npm run prisma:migrate:deploy; fi && npm run build" + "buildCommand": "if [ \"$VERCEL_ENV\" = \"preview\" ]; then DATABASE_URL=\"$DATABASE_URL_UNPOOLED\" npm run prisma:migrate:deploy; fi && npm run build", + "crons": [ + { "path": "/api/cron/manager-daily-digest", "schedule": "0 12 * * *" }, + { "path": "/api/cron/manager-weekly-digest", "schedule": "0 13 * * 1" } + ] }