From 08aa1ee18ed4f5d3abb22698e3981714e6a8086a Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Fri, 11 Sep 2026 23:48:01 -0400 Subject: [PATCH 01/35] #549 rename manager_digest to manager_daily_digest, add weekly manager_digest shipped in #547 but nothing ever wrote it. Renaming it now and adding manager_weekly_digest is data-preserving since the column is still unused. Co-Authored-By: Claude Sonnet 4.6 --- lib/constants.ts | 11 ++++++++--- lib/utils.ts | 4 ++-- .../migration.sql | 2 ++ prisma/schema.prisma | 3 ++- tests/db/email-log-queries.test.ts | 11 +++++++---- tests/unit/utils.test.ts | 4 ++-- 6 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 prisma/migrations/20260911222818_manager_digest_cadences/migration.sql diff --git a/lib/constants.ts b/lib/constants.ts index 41f72d40..91d10135 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -418,7 +418,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.', @@ -1123,9 +1123,13 @@ 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; + export const EMAIL_STATUS_LABELS: Record<$Enums.EmailStatus, string> = { scheduled: 'Scheduled', sent: 'Sent', @@ -1157,7 +1161,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/utils.ts b/lib/utils.ts index a3b0b553..de6bd0b5 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -93,9 +93,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/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 d8f89a88..3e807969 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/unit/utils.test.ts b/tests/unit/utils.test.ts index 40f57241..af7c75c1 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -1629,14 +1629,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', () => { From e7fd2d6b54be1e42bafc10d9fe4bc705f0083ff7 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Fri, 11 Sep 2026 23:48:05 -0400 Subject: [PATCH 02/35] #549 add org week date helpers previousOrgDay, currentOrgWeekStart and previousOrgWeek do their arithmetic on the org calendar date, never a resolved instant, so a DST transition inside the shifted range can't shift the day count. Co-Authored-By: Claude Sonnet 4.6 --- lib/dates.ts | 48 +++++++++++++++++++++++++++++++ tests/unit/dates.test.ts | 61 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/lib/dates.ts b/lib/dates.ts index 4092230b..75b460cd 100644 --- a/lib/dates.ts +++ b/lib/dates.ts @@ -73,6 +73,54 @@ 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); +} + +/** The Monday–Sunday org week before `now`'s current week, with its instant bounds. */ +export function previousOrgWeek(now: Date): { + startDay: string; + endDay: string; + start: Date; + end: Date; +} { + const startDay = shiftOrgDay(currentOrgWeekStart(now), -7); + const endDay = shiftOrgDay(startDay, 6); + return { + startDay, + endDay, + start: orgDayStart(startDay), + end: orgDayEnd(endDay), + }; +} + export function formatInstant( date: Date, { precision, timeZone }: { precision: DatePrecision; timeZone: string }, diff --git a/tests/unit/dates.test.ts b/tests/unit/dates.test.ts index a8b09077..9f90eb66 100644 --- a/tests/unit/dates.test.ts +++ b/tests/unit/dates.test.ts @@ -1,13 +1,20 @@ import { describe, expect, it } from 'vitest'; import { + currentOrgWeekStart, formatInstant, formatRelativeTime, orgDayEnd, orgDayStart, + previousOrgDay, + previousOrgWeek, 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 +89,60 @@ 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('previousOrgWeek', () => { + it('spans a full Monday-Sunday week before the current one', () => { + const week = previousOrgWeek(noonOn('2026-03-23')); + expect(week.startDay).toBe('2026-03-16'); + expect(week.endDay).toBe('2026-03-22'); + expect(week.start).toEqual(orgDayStart('2026-03-16')); + expect(week.end).toEqual(orgDayEnd('2026-03-22')); + }); + + it('still spans exactly 7 calendar days across a DST change', () => { + // now's current week is 2026-03-09..15, so the previous week is + // 2026-03-02..08 — the Sunday it ends on is the spring-forward day. + const week = previousOrgWeek(noonOn('2026-03-09')); + expect(week.startDay).toBe('2026-03-02'); + expect(week.endDay).toBe('2026-03-08'); + + // Spring-forward inside the week loses an hour, so the instant span is + // 167 hours, not 168 — the calendar-day count is still exactly 7. + const hours = + (week.end.getTime() - week.start.getTime() + 1) / (1000 * 60 * 60); + expect(hours).toBeCloseTo(167, 5); + }); +}); + describe('formatInstant', () => { const date = new Date('2026-06-30T23:30:00.000Z'); From 8b1a547db2d22ba066b4c3f3f2dedd6f40ef4606 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Fri, 11 Sep 2026 23:48:11 -0400 Subject: [PATCH 03/35] #549 add manager digest recipient queries and templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDailyDigestRecipients/getWeeklyDigestRecipients compute recipients from EmailLog alone (no new column) — a manager already digested inside the current calendar day/week is dropped and counted as skipped, so a repeat cron fire is a no-op. Co-Authored-By: Claude Sonnet 4.6 --- lib/email/templates.ts | 208 +++++++++++++++++++++++ lib/types.ts | 31 ++++ prisma/data/digests.ts | 263 +++++++++++++++++++++++++++++ tests/unit/email-templates.test.ts | 176 +++++++++++++++++++ 4 files changed, 678 insertions(+) create mode 100644 prisma/data/digests.ts diff --git a/lib/email/templates.ts b/lib/email/templates.ts index 28c39421..f8592b83 100644 --- a/lib/email/templates.ts +++ b/lib/email/templates.ts @@ -1,6 +1,12 @@ import 'server-only'; import { getBaseUrl } from '@/lib/base-url'; +import { APPLICATION_STATUS_LABELS, ORG_TIMEZONE } from '@/lib/constants'; +import { orgDayStart } from '@/lib/dates'; +import { + type ManagerDigestPosition, + type WeeklyDigestStatusCount, +} from '@/lib/types'; // Inline styles throughout: email clients ignore Tailwind classes. @@ -248,3 +254,205 @@ 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)); +} + +function formatDigestWeekRange(startDay: string, endDay: string): string { + const startLabel = new Intl.DateTimeFormat('en-US', { + timeZone: ORG_TIMEZONE, + month: 'short', + day: 'numeric', + }).format(orgDayStart(startDay)); + const endLabel = new Intl.DateTimeFormat('en-US', { + timeZone: ORG_TIMEZONE, + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(orgDayStart(endDay)); + return `${startLabel} – ${endLabel}`; +} + +export interface ManagerDailyDigestEmailOptions { + firstName?: string; + day: string; + positions: ManagerDigestPosition[]; + total: number; +} + +export function managerDailyDigestEmail({ + firstName, + day, + positions, + total, +}: ManagerDailyDigestEmailOptions): EmailTemplate { + const baseUrl = getBaseUrl(); + const allApplicationsUrl = `${baseUrl}/manage/applications`; + const dayLabel = formatDigestDay(day); + 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, from ${escapeHtml(dayLabel)}.

+
${positionRows}
+ ${primaryButton(allApplicationsUrl, 'Review all applications')} + `; + + const text = [ + greeting(firstName), + '', + `New applications on the positions you manage, from ${dayLabel}.`, + '', + ...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, + }; +} + +export interface ManagerWeeklyDigestEmailOptions { + firstName?: string; + weekStart: string; + weekEnd: string; + newApplications: number; + statusCounts: WeeklyDigestStatusCount[]; + openPositions: Pick[]; +} + +export function managerWeeklyDigestEmail({ + firstName, + weekStart, + weekEnd, + newApplications, + statusCounts, + openPositions, +}: ManagerWeeklyDigestEmailOptions): EmailTemplate { + const baseUrl = getBaseUrl(); + const allApplicationsUrl = `${baseUrl}/manage/applications`; + const weekRangeLabel = formatDigestWeekRange(weekStart, weekEnd); + const safeGreeting = escapeHtml(greeting(firstName)); + const unresolvedTotal = statusCounts.reduce( + (sum, entry) => sum + entry.count, + 0, + ); + + const subject = + newApplications > 0 + ? `Your week on Aplio: ${newApplications} new ${pluralize(newApplications, 'application')}` + : `Your week on Aplio: ${unresolvedTotal} ${pluralize(unresolvedTotal, 'application')} awaiting review`; + + const newThisWeekLine = + newApplications > 0 + ? `${newApplications} new ${pluralize(newApplications, 'application')} across the positions you manage.` + : 'No new applications this week.'; + + const statusRows = + statusCounts.length > 0 + ? statusCounts + .map((entry) => { + const url = `${allApplicationsUrl}?status=${entry.status}`; + return `${escapeHtml(APPLICATION_STATUS_LABELS[entry.status])} — ${entry.count}`; + }) + .join('
') + : 'No applications on your positions yet.'; + + 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}

+

Your weekly summary for ${escapeHtml(weekRangeLabel)}.

+

New this week

+

${escapeHtml(newThisWeekLine)}

+

Where things stand

+

${statusRows}

+

Your open positions

+

${openPositionsLine}

+ ${primaryButton(allApplicationsUrl, 'Review all applications')} + `; + + const text = [ + greeting(firstName), + '', + `Your weekly summary for ${weekRangeLabel}.`, + '', + 'New this week', + newThisWeekLine, + '', + 'Where things stand', + statusCounts.length > 0 + ? statusCounts + .map( + (entry) => + `${APPLICATION_STATUS_LABELS[entry.status]} (${allApplicationsUrl}?status=${entry.status}) — ${entry.count}`, + ) + .join('\n') + : 'No applications on your positions yet.', + '', + '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 f4983db7..c375b33e 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -527,3 +527,34 @@ 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; + day: string; + positions: ManagerDigestPosition[]; + total: number; +}; + +export type WeeklyDigestStatusCount = { + status: $Enums.ApplicationStatus; + count: number; +}; + +export type WeeklyDigestRecipient = { + userId: string; + email: string; + name: string | null; + weekStart: string; + weekEnd: string; + newApplications: number; + statusCounts: WeeklyDigestStatusCount[]; + openPositions: Pick[]; +}; diff --git a/prisma/data/digests.ts b/prisma/data/digests.ts new file mode 100644 index 00000000..43fde2c9 --- /dev/null +++ b/prisma/data/digests.ts @@ -0,0 +1,263 @@ +import 'server-only'; + +import { type $Enums } from '@/prisma/client'; + +import { + NON_REVIEWABLE_APPLICATION_STATUSES, + PUBLISHED_POSITION_WHERE, + REVIEWER_APPLICATION_STATUSES, + UNRESOLVED_APPLICATION_STATUSES, +} from '@/lib/constants'; +import { + currentOrgWeekStart, + orgDayStart, + previousOrgDay, + previousOrgWeek, + 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, + }, + }, + }, + }); +} + +async function tallyNewApplications( + positionIds: string[], + start: Date, + end: Date, +): Promise> { + if (positionIds.length === 0) return new Map(); + + const rows = await prisma.application.groupBy({ + by: ['positionId'], + where: { + positionId: { in: positionIds }, + deletedAt: null, + status: { notIn: NON_REVIEWABLE_APPLICATION_STATUSES }, + submittedAt: { gte: start, lte: end }, + position: PUBLISHED_POSITION_WHERE, + }, + _count: true, + }); + + return new Map(rows.map((row) => [row.positionId, row._count])); +} + +async function tallyStatusBreakdown( + positionIds: string[], +): 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: { notIn: NON_REVIEWABLE_APPLICATION_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 at least one new application on a managed position over the + * previous org calendar day, gated so a manager already digested today is + * dropped (and counted in `skipped`) rather than emailed twice. + */ +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 { day, start, end } = previousOrgDay(now); + + const alreadyDigested = await prisma.emailLog.findMany({ + where: { + template: 'manager_daily_digest', + userId: { in: managerIds }, + createdAt: { gte: orgDayStart(toOrgDayString(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 tallies = await tallyNewApplications(positionIds, start, end); + + const recipients: DailyDigestRecipient[] = []; + for (const manager of candidates) { + const positions: ManagerDigestPosition[] = manager.managedPositions + .map((position) => ({ + positionId: position.id, + title: position.title, + newApplications: tallies.get(position.id) ?? 0, + })) + .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) continue; + + recipients.push({ + userId: manager.id, + email: manager.email, + name: manager.name, + day, + positions, + total, + }); + } + + return { recipients, skipped: gatedIds.size }; +} + +/** + * Managers with new applications or anything unresolved on a managed + * position over the previous org Monday-Sunday week. Same per-period gate + * shape as the daily digest, keyed to `manager_weekly_digest` rows. + */ +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 { startDay, endDay, start, end } = previousOrgWeek(now); + + 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 [newTallies, statusTallies] = await Promise.all([ + tallyNewApplications(positionIds, start, end), + tallyStatusBreakdown(positionIds), + ]); + + const unresolvedStatuses: readonly $Enums.ApplicationStatus[] = + UNRESOLVED_APPLICATION_STATUSES; + + const recipients: WeeklyDigestRecipient[] = []; + for (const manager of candidates) { + const managerPositionIds = manager.managedPositions.map( + (position) => position.id, + ); + + const newApplications = managerPositionIds.reduce( + (sum, id) => sum + (newTallies.get(id) ?? 0), + 0, + ); + + const statusCounts = REVIEWER_APPLICATION_STATUSES.map((status) => ({ + status, + count: managerPositionIds.reduce( + (sum, id) => sum + (statusTallies.get(id)?.get(status) ?? 0), + 0, + ), + })).filter((entry) => entry.count > 0); + + const unresolvedTotal = statusCounts + .filter((entry) => unresolvedStatuses.includes(entry.status)) + .reduce((sum, entry) => sum + entry.count, 0); + + if (newApplications === 0 && unresolvedTotal === 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, + weekStart: startDay, + weekEnd: endDay, + newApplications, + statusCounts, + openPositions, + }); + } + + return { recipients, skipped: gatedIds.size }; +} diff --git a/tests/unit/email-templates.test.ts b/tests/unit/email-templates.test.ts index c2c55062..d613002b 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,176 @@ 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', + day: '2026-03-02', + 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({ + day: '2026-03-02', + 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({ + day: '2026-03-02', + 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({ + day: '2026-03-02', + 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({ + day: '2026-03-02', + 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 org day and includes the manager footer', () => { + const result = managerDailyDigestEmail({ + day: '2026-03-02', + positions: [ + { positionId: 'pos-1', title: 'Treasurer', newApplications: 1 }, + ], + total: 1, + }); + expect(result.html).toContain('Mar 2, 2026'); + expect(result.html).toContain(MANAGER_EMAIL_FOOTER); + }); +}); + +describe('managerWeeklyDigestEmail', () => { + const base = { + weekStart: '2026-03-02', + weekEnd: '2026-03-08', + openPositions: [], + }; + + it('uses the new-applications subject when there is new activity', () => { + const result = managerWeeklyDigestEmail({ + ...base, + newApplications: 12, + statusCounts: [], + }); + expect(result.subject).toBe('Your week on Aplio: 12 new applications'); + }); + + it('singularizes the new-applications subject at exactly 1', () => { + const result = managerWeeklyDigestEmail({ + ...base, + newApplications: 1, + statusCounts: [], + }); + expect(result.subject).toBe('Your week on Aplio: 1 new application'); + }); + + it('falls back to the awaiting-review subject with no new applications', () => { + const result = managerWeeklyDigestEmail({ + ...base, + newApplications: 0, + statusCounts: [{ status: 'applied', count: 7 }], + }); + expect(result.subject).toBe( + 'Your week on Aplio: 7 applications awaiting review', + ); + }); + + it('omits zero-count statuses and links each remaining status', () => { + const result = managerWeeklyDigestEmail({ + ...base, + newApplications: 0, + statusCounts: [ + { status: 'applied', count: 7 }, + { status: 'accepted', count: 3 }, + ], + }); + expect(result.html).toContain('?status=applied'); + expect(result.html).toContain('?status=accepted'); + expect(result.html).not.toContain('?status=rejected'); + }); + + it('shows the empty statuses line when nothing is unresolved but new applications exist', () => { + const result = managerWeeklyDigestEmail({ + ...base, + newApplications: 4, + statusCounts: [], + }); + expect(result.html).toContain('No applications on your positions yet.'); + }); + + it('shows the empty open-positions line with none open', () => { + const result = managerWeeklyDigestEmail({ + ...base, + newApplications: 4, + statusCounts: [], + }); + expect(result.html).toContain('You have no positions open right now.'); + }); + + it('links open positions by positionId', () => { + const result = managerWeeklyDigestEmail({ + ...base, + newApplications: 4, + statusCounts: [], + 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 week range and the manager footer', () => { + const result = managerWeeklyDigestEmail({ + ...base, + newApplications: 4, + statusCounts: [], + }); + expect(result.html).toContain('Mar 2'); + expect(result.html).toContain('Mar 8, 2026'); + expect(result.html).toContain(MANAGER_EMAIL_FOOTER); + }); +}); From 09653ccd382368ba622a288722befe806f396ef6 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Fri, 11 Sep 2026 23:48:20 -0400 Subject: [PATCH 04/35] #549 add manager digest cron routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two static routes, not one route with a mode param — each names and runs exactly the one digest its path names, so it stays a falsifiable allowlist line rather than a job runner. Both reject any request whose Authorization header isn't the CRON_SECRET bearer token before touching the database. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 4 + README.md | 19 +- app/api/cron/manager-daily-digest/route.ts | 11 + app/api/cron/manager-weekly-digest/route.ts | 11 + lib/cron.ts | 14 + lib/email/manager-digests.ts | 93 +++++ tests/db/manager-digests.test.ts | 374 ++++++++++++++++++++ vercel.json | 6 +- 8 files changed, 522 insertions(+), 10 deletions(-) create mode 100644 app/api/cron/manager-daily-digest/route.ts create mode 100644 app/api/cron/manager-weekly-digest/route.ts create mode 100644 lib/cron.ts create mode 100644 lib/email/manager-digests.ts create mode 100644 tests/db/manager-digests.test.ts 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/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/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/email/manager-digests.ts b/lib/email/manager-digests.ts new file mode 100644 index 00000000..56713055 --- /dev/null +++ b/lib/email/manager-digests.ts @@ -0,0 +1,93 @@ +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), + day: recipient.day, + 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), + weekStart: recipient.weekStart, + weekEnd: recipient.weekEnd, + newApplications: recipient.newApplications, + 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/tests/db/manager-digests.test.ts b/tests/db/manager-digests.test.ts new file mode 100644 index 00000000..cef24389 --- /dev/null +++ b/tests/db/manager-digests.test.ts @@ -0,0 +1,374 @@ +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 { + orgDayStart, + previousOrgDay, + previousOrgWeek, + toOrgDayString, +} from '@/lib/dates'; +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 submitted yesterday, logged once', async () => { + const yesterday = previousOrgDay(new Date()); + const submittedAt = new Date(yesterday.start.getTime() + 60 * 60 * 1000); + + const applicantA = await createTestUser(); + const applicantB = await createTestUser(); + await createTestApplication(applicantA, position1, { submittedAt }); + await createTestApplication(applicantB, position1, { submittedAt }); + await createTestApplication(applicantA, position2, { submittedAt }); + + 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('excludes submissions from today and from two days ago', async () => { + const now = new Date(); + const todaySubmittedAt = new Date( + orgDayStart(toOrgDayString(now)).getTime() + 60 * 60 * 1000, + ); + const twoDaysAgo = previousOrgDay(previousOrgDay(now).start); + const twoDaysAgoSubmittedAt = new Date( + twoDaysAgo.start.getTime() + 60 * 60 * 1000, + ); + + const applicantToday = await createTestUser(); + const applicantTwoDaysAgo = await createTestUser(); + await createTestApplication(applicantToday, position1, { + submittedAt: todaySubmittedAt, + }); + await createTestApplication(applicantTwoDaysAgo, position1, { + submittedAt: twoDaysAgoSubmittedAt, + }); + + const res = await dailyGET(makeRequest(DAILY_URL, `Bearer ${CRON_SECRET}`)); + expect(res.status).toBe(200); + expect(sentTo(manager.email)).toBeUndefined(); + + const logs = await prisma.emailLog.findMany({ + where: { userId: manager.id, template: 'manager_daily_digest' }, + }); + expect(logs).toHaveLength(0); + }); + + 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('gates a repeat call the same day after a successful send', async () => { + const yesterday = previousOrgDay(new Date()); + const submittedAt = new Date(yesterday.start.getTime() + 60 * 60 * 1000); + const applicant = await createTestUser(); + await createTestApplication(applicant, position1, { submittedAt }); + + 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('excludes a deactivated manager and a manager who only manages a draft position', async () => { + const yesterday = previousOrgDay(new Date()); + const submittedAt = new Date(yesterday.start.getTime() + 60 * 60 * 1000); + + const deactivatedManager = await createTestUser({ deletedAt: new Date() }); + const deactivatedManagerPosition = await createTestPosition(creator, { + managers: [deactivatedManager], + }); + const applicantA = await createTestUser(); + await createTestApplication(applicantA, deactivatedManagerPosition, { + submittedAt, + }); + + const draftOnlyManager = await createTestUser(); + const draftPosition = await createTestPosition(creator, { + managers: [draftOnlyManager], + status: 'draft', + }); + const applicantB = await createTestUser(); + await createTestApplication(applicantB, draftPosition, { submittedAt }); + + 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 totals, a status breakdown excluding withdrawn/draft, and open positions excluding a closed-by-date one', async () => { + const week = previousOrgWeek(new Date()); + const submittedAt = new Date(week.start.getTime() + 60 * 60 * 1000); + + const applicantApplied = await createTestUser(); + const applicantReviewing = await createTestUser(); + const applicantAccepted = await createTestUser(); + const applicantWithdrawn = await createTestUser(); + const applicantDraft = await createTestUser(); + + await createTestApplication(applicantApplied, openPosition, { + submittedAt, + status: 'applied', + }); + await createTestApplication(applicantReviewing, openPosition, { + submittedAt, + status: 'reviewing', + }); + await createTestApplication(applicantAccepted, closedByDatePosition, { + submittedAt, + status: 'accepted', + }); + await createTestApplication(applicantWithdrawn, openPosition, { + submittedAt, + status: 'withdrawn', + }); + await createTestApplication(applicantDraft, openPosition, { + submittedAt, + 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('Your week on Aplio: 3 new applications'); + expect(call?.html).toContain('?status=applied'); + expect(call?.html).toContain('?status=reviewing'); + expect(call?.html).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 with zero new applications and nothing unresolved', async () => { + const res = await weeklyGET( + makeRequest(WEEKLY_URL, `Bearer ${CRON_SECRET}`), + ); + expect(res.status).toBe(200); + expect(sentTo(manager.email)).toBeUndefined(); + }); + + it('sends the awaiting-review subject with zero new applications but something unresolved', async () => { + const now = new Date(); + const twoWeeksAgo = previousOrgWeek(previousOrgWeek(now).start); + const submittedAt = new Date(twoWeeksAgo.start.getTime() + 60 * 60 * 1000); + const applicant = await createTestUser(); + await createTestApplication(applicant, openPosition, { + submittedAt, + 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( + 'Your week on Aplio: 1 application awaiting review', + ); + }); + + it('gates a repeat call the same week', async () => { + const week = previousOrgWeek(new Date()); + const submittedAt = new Date(week.start.getTime() + 60 * 60 * 1000); + const applicant = await createTestUser(); + await createTestApplication(applicant, openPosition, { + submittedAt, + 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 week = previousOrgWeek(new Date()); + const submittedAt = new Date(week.start.getTime() + 60 * 60 * 1000); + const applicant = await createTestUser(); + await createTestApplication(applicant, openPosition, { + submittedAt, + 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/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" } + ] } From eddcecccb2f7bbb6fa084a4ec4655064904804fa Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Fri, 11 Sep 2026 23:48:29 -0400 Subject: [PATCH 05/35] #549 amend api-route allowlist for the digest cron routes Appends both cron routes to CLAUDE.md's allowlist per #569's shape, and updates ENGINEERING/PERMISSIONS/WORKFLOWS for the new swallow site, the machine-authenticated route, and the two digest workflows. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 1 + docs/ENGINEERING.md | 1 + docs/PERMISSIONS.md | 1 + docs/WORKFLOWS.md | 17 ++++++++++++++--- 4 files changed, 17 insertions(+), 3 deletions(-) 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/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 c43a82ee..a0812f3f 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 57e4596a..f3c993c9 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) +**[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) **[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,6 +89,17 @@ 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, both windowed by the **org calendar**, not by a per-manager "since last send" — daily and weekly are independent cadences that never gate each other. + +- **Daily** (`manager_daily_digest`) — `GET /api/cron/manager-daily-digest`, Vercel Cron at `0 12 * * *` UTC (08:00 ET). Covers the previous org calendar day. 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=`); a manager with nothing new gets nothing. +- **Weekly** (`manager_weekly_digest`) — `GET /api/cron/manager-weekly-digest`, Vercel Cron at `0 13 * * 1` UTC, Mondays. Covers the previous Monday–Sunday org week: new applications, a status breakdown (`/manage/applications?status=` per row), and the manager's currently-open positions as context. Sent when there's anything new **or** anything still unresolved — an open position with zero new applications and zero unresolved is not itself a reason to send. +- **Idempotent per period, not per send.** Neither cadence has a stored "last sent" column — both are gated by whether an `EmailLog` row for that template already exists inside the current calendar day/week (`prisma/data/digests.ts`), so a double-fire is a no-op. 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.** @@ -500,7 +511,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 " ()". @@ -688,7 +699,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. From 3d2e4122e01fd32d125a4eabbcc2827de592e8b0 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Sat, 12 Sep 2026 00:04:12 -0400 Subject: [PATCH 06/35] #549 address review feedback Co-Authored-By: Claude Sonnet 4.6 --- lib/email/templates.ts | 4 ++++ prisma/data/digests.ts | 12 ++--------- tests/db/manager-digests.test.ts | 34 ++++++++++++++++++++++++++++++ tests/unit/email-templates.test.ts | 11 ++++++++++ 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/lib/email/templates.ts b/lib/email/templates.ts index f8592b83..a9021a81 100644 --- a/lib/email/templates.ts +++ b/lib/email/templates.ts @@ -272,10 +272,14 @@ function formatDigestDay(day: string): string { } function formatDigestWeekRange(startDay: string, endDay: string): string { + // Only the start label carries the year conditionally — a week spanning a + // year boundary would otherwise read ambiguously (Dec 29 – Jan 4, 2027). + const crossesYear = startDay.slice(0, 4) !== endDay.slice(0, 4); const startLabel = new Intl.DateTimeFormat('en-US', { timeZone: ORG_TIMEZONE, month: 'short', day: 'numeric', + ...(crossesYear ? { year: 'numeric' } : {}), }).format(orgDayStart(startDay)); const endLabel = new Intl.DateTimeFormat('en-US', { timeZone: ORG_TIMEZONE, diff --git a/prisma/data/digests.ts b/prisma/data/digests.ts index 43fde2c9..ea6e2106 100644 --- a/prisma/data/digests.ts +++ b/prisma/data/digests.ts @@ -110,11 +110,7 @@ async function tallyStatusBreakdown( return map; } -/** - * Managers with at least one new application on a managed position over the - * previous org calendar day, gated so a manager already digested today is - * dropped (and counted in `skipped`) rather than emailed twice. - */ +/** Managers with new applications yesterday, minus any already digested today (counted in `skipped`). */ export async function getDailyDigestRecipients( now: Date = new Date(), ): Promise<{ recipients: DailyDigestRecipient[]; skipped: number }> { @@ -175,11 +171,7 @@ export async function getDailyDigestRecipients( return { recipients, skipped: gatedIds.size }; } -/** - * Managers with new applications or anything unresolved on a managed - * position over the previous org Monday-Sunday week. Same per-period gate - * shape as the daily digest, keyed to `manager_weekly_digest` rows. - */ +/** Managers with new or unresolved applications last week, gated like the daily digest. */ export async function getWeeklyDigestRecipients( now: Date = new Date(), ): Promise<{ recipients: WeeklyDigestRecipient[]; skipped: number }> { diff --git a/tests/db/manager-digests.test.ts b/tests/db/manager-digests.test.ts index cef24389..156a4bb4 100644 --- a/tests/db/manager-digests.test.ts +++ b/tests/db/manager-digests.test.ts @@ -197,6 +197,40 @@ describe('daily digest', () => { expect(logs).toHaveLength(1); }); + it('does not stop the run when one recipient send fails, and records it as failed', async () => { + const yesterday = previousOrgDay(new Date()); + const submittedAt = new Date(yesterday.start.getTime() + 60 * 60 * 1000); + + const applicant = await createTestUser(); + await createTestApplication(applicant, position1, { submittedAt }); + + const managerOk = await createTestUser(); + const positionOk = await createTestPosition(creator, { + managers: [managerOk], + }); + const applicantOk = await createTestUser(); + await createTestApplication(applicantOk, positionOk, { submittedAt }); + + 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 yesterday = previousOrgDay(new Date()); const submittedAt = new Date(yesterday.start.getTime() + 60 * 60 * 1000); diff --git a/tests/unit/email-templates.test.ts b/tests/unit/email-templates.test.ts index d613002b..06c46a33 100644 --- a/tests/unit/email-templates.test.ts +++ b/tests/unit/email-templates.test.ts @@ -350,4 +350,15 @@ describe('managerWeeklyDigestEmail', () => { expect(result.html).toContain('Mar 8, 2026'); expect(result.html).toContain(MANAGER_EMAIL_FOOTER); }); + + it('disambiguates a week range that crosses a year boundary', () => { + const result = managerWeeklyDigestEmail({ + weekStart: '2026-12-28', + weekEnd: '2027-01-03', + openPositions: [], + newApplications: 4, + statusCounts: [], + }); + expect(result.html).toContain('Dec 28, 2026 – Jan 3, 2027'); + }); }); From 7054cb70fbc22ec3e0cf8c9483e8f7be663cf80a Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Sat, 12 Sep 2026 00:13:32 -0400 Subject: [PATCH 07/35] #549 address review feedback Co-Authored-By: Claude Sonnet 4.6 --- lib/email/templates.ts | 14 +++++++++----- tests/unit/email-templates.test.ts | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/lib/email/templates.ts b/lib/email/templates.ts index a9021a81..5638532b 100644 --- a/lib/email/templates.ts +++ b/lib/email/templates.ts @@ -1,7 +1,11 @@ import 'server-only'; import { getBaseUrl } from '@/lib/base-url'; -import { APPLICATION_STATUS_LABELS, ORG_TIMEZONE } from '@/lib/constants'; +import { + APPLICATION_STATUS_LABELS, + ORG_TIMEZONE, + UNRESOLVED_APPLICATION_STATUSES, +} from '@/lib/constants'; import { orgDayStart } from '@/lib/dates'; import { type ManagerDigestPosition, @@ -372,10 +376,10 @@ export function managerWeeklyDigestEmail({ const allApplicationsUrl = `${baseUrl}/manage/applications`; const weekRangeLabel = formatDigestWeekRange(weekStart, weekEnd); const safeGreeting = escapeHtml(greeting(firstName)); - const unresolvedTotal = statusCounts.reduce( - (sum, entry) => sum + entry.count, - 0, - ); + const unresolvedStatuses: readonly string[] = UNRESOLVED_APPLICATION_STATUSES; + const unresolvedTotal = statusCounts + .filter((entry) => unresolvedStatuses.includes(entry.status)) + .reduce((sum, entry) => sum + entry.count, 0); const subject = newApplications > 0 diff --git a/tests/unit/email-templates.test.ts b/tests/unit/email-templates.test.ts index 06c46a33..c51db81e 100644 --- a/tests/unit/email-templates.test.ts +++ b/tests/unit/email-templates.test.ts @@ -294,6 +294,21 @@ describe('managerWeeklyDigestEmail', () => { ); }); + it('excludes terminal decisions from the awaiting-review count', () => { + const result = managerWeeklyDigestEmail({ + ...base, + newApplications: 0, + statusCounts: [ + { status: 'applied', count: 7 }, + { status: 'accepted', count: 3 }, + { status: 'rejected', count: 2 }, + ], + }); + expect(result.subject).toBe( + 'Your week on Aplio: 7 applications awaiting review', + ); + }); + it('omits zero-count statuses and links each remaining status', () => { const result = managerWeeklyDigestEmail({ ...base, From c594d25f27d115323afd39563ee6f5c89a048c88 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Sat, 12 Sep 2026 11:09:30 -0400 Subject: [PATCH 08/35] #549 make the weekly digest a pure unresolved-work reminder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weekly email no longer tallies "new applications this week" — a manager gets it only when they have applications still short of a terminal status, with a status-count box per status (color-coded like the in-app badges) instead of a text recap. previousOrgWeek has no remaining caller and is removed along with it. Co-Authored-By: Claude Sonnet 4.6 --- docs/WORKFLOWS.md | 4 +- lib/dates.ts | 17 ---- lib/email/manager-digests.ts | 4 +- lib/email/templates.ts | 144 ++++++++++++++--------------- lib/types.ts | 5 +- prisma/data/digests.ts | 38 +++----- tests/db/manager-digests.test.ts | 54 ++++------- tests/unit/dates.test.ts | 25 ----- tests/unit/email-templates.test.ts | 95 +++++-------------- 9 files changed, 126 insertions(+), 260 deletions(-) diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index f3c993c9..0ff22b45 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -92,10 +92,10 @@ Three applicant-facing email _events_, over two rendered templates, all through ### XC-10 Manager digests -Two cron-triggered emails, both windowed by the **org calendar**, not by a per-manager "since last send" — daily and weekly are independent cadences that never gate each other. +Two cron-triggered emails on independent cadences that never gate each other, both gated per period by an `EmailLog` row rather than a per-manager "since last send." - **Daily** (`manager_daily_digest`) — `GET /api/cron/manager-daily-digest`, Vercel Cron at `0 12 * * *` UTC (08:00 ET). Covers the previous org calendar day. 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=`); a manager with nothing new gets nothing. -- **Weekly** (`manager_weekly_digest`) — `GET /api/cron/manager-weekly-digest`, Vercel Cron at `0 13 * * 1` UTC, Mondays. Covers the previous Monday–Sunday org week: new applications, a status breakdown (`/manage/applications?status=` per row), and the manager's currently-open positions as context. Sent when there's anything new **or** anything still unresolved — an open position with zero new applications and zero unresolved is not itself a reason to send. +- **Weekly** (`manager_weekly_digest`) — `GET /api/cron/manager-weekly-digest`, Vercel Cron at `0 13 * * 1` UTC, Mondays. 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. - **Idempotent per period, not per send.** Neither cadence has a stored "last sent" column — both are gated by whether an `EmailLog` row for that template already exists inside the current calendar day/week (`prisma/data/digests.ts`), so a double-fire is a no-op. 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. diff --git a/lib/dates.ts b/lib/dates.ts index 75b460cd..96775d53 100644 --- a/lib/dates.ts +++ b/lib/dates.ts @@ -104,23 +104,6 @@ export function currentOrgWeekStart(now: Date): string { return shiftOrgDay(day, -daysSinceMonday); } -/** The Monday–Sunday org week before `now`'s current week, with its instant bounds. */ -export function previousOrgWeek(now: Date): { - startDay: string; - endDay: string; - start: Date; - end: Date; -} { - const startDay = shiftOrgDay(currentOrgWeekStart(now), -7); - const endDay = shiftOrgDay(startDay, 6); - return { - startDay, - endDay, - start: orgDayStart(startDay), - end: orgDayEnd(endDay), - }; -} - 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 index 56713055..d4f9a080 100644 --- a/lib/email/manager-digests.ts +++ b/lib/email/manager-digests.ts @@ -66,9 +66,7 @@ export async function dispatchWeeklyManagerDigests( try { const { subject, html, text } = managerWeeklyDigestEmail({ firstName: getFirstName(recipient.name), - weekStart: recipient.weekStart, - weekEnd: recipient.weekEnd, - newApplications: recipient.newApplications, + asOfDay: recipient.asOfDay, statusCounts: recipient.statusCounts, openPositions: recipient.openPositions, }); diff --git a/lib/email/templates.ts b/lib/email/templates.ts index 5638532b..d0de1c1a 100644 --- a/lib/email/templates.ts +++ b/lib/email/templates.ts @@ -1,11 +1,9 @@ import 'server-only'; +import { type $Enums } from '@/prisma/client'; + import { getBaseUrl } from '@/lib/base-url'; -import { - APPLICATION_STATUS_LABELS, - ORG_TIMEZONE, - UNRESOLVED_APPLICATION_STATUSES, -} from '@/lib/constants'; +import { APPLICATION_STATUS_LABELS, ORG_TIMEZONE } from '@/lib/constants'; import { orgDayStart } from '@/lib/dates'; import { type ManagerDigestPosition, @@ -275,25 +273,6 @@ function formatDigestDay(day: string): string { }).format(orgDayStart(day)); } -function formatDigestWeekRange(startDay: string, endDay: string): string { - // Only the start label carries the year conditionally — a week spanning a - // year boundary would otherwise read ambiguously (Dec 29 – Jan 4, 2027). - const crossesYear = startDay.slice(0, 4) !== endDay.slice(0, 4); - const startLabel = new Intl.DateTimeFormat('en-US', { - timeZone: ORG_TIMEZONE, - month: 'short', - day: 'numeric', - ...(crossesYear ? { year: 'numeric' } : {}), - }).format(orgDayStart(startDay)); - const endLabel = new Intl.DateTimeFormat('en-US', { - timeZone: ORG_TIMEZONE, - month: 'short', - day: 'numeric', - year: 'numeric', - }).format(orgDayStart(endDay)); - return `${startLabel} – ${endLabel}`; -} - export interface ManagerDailyDigestEmailOptions { firstName?: string; day: string; @@ -355,51 +334,83 @@ export function managerDailyDigestEmail({ }; } +// Only the two variants an unresolved status can carry (see +// APPLICATION_STATUS_BADGE_VARIANT) — reviewing is the sole 'warning', the +// rest are 'info'. Matches the in-app status-dot palette (app/globals.css). +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; - weekStart: string; - weekEnd: string; - newApplications: number; + 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, - weekStart, - weekEnd, - newApplications, + asOfDay, statusCounts, openPositions, }: ManagerWeeklyDigestEmailOptions): EmailTemplate { const baseUrl = getBaseUrl(); const allApplicationsUrl = `${baseUrl}/manage/applications`; - const weekRangeLabel = formatDigestWeekRange(weekStart, weekEnd); + const asOfLabel = formatDigestDay(asOfDay); const safeGreeting = escapeHtml(greeting(firstName)); - const unresolvedStatuses: readonly string[] = UNRESOLVED_APPLICATION_STATUSES; - const unresolvedTotal = statusCounts - .filter((entry) => unresolvedStatuses.includes(entry.status)) - .reduce((sum, entry) => sum + entry.count, 0); + const total = statusCounts.reduce((sum, entry) => sum + entry.count, 0); - const subject = - newApplications > 0 - ? `Your week on Aplio: ${newApplications} new ${pluralize(newApplications, 'application')}` - : `Your week on Aplio: ${unresolvedTotal} ${pluralize(unresolvedTotal, 'application')} awaiting review`; - - const newThisWeekLine = - newApplications > 0 - ? `${newApplications} new ${pluralize(newApplications, 'application')} across the positions you manage.` - : 'No new applications this week.'; - - const statusRows = - statusCounts.length > 0 - ? statusCounts - .map((entry) => { - const url = `${allApplicationsUrl}?status=${entry.status}`; - return `${escapeHtml(APPLICATION_STATUS_LABELS[entry.status])} — ${entry.count}`; - }) - .join('
') - : 'No applications on your positions yet.'; + 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 @@ -413,11 +424,8 @@ export function managerWeeklyDigestEmail({ const content = `

${safeGreeting}

-

Your weekly summary for ${escapeHtml(weekRangeLabel)}.

-

New this week

-

${escapeHtml(newThisWeekLine)}

-

Where things stand

-

${statusRows}

+

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')} @@ -426,20 +434,12 @@ export function managerWeeklyDigestEmail({ const text = [ greeting(firstName), '', - `Your weekly summary for ${weekRangeLabel}.`, - '', - 'New this week', - newThisWeekLine, + `As of ${asOfLabel}, you have ${total} ${pluralize(total, 'application')} awaiting review across the positions you manage.`, '', - 'Where things stand', - statusCounts.length > 0 - ? statusCounts - .map( - (entry) => - `${APPLICATION_STATUS_LABELS[entry.status]} (${allApplicationsUrl}?status=${entry.status}) — ${entry.count}`, - ) - .join('\n') - : 'No applications on your positions yet.', + ...statusCounts.map( + (entry) => + `${APPLICATION_STATUS_LABELS[entry.status]}: ${entry.count} — ${allApplicationsUrl}?status=${entry.status}`, + ), '', 'Your open positions', openPositions.length > 0 diff --git a/lib/types.ts b/lib/types.ts index c375b33e..376859d2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -543,6 +543,7 @@ export type DailyDigestRecipient = { total: number; }; +// status is always one of UNRESOLVED_APPLICATION_STATUSES — never a terminal decision. export type WeeklyDigestStatusCount = { status: $Enums.ApplicationStatus; count: number; @@ -552,9 +553,7 @@ export type WeeklyDigestRecipient = { userId: string; email: string; name: string | null; - weekStart: string; - weekEnd: string; - newApplications: number; + asOfDay: string; statusCounts: WeeklyDigestStatusCount[]; openPositions: Pick[]; }; diff --git a/prisma/data/digests.ts b/prisma/data/digests.ts index ea6e2106..caf69e28 100644 --- a/prisma/data/digests.ts +++ b/prisma/data/digests.ts @@ -5,14 +5,12 @@ import { type $Enums } from '@/prisma/client'; import { NON_REVIEWABLE_APPLICATION_STATUSES, PUBLISHED_POSITION_WHERE, - REVIEWER_APPLICATION_STATUSES, UNRESOLVED_APPLICATION_STATUSES, } from '@/lib/constants'; import { currentOrgWeekStart, orgDayStart, previousOrgDay, - previousOrgWeek, toOrgDayString, } from '@/lib/dates'; import { prisma } from '@/lib/prisma'; @@ -86,6 +84,7 @@ async function tallyNewApplications( async function tallyStatusBreakdown( positionIds: string[], + statuses: readonly $Enums.ApplicationStatus[], ): Promise>> { const map = new Map>(); if (positionIds.length === 0) return map; @@ -95,7 +94,7 @@ async function tallyStatusBreakdown( where: { positionId: { in: positionIds }, deletedAt: null, - status: { notIn: NON_REVIEWABLE_APPLICATION_STATUSES }, + status: { in: [...statuses] }, position: PUBLISHED_POSITION_WHERE, }, _count: true, @@ -171,7 +170,7 @@ export async function getDailyDigestRecipients( return { recipients, skipped: gatedIds.size }; } -/** Managers with new or unresolved applications last week, gated like the daily digest. */ +/** 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 }> { @@ -179,7 +178,6 @@ export async function getWeeklyDigestRecipients( if (managers.length === 0) return { recipients: [], skipped: 0 }; const managerIds = managers.map((manager) => manager.id); - const { startDay, endDay, start, end } = previousOrgWeek(now); const alreadyDigested = await prisma.emailLog.findMany({ where: { @@ -200,14 +198,10 @@ export async function getWeeklyDigestRecipients( const positionIds = candidates.flatMap((manager) => manager.managedPositions.map((position) => position.id), ); - - const [newTallies, statusTallies] = await Promise.all([ - tallyNewApplications(positionIds, start, end), - tallyStatusBreakdown(positionIds), - ]); - - const unresolvedStatuses: readonly $Enums.ApplicationStatus[] = - UNRESOLVED_APPLICATION_STATUSES; + const statusTallies = await tallyStatusBreakdown( + positionIds, + UNRESOLVED_APPLICATION_STATUSES, + ); const recipients: WeeklyDigestRecipient[] = []; for (const manager of candidates) { @@ -215,12 +209,7 @@ export async function getWeeklyDigestRecipients( (position) => position.id, ); - const newApplications = managerPositionIds.reduce( - (sum, id) => sum + (newTallies.get(id) ?? 0), - 0, - ); - - const statusCounts = REVIEWER_APPLICATION_STATUSES.map((status) => ({ + const statusCounts = UNRESOLVED_APPLICATION_STATUSES.map((status) => ({ status, count: managerPositionIds.reduce( (sum, id) => sum + (statusTallies.get(id)?.get(status) ?? 0), @@ -228,11 +217,8 @@ export async function getWeeklyDigestRecipients( ), })).filter((entry) => entry.count > 0); - const unresolvedTotal = statusCounts - .filter((entry) => unresolvedStatuses.includes(entry.status)) - .reduce((sum, entry) => sum + entry.count, 0); - - if (newApplications === 0 && unresolvedTotal === 0) continue; + const total = statusCounts.reduce((sum, entry) => sum + entry.count, 0); + if (total === 0) continue; const openPositions = manager.managedPositions .filter((position) => isAcceptingApplications(position)) @@ -243,9 +229,7 @@ export async function getWeeklyDigestRecipients( userId: manager.id, email: manager.email, name: manager.name, - weekStart: startDay, - weekEnd: endDay, - newApplications, + asOfDay: toOrgDayString(now), statusCounts, openPositions, }); diff --git a/tests/db/manager-digests.test.ts b/tests/db/manager-digests.test.ts index 156a4bb4..c23e2cdc 100644 --- a/tests/db/manager-digests.test.ts +++ b/tests/db/manager-digests.test.ts @@ -18,12 +18,7 @@ import { import type { Position, User } from '@/prisma/client'; -import { - orgDayStart, - previousOrgDay, - previousOrgWeek, - toOrgDayString, -} from '@/lib/dates'; +import { orgDayStart, previousOrgDay, toOrgDayString } from '@/lib/dates'; import { prisma } from '@/lib/prisma'; const mockSend = vi.fn(); @@ -273,10 +268,7 @@ describe('weekly digest', () => { }); }); - it('sends one email with totals, a status breakdown excluding withdrawn/draft, and open positions excluding a closed-by-date one', async () => { - const week = previousOrgWeek(new Date()); - const submittedAt = new Date(week.start.getTime() + 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(); @@ -284,23 +276,18 @@ describe('weekly digest', () => { const applicantDraft = await createTestUser(); await createTestApplication(applicantApplied, openPosition, { - submittedAt, status: 'applied', }); await createTestApplication(applicantReviewing, openPosition, { - submittedAt, status: 'reviewing', }); await createTestApplication(applicantAccepted, closedByDatePosition, { - submittedAt, status: 'accepted', }); await createTestApplication(applicantWithdrawn, openPosition, { - submittedAt, status: 'withdrawn', }); await createTestApplication(applicantDraft, openPosition, { - submittedAt, status: 'draft', }); @@ -311,10 +298,10 @@ describe('weekly digest', () => { const call = sentTo(manager.email); expect(call).toBeDefined(); - expect(call?.subject).toBe('Your week on Aplio: 3 new applications'); + expect(call?.subject).toBe('2 applications awaiting your review'); expect(call?.html).toContain('?status=applied'); expect(call?.html).toContain('?status=reviewing'); - expect(call?.html).toContain('?status=accepted'); + expect(call?.html).not.toContain('?status=accepted'); expect(call?.html).toContain(`?positionId=${openPosition.id}`); expect(call?.html).not.toContain(`?positionId=${closedByDatePosition.id}`); @@ -325,7 +312,12 @@ describe('weekly digest', () => { expect(logs[0]?.applicationId).toBeNull(); }); - it('sends nothing with zero new applications and nothing unresolved', async () => { + 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}`), ); @@ -333,13 +325,11 @@ describe('weekly digest', () => { expect(sentTo(manager.email)).toBeUndefined(); }); - it('sends the awaiting-review subject with zero new applications but something unresolved', async () => { - const now = new Date(); - const twoWeeksAgo = previousOrgWeek(previousOrgWeek(now).start); - const submittedAt = new Date(twoWeeksAgo.start.getTime() + 60 * 60 * 1000); + 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, + submittedAt: longAgo, status: 'applied', }); @@ -350,19 +340,12 @@ describe('weekly digest', () => { const call = sentTo(manager.email); expect(call).toBeDefined(); - expect(call?.subject).toBe( - 'Your week on Aplio: 1 application awaiting review', - ); + expect(call?.subject).toBe('1 application awaiting your review'); }); it('gates a repeat call the same week', async () => { - const week = previousOrgWeek(new Date()); - const submittedAt = new Date(week.start.getTime() + 60 * 60 * 1000); const applicant = await createTestUser(); - await createTestApplication(applicant, openPosition, { - submittedAt, - status: 'applied', - }); + await createTestApplication(applicant, openPosition, { status: 'applied' }); const first = await weeklyGET( makeRequest(WEEKLY_URL, `Bearer ${CRON_SECRET}`), @@ -381,13 +364,8 @@ describe('weekly digest', () => { }); it('is not gated by an existing daily-digest row for the same manager', async () => { - const week = previousOrgWeek(new Date()); - const submittedAt = new Date(week.start.getTime() + 60 * 60 * 1000); const applicant = await createTestUser(); - await createTestApplication(applicant, openPosition, { - submittedAt, - status: 'applied', - }); + await createTestApplication(applicant, openPosition, { status: 'applied' }); await prisma.emailLog.create({ data: { diff --git a/tests/unit/dates.test.ts b/tests/unit/dates.test.ts index 9f90eb66..6940cee1 100644 --- a/tests/unit/dates.test.ts +++ b/tests/unit/dates.test.ts @@ -7,7 +7,6 @@ import { orgDayEnd, orgDayStart, previousOrgDay, - previousOrgWeek, toOrgDayString, } from '@/lib/dates'; @@ -119,30 +118,6 @@ describe('currentOrgWeekStart', () => { }); }); -describe('previousOrgWeek', () => { - it('spans a full Monday-Sunday week before the current one', () => { - const week = previousOrgWeek(noonOn('2026-03-23')); - expect(week.startDay).toBe('2026-03-16'); - expect(week.endDay).toBe('2026-03-22'); - expect(week.start).toEqual(orgDayStart('2026-03-16')); - expect(week.end).toEqual(orgDayEnd('2026-03-22')); - }); - - it('still spans exactly 7 calendar days across a DST change', () => { - // now's current week is 2026-03-09..15, so the previous week is - // 2026-03-02..08 — the Sunday it ends on is the spring-forward day. - const week = previousOrgWeek(noonOn('2026-03-09')); - expect(week.startDay).toBe('2026-03-02'); - expect(week.endDay).toBe('2026-03-08'); - - // Spring-forward inside the week loses an hour, so the instant span is - // 167 hours, not 168 — the calendar-day count is still exactly 7. - const hours = - (week.end.getTime() - week.start.getTime() + 1) / (1000 * 60 * 60); - expect(hours).toBeCloseTo(167, 5); - }); -}); - 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 c51db81e..e8b21862 100644 --- a/tests/unit/email-templates.test.ts +++ b/tests/unit/email-templates.test.ts @@ -259,84 +259,47 @@ describe('managerDailyDigestEmail', () => { }); describe('managerWeeklyDigestEmail', () => { - const base = { - weekStart: '2026-03-02', - weekEnd: '2026-03-08', - openPositions: [], - }; - - it('uses the new-applications subject when there is new activity', () => { - const result = managerWeeklyDigestEmail({ - ...base, - newApplications: 12, - statusCounts: [], - }); - expect(result.subject).toBe('Your week on Aplio: 12 new applications'); - }); + const base = { asOfDay: '2026-03-09', openPositions: [] }; - it('singularizes the new-applications subject at exactly 1', () => { + it('sums the unresolved statuses into the subject, pluralized', () => { const result = managerWeeklyDigestEmail({ ...base, - newApplications: 1, - statusCounts: [], - }); - expect(result.subject).toBe('Your week on Aplio: 1 new application'); - }); - - it('falls back to the awaiting-review subject with no new applications', () => { - const result = managerWeeklyDigestEmail({ - ...base, - newApplications: 0, - statusCounts: [{ status: 'applied', count: 7 }], + statusCounts: [ + { status: 'applied', count: 7 }, + { status: 'reviewing', count: 3 }, + ], }); - expect(result.subject).toBe( - 'Your week on Aplio: 7 applications awaiting review', - ); + expect(result.subject).toBe('10 applications awaiting your review'); }); - it('excludes terminal decisions from the awaiting-review count', () => { + it('singularizes the subject at exactly 1', () => { const result = managerWeeklyDigestEmail({ ...base, - newApplications: 0, - statusCounts: [ - { status: 'applied', count: 7 }, - { status: 'accepted', count: 3 }, - { status: 'rejected', count: 2 }, - ], + statusCounts: [{ status: 'applied', count: 1 }], }); - expect(result.subject).toBe( - 'Your week on Aplio: 7 applications awaiting review', - ); + expect(result.subject).toBe('1 application awaiting your review'); }); - it('omits zero-count statuses and links each remaining status', () => { + it('renders a stat box per status, linked by status value', () => { const result = managerWeeklyDigestEmail({ ...base, - newApplications: 0, statusCounts: [ { status: 'applied', count: 7 }, - { status: 'accepted', count: 3 }, + { status: 'reviewing', count: 3 }, ], }); expect(result.html).toContain('?status=applied'); - expect(result.html).toContain('?status=accepted'); - expect(result.html).not.toContain('?status=rejected'); - }); - - it('shows the empty statuses line when nothing is unresolved but new applications exist', () => { - const result = managerWeeklyDigestEmail({ - ...base, - newApplications: 4, - statusCounts: [], - }); - expect(result.html).toContain('No applications on your positions yet.'); + 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, - newApplications: 4, - statusCounts: [], + statusCounts: [{ status: 'applied', count: 1 }], }); expect(result.html).toContain('You have no positions open right now.'); }); @@ -344,8 +307,7 @@ describe('managerWeeklyDigestEmail', () => { it('links open positions by positionId', () => { const result = managerWeeklyDigestEmail({ ...base, - newApplications: 4, - statusCounts: [], + statusCounts: [{ status: 'applied', count: 1 }], openPositions: [ { positionId: 'pos-1', title: 'Senator' }, { positionId: 'pos-2', title: 'Treasurer' }, @@ -355,25 +317,12 @@ describe('managerWeeklyDigestEmail', () => { expect(result.html).toContain('?positionId=pos-2'); }); - it('includes the week range and the manager footer', () => { + it('includes the as-of date and the manager footer', () => { const result = managerWeeklyDigestEmail({ ...base, - newApplications: 4, - statusCounts: [], + statusCounts: [{ status: 'applied', count: 1 }], }); - expect(result.html).toContain('Mar 2'); - expect(result.html).toContain('Mar 8, 2026'); + expect(result.html).toContain('Mar 9, 2026'); expect(result.html).toContain(MANAGER_EMAIL_FOOTER); }); - - it('disambiguates a week range that crosses a year boundary', () => { - const result = managerWeeklyDigestEmail({ - weekStart: '2026-12-28', - weekEnd: '2027-01-03', - openPositions: [], - newApplications: 4, - statusCounts: [], - }); - expect(result.html).toContain('Dec 28, 2026 – Jan 3, 2027'); - }); }); From 0726d04f049c2a733d10d20fedf60440f60ab174 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Sat, 12 Sep 2026 11:21:53 -0400 Subject: [PATCH 09/35] #549 window the daily digest since each manager's last send Replaces the previous-org-calendar-day window with a gap-safe one anchored to each manager's own last digest (24h fallback for a first-ever digest). A missed or delayed cron fire can no longer drop an application, and in steady daily operation the window is still always ~24h. The email now names the exact since-instant rather than a calendar day, since the window can span more than a day after a gap. Co-Authored-By: Claude Sonnet 4.6 --- docs/WORKFLOWS.md | 8 +- lib/constants.ts | 5 ++ lib/email/manager-digests.ts | 2 +- lib/email/templates.ts | 18 +++-- lib/types.ts | 2 +- prisma/data/digests.ts | 116 +++++++++++++++++++---------- tests/db/manager-digests.test.ts | 89 +++++++++++----------- tests/unit/email-templates.test.ts | 16 ++-- 8 files changed, 153 insertions(+), 103 deletions(-) diff --git a/docs/WORKFLOWS.md b/docs/WORKFLOWS.md index 0ff22b45..39e9a013 100644 --- a/docs/WORKFLOWS.md +++ b/docs/WORKFLOWS.md @@ -92,11 +92,11 @@ Three applicant-facing email _events_, over two rendered templates, all through ### XC-10 Manager digests -Two cron-triggered emails on independent cadences that never gate each other, both gated per period by an `EmailLog` row rather than a per-manager "since last send." +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). Covers the previous org calendar day. 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=`); a manager with nothing new gets nothing. -- **Weekly** (`manager_weekly_digest`) — `GET /api/cron/manager-weekly-digest`, Vercel Cron at `0 13 * * 1` UTC, Mondays. 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. -- **Idempotent per period, not per send.** Neither cadence has a stored "last sent" column — both are gated by whether an `EmailLog` row for that template already exists inside the current calendar day/week (`prisma/data/digests.ts`), so a double-fire is a no-op. A manager who manages several positions always gets **one** email per cadence, never one per position. +- **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). diff --git a/lib/constants.ts b/lib/constants.ts index 91d10135..3a71ba7f 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1130,6 +1130,11 @@ export const EMAIL_TEMPLATE_VALUES = [ // 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', diff --git a/lib/email/manager-digests.ts b/lib/email/manager-digests.ts index d4f9a080..91b9783f 100644 --- a/lib/email/manager-digests.ts +++ b/lib/email/manager-digests.ts @@ -31,7 +31,7 @@ export async function dispatchDailyManagerDigests( try { const { subject, html, text } = managerDailyDigestEmail({ firstName: getFirstName(recipient.name), - day: recipient.day, + since: recipient.since, positions: recipient.positions, total: recipient.total, }); diff --git a/lib/email/templates.ts b/lib/email/templates.ts index d0de1c1a..00af7631 100644 --- a/lib/email/templates.ts +++ b/lib/email/templates.ts @@ -4,7 +4,7 @@ import { type $Enums } from '@/prisma/client'; import { getBaseUrl } from '@/lib/base-url'; import { APPLICATION_STATUS_LABELS, ORG_TIMEZONE } from '@/lib/constants'; -import { orgDayStart } from '@/lib/dates'; +import { formatInstant, orgDayStart } from '@/lib/dates'; import { type ManagerDigestPosition, type WeeklyDigestStatusCount, @@ -275,20 +275,26 @@ function formatDigestDay(day: string): string { export interface ManagerDailyDigestEmailOptions { firstName?: string; - day: 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, - day, + since, positions, total, }: ManagerDailyDigestEmailOptions): EmailTemplate { const baseUrl = getBaseUrl(); const allApplicationsUrl = `${baseUrl}/manage/applications`; - const dayLabel = formatDigestDay(day); + const sinceLabel = formatInstant(since, { + precision: 'datetime', + timeZone: ORG_TIMEZONE, + }); const safeGreeting = escapeHtml(greeting(firstName)); const subject = @@ -305,7 +311,7 @@ export function managerDailyDigestEmail({ const content = `

${safeGreeting}

-

New applications on the positions you manage, from ${escapeHtml(dayLabel)}.

+

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

${positionRows}
${primaryButton(allApplicationsUrl, 'Review all applications')} `; @@ -313,7 +319,7 @@ export function managerDailyDigestEmail({ const text = [ greeting(firstName), '', - `New applications on the positions you manage, from ${dayLabel}.`, + `New applications on the positions you manage, since ${sinceLabel}.`, '', ...positions.map( (position) => diff --git a/lib/types.ts b/lib/types.ts index 376859d2..87667a87 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -538,7 +538,7 @@ export type DailyDigestRecipient = { userId: string; email: string; name: string | null; - day: string; + since: Date; positions: ManagerDigestPosition[]; total: number; }; diff --git a/prisma/data/digests.ts b/prisma/data/digests.ts index caf69e28..76267879 100644 --- a/prisma/data/digests.ts +++ b/prisma/data/digests.ts @@ -3,16 +3,12 @@ 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, - previousOrgDay, - toOrgDayString, -} from '@/lib/dates'; +import { currentOrgWeekStart, orgDayStart, toOrgDayString } from '@/lib/dates'; import { prisma } from '@/lib/prisma'; import { type DailyDigestRecipient, @@ -60,26 +56,49 @@ async function getManagerCandidates(): Promise { }); } -async function tallyNewApplications( +// 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[], - start: Date, - end: Date, -): Promise> { - if (positionIds.length === 0) return new Map(); + since: Date, + until: Date, +): Promise<{ positionId: string; submittedAt: Date }[]> { + if (positionIds.length === 0) return []; - const rows = await prisma.application.groupBy({ - by: ['positionId'], + return prisma.application.findMany({ where: { positionId: { in: positionIds }, deletedAt: null, status: { notIn: NON_REVIEWABLE_APPLICATION_STATUSES }, - submittedAt: { gte: start, lte: end }, + submittedAt: { gt: since, lte: until }, position: PUBLISHED_POSITION_WHERE, }, - _count: true, + select: { positionId: true, submittedAt: true }, + }); +} + +// 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 }, }); - return new Map(rows.map((row) => [row.positionId, row._count])); + 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( @@ -109,7 +128,13 @@ async function tallyStatusBreakdown( return map; } -/** Managers with new applications yesterday, minus any already digested today (counted in `skipped`). */ +/** + * 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 }> { @@ -117,36 +142,44 @@ export async function getDailyDigestRecipients( if (managers.length === 0) return { recipients: [], skipped: 0 }; const managerIds = managers.map((manager) => manager.id); - const { day, start, end } = previousOrgDay(now); - - const alreadyDigested = await prisma.emailLog.findMany({ - where: { - template: 'manager_daily_digest', - userId: { in: managerIds }, - createdAt: { gte: orgDayStart(toOrgDayString(now)) }, - }, - select: { userId: true }, - }); - const gatedIds = new Set( - alreadyDigested.map((row) => row.userId).filter((id) => id !== null), + const fallbackSince = new Date(now.getTime() - DAILY_DIGEST_LOOKBACK_MS); + const sinceByManager = await getDigestSinceByManager( + managerIds, + fallbackSince, ); - const candidates = managers.filter((manager) => !gatedIds.has(manager.id)); - if (candidates.length === 0) - return { recipients: [], skipped: gatedIds.size }; - - const positionIds = candidates.flatMap((manager) => + const positionIds = managers.flatMap((manager) => manager.managedPositions.map((position) => position.id), ); - const tallies = await tallyNewApplications(positionIds, start, end); + 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[] = []; - for (const manager of candidates) { + 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: tallies.get(position.id) ?? 0, + newApplications: (submittedAtByPosition.get(position.id) ?? []).filter( + (submittedAt) => submittedAt > since, + ).length, })) .filter((position) => position.newApplications > 0) .sort((a, b) => a.title.localeCompare(b.title)); @@ -155,19 +188,22 @@ export async function getDailyDigestRecipients( (sum, position) => sum + position.newApplications, 0, ); - if (total === 0) continue; + if (total === 0) { + skipped += 1; + continue; + } recipients.push({ userId: manager.id, email: manager.email, name: manager.name, - day, + since, positions, total, }); } - return { recipients, skipped: gatedIds.size }; + return { recipients, skipped }; } /** Managers with any application still short of a terminal status, gated per org week. */ diff --git a/tests/db/manager-digests.test.ts b/tests/db/manager-digests.test.ts index c23e2cdc..df3f4874 100644 --- a/tests/db/manager-digests.test.ts +++ b/tests/db/manager-digests.test.ts @@ -18,7 +18,6 @@ import { import type { Position, User } from '@/prisma/client'; -import { orgDayStart, previousOrgDay, toOrgDayString } from '@/lib/dates'; import { prisma } from '@/lib/prisma'; const mockSend = vi.fn(); @@ -104,15 +103,12 @@ describe('daily digest', () => { position2 = await createTestPosition(creator, { managers: [manager] }); }); - it('sends one email covering two positions submitted yesterday, logged once', async () => { - const yesterday = previousOrgDay(new Date()); - const submittedAt = new Date(yesterday.start.getTime() + 60 * 60 * 1000); - + 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, { submittedAt }); - await createTestApplication(applicantB, position1, { submittedAt }); - await createTestApplication(applicantA, position2, { submittedAt }); + 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); @@ -130,33 +126,26 @@ describe('daily digest', () => { expect(logs[0]?.applicationId).toBeNull(); }); - it('excludes submissions from today and from two days ago', async () => { + it('only looks back the fallback window for a manager never digested before', async () => { const now = new Date(); - const todaySubmittedAt = new Date( - orgDayStart(toOrgDayString(now)).getTime() + 60 * 60 * 1000, - ); - const twoDaysAgo = previousOrgDay(previousOrgDay(now).start); - const twoDaysAgoSubmittedAt = new Date( - twoDaysAgo.start.getTime() + 60 * 60 * 1000, - ); + const tooOld = new Date(now.getTime() - 25 * 60 * 60 * 1000); + const withinLookback = new Date(now.getTime() - 23 * 60 * 60 * 1000); - const applicantToday = await createTestUser(); - const applicantTwoDaysAgo = await createTestUser(); - await createTestApplication(applicantToday, position1, { - submittedAt: todaySubmittedAt, + const applicantOld = await createTestUser(); + const applicantRecent = await createTestUser(); + await createTestApplication(applicantOld, position1, { + submittedAt: tooOld, }); - await createTestApplication(applicantTwoDaysAgo, position1, { - submittedAt: twoDaysAgoSubmittedAt, + await createTestApplication(applicantRecent, position1, { + submittedAt: withinLookback, }); const res = await dailyGET(makeRequest(DAILY_URL, `Bearer ${CRON_SECRET}`)); expect(res.status).toBe(200); - expect(sentTo(manager.email)).toBeUndefined(); - const logs = await prisma.emailLog.findMany({ - where: { userId: manager.id, template: 'manager_daily_digest' }, - }); - expect(logs).toHaveLength(0); + 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 () => { @@ -165,11 +154,9 @@ describe('daily digest', () => { expect(sentTo(manager.email)).toBeUndefined(); }); - it('gates a repeat call the same day after a successful send', async () => { - const yesterday = previousOrgDay(new Date()); - const submittedAt = new Date(yesterday.start.getTime() + 60 * 60 * 1000); + it('finds nothing new when called again immediately after a successful send', async () => { const applicant = await createTestUser(); - await createTestApplication(applicant, position1, { submittedAt }); + await createTestApplication(applicant, position1, {}); const first = await dailyGET( makeRequest(DAILY_URL, `Bearer ${CRON_SECRET}`), @@ -192,19 +179,40 @@ describe('daily digest', () => { expect(logs).toHaveLength(1); }); - it('does not stop the run when one recipient send fails, and records it as failed', async () => { - const yesterday = previousOrgDay(new Date()); - const submittedAt = new Date(yesterday.start.getTime() + 60 * 60 * 1000); + 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, { submittedAt }); + await createTestApplication(applicant, position1, {}); const managerOk = await createTestUser(); const positionOk = await createTestPosition(creator, { managers: [managerOk], }); const applicantOk = await createTestUser(); - await createTestApplication(applicantOk, positionOk, { submittedAt }); + await createTestApplication(applicantOk, positionOk, {}); mockSend.mockImplementation(async (args: { to: string }) => { if (args.to === manager.email) throw new Error('simulated send failure'); @@ -227,17 +235,12 @@ describe('daily digest', () => { }); it('excludes a deactivated manager and a manager who only manages a draft position', async () => { - const yesterday = previousOrgDay(new Date()); - const submittedAt = new Date(yesterday.start.getTime() + 60 * 60 * 1000); - const deactivatedManager = await createTestUser({ deletedAt: new Date() }); const deactivatedManagerPosition = await createTestPosition(creator, { managers: [deactivatedManager], }); const applicantA = await createTestUser(); - await createTestApplication(applicantA, deactivatedManagerPosition, { - submittedAt, - }); + await createTestApplication(applicantA, deactivatedManagerPosition, {}); const draftOnlyManager = await createTestUser(); const draftPosition = await createTestPosition(creator, { @@ -245,7 +248,7 @@ describe('daily digest', () => { status: 'draft', }); const applicantB = await createTestUser(); - await createTestApplication(applicantB, draftPosition, { submittedAt }); + await createTestApplication(applicantB, draftPosition, {}); const res = await dailyGET(makeRequest(DAILY_URL, `Bearer ${CRON_SECRET}`)); expect(res.status).toBe(200); diff --git a/tests/unit/email-templates.test.ts b/tests/unit/email-templates.test.ts index e8b21862..7aba4737 100644 --- a/tests/unit/email-templates.test.ts +++ b/tests/unit/email-templates.test.ts @@ -183,7 +183,7 @@ describe('managerDailyDigestEmail', () => { it('singularizes the subject and body at exactly 1', () => { const result = managerDailyDigestEmail({ firstName: 'Jane', - day: '2026-03-02', + since: new Date('2026-03-02T14:00:00.000Z'), positions: [ { positionId: 'pos-1', title: 'Treasurer', newApplications: 1 }, ], @@ -195,7 +195,7 @@ describe('managerDailyDigestEmail', () => { it('names the single position in the subject, pluralized', () => { const result = managerDailyDigestEmail({ - day: '2026-03-02', + since: new Date('2026-03-02T14:00:00.000Z'), positions: [ { positionId: 'pos-1', title: 'Treasurer', newApplications: 3 }, ], @@ -206,7 +206,7 @@ describe('managerDailyDigestEmail', () => { it('summarizes across positions in the subject when there is more than one', () => { const result = managerDailyDigestEmail({ - day: '2026-03-02', + since: new Date('2026-03-02T14:00:00.000Z'), positions: [ { positionId: 'pos-1', title: 'Senator', newApplications: 3 }, { positionId: 'pos-2', title: 'Treasurer', newApplications: 2 }, @@ -218,7 +218,7 @@ describe('managerDailyDigestEmail', () => { it('escapes a dangerous position title in the html but leaves the subject raw', () => { const result = managerDailyDigestEmail({ - day: '2026-03-02', + since: new Date('2026-03-02T14:00:00.000Z'), positions: [ { positionId: 'pos-1', title: DANGEROUS_TITLE, newApplications: 1 }, ], @@ -232,7 +232,7 @@ describe('managerDailyDigestEmail', () => { it('links every position row to its own positionId', () => { const result = managerDailyDigestEmail({ - day: '2026-03-02', + since: new Date('2026-03-02T14:00:00.000Z'), positions: [ { positionId: 'pos-1', title: 'Senator', newApplications: 3 }, { positionId: 'pos-2', title: 'Treasurer', newApplications: 2 }, @@ -245,15 +245,15 @@ describe('managerDailyDigestEmail', () => { expect(result.text).toContain('?positionId=pos-2'); }); - it('names the org day and includes the manager footer', () => { + it('names the exact since-instant and includes the manager footer', () => { const result = managerDailyDigestEmail({ - day: '2026-03-02', + 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'); + expect(result.html).toContain('Mar 2, 2026, 9:00 AM EST'); expect(result.html).toContain(MANAGER_EMAIL_FOOTER); }); }); From e56c3b707dc4340ee23b31a0bdbb3e222c2b79e1 Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Sat, 12 Sep 2026 11:27:34 -0400 Subject: [PATCH 10/35] #549 address review feedback Co-Authored-By: Claude Sonnet 4.6 --- lib/email/templates.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/email/templates.ts b/lib/email/templates.ts index 00af7631..fc5b0c4d 100644 --- a/lib/email/templates.ts +++ b/lib/email/templates.ts @@ -340,9 +340,8 @@ export function managerDailyDigestEmail({ }; } -// Only the two variants an unresolved status can carry (see -// APPLICATION_STATUS_BADGE_VARIANT) — reviewing is the sole 'warning', the -// rest are 'info'. Matches the in-app status-dot palette (app/globals.css). +// 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> > = { From c279e85000a484cc83886075944151386ac3062d Mon Sep 17 00:00:00 2001 From: b-at-neu Date: Wed, 23 Sep 2026 19:59:25 -0400 Subject: [PATCH 11/35] #549 handle nullable submittedAt after rebasing onto #747 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #747 (merged to dev after this branch's last rebase) dropped the default and made Application.submittedAt nullable — a draft no longer has a fake submission timestamp. fetchNewApplications already excludes drafts by status, so the null case can't occur in practice; narrow the type back with a filter rather than loosening the daily digest's own types to Date | null everywhere downstream. Co-Authored-By: Claude Sonnet 4.6 --- prisma/data/digests.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/prisma/data/digests.ts b/prisma/data/digests.ts index 76267879..67fef5ea 100644 --- a/prisma/data/digests.ts +++ b/prisma/data/digests.ts @@ -66,7 +66,7 @@ async function fetchNewApplications( ): Promise<{ positionId: string; submittedAt: Date }[]> { if (positionIds.length === 0) return []; - return prisma.application.findMany({ + const rows = await prisma.application.findMany({ where: { positionId: { in: positionIds }, deletedAt: null, @@ -76,6 +76,13 @@ async function fetchNewApplications( }, 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 From 025c989f1d8b6e99df9f59f40c5a5a58027c8723 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 23 Sep 2026 21:08:28 -0400 Subject: [PATCH 12/35] #769 highlight the section actually in view in section nav Replace the IntersectionObserver-based active section detection with scroll-position measurements. The old approach let an edge-only overlap of the previous section win over the section actually being read, and never let the last section highlight once the user was already at the bottom of the page. Co-Authored-By: Claude Sonnet 5 --- components/layouts/section-nav-list.tsx | 4 +- components/layouts/use-section-nav.ts | 108 +++++++++++++++-------- docs/DESIGN.md | 2 +- lib/section-nav.ts | 43 ++++++--- lib/types.ts | 13 ++- tests/unit/section-nav.test.ts | 110 +++++++++++++++++------- 6 files changed, 194 insertions(+), 86 deletions(-) diff --git a/components/layouts/section-nav-list.tsx b/components/layouts/section-nav-list.tsx index 3434708e..64ea4e73 100644 --- a/components/layouts/section-nav-list.tsx +++ b/components/layouts/section-nav-list.tsx @@ -5,6 +5,8 @@ import type { MouseEvent } from 'react'; import type { SectionNavItem } from '@/lib/types'; import { cn } from '@/lib/utils'; +import { scrollToSection } from '@/components/layouts/use-section-nav'; + interface SectionNavListProps { sections: SectionNavItem[]; activeId: string | null; @@ -59,6 +61,6 @@ function handleClick( ); onNavigate?.(); requestAnimationFrame(() => { - document.getElementById(id)?.scrollIntoView({ block: 'start' }); + scrollToSection(id); }); } diff --git a/components/layouts/use-section-nav.ts b/components/layouts/use-section-nav.ts index 8b54bdcf..1d82a45e 100644 --- a/components/layouts/use-section-nav.ts +++ b/components/layouts/use-section-nav.ts @@ -4,12 +4,11 @@ import { usePathname } from 'next/navigation'; import { useEffect, useState } from 'react'; import { - SECTION_NAV_ROOT_MARGIN, SECTION_NAV_SELECTOR, buildSectionNavItems, selectActiveSectionId, } from '@/lib/section-nav'; -import type { SectionNavItem } from '@/lib/types'; +import type { SectionNavItem, SectionPosition } from '@/lib/types'; interface UseSectionNavResult { sections: SectionNavItem[]; @@ -27,6 +26,27 @@ function sectionsKey(sections: SectionNavItem[]): string { return sections.map((s) => `${s.id}|${s.label}`).join(','); } +let pin: { id: string; settled: boolean } | null = null; +let settleTimer: ReturnType | null = null; + +function armSettleTimer(): void { + if (settleTimer !== null) clearTimeout(settleTimer); + settleTimer = setTimeout(() => { + if (pin) pin.settled = true; + }, 150); +} + +// Module-level: the desktop sidebar and mobile Sheet each mount their own +// useSectionNav, and the Sheet unmounts right after a click. +export function scrollToSection(id: string): void { + pin = { id, settled: false }; + document + .getElementById('main-content') + ?.dispatchEvent(new Event('section-nav:pin')); + document.getElementById(id)?.scrollIntoView({ block: 'start' }); + armSettleTimer(); +} + // Sole permitted useEffect: subscribes to native observers, not data fetching. export function useSectionNav(): UseSectionNavResult { const pathname = usePathname(); @@ -42,15 +62,37 @@ export function useSectionNav(): UseSectionNavResult { let activeId: string | null = null; let skipNextHashSync = true; let hasScrolledToInitialHash = false; - const visibility = new Map(); - let observer: IntersectionObserver | null = null; + let rafId: number | null = null; + + function scheduleCompute(): void { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + computeActive(); + }); + } function computeActive(): void { - const entries = currentSections.map((section) => ({ - id: section.id, - isIntersecting: visibility.get(section.id) ?? false, - })); - const next = selectActiveSectionId(entries, activeId); + const rootTop = main.getBoundingClientRect().top; + const positions: SectionPosition[] = []; + for (const section of currentSections) { + const el = document.getElementById(section.id); + if (!el) continue; + positions.push({ + id: section.id, + top: el.getBoundingClientRect().top - rootTop, + }); + } + + const next = selectActiveSectionId( + positions, + { + scrollTop: main.scrollTop, + scrollHeight: main.scrollHeight, + clientHeight: main.clientHeight, + }, + pin?.id ?? null, + ); if (next === activeId) return; activeId = next; @@ -68,27 +110,10 @@ export function useSectionNav(): UseSectionNavResult { ); } - function subscribe(): void { - observer?.disconnect(); - visibility.clear(); - if (currentSections.length === 0) { - observer = null; - return; - } - - observer = new IntersectionObserver( - (observerEntries) => { - for (const entry of observerEntries) - visibility.set(entry.target.id, entry.isIntersecting); - computeActive(); - }, - { root: main, rootMargin: SECTION_NAV_ROOT_MARGIN, threshold: 0 }, - ); - - for (const section of currentSections) { - const el = document.getElementById(section.id); - if (el) observer.observe(el); - } + function handleScroll(): void { + if (pin?.settled) pin = null; + else if (pin) armSettleTimer(); + scheduleCompute(); } function scrollToInitialHashIfPresent(): void { @@ -99,27 +124,34 @@ export function useSectionNav(): UseSectionNavResult { if (!matches) return; hasScrolledToInitialHash = true; - document.getElementById(hashId)?.scrollIntoView({ block: 'start' }); + scrollToSection(hashId); } function refresh(): void { const discovered = discoverSections(main); - if (sectionsKey(discovered) === sectionsKey(currentSections)) return; - - currentSections = discovered; - setSections(discovered); - subscribe(); - scrollToInitialHashIfPresent(); + if (sectionsKey(discovered) !== sectionsKey(currentSections)) { + currentSections = discovered; + setSections(discovered); + scrollToInitialHashIfPresent(); + } + scheduleCompute(); } refresh(); + main.addEventListener('scroll', handleScroll, { passive: true }); + main.addEventListener('section-nav:pin', scheduleCompute); + const resizeObserver = new ResizeObserver(scheduleCompute); + resizeObserver.observe(main); const mutationObserver = new MutationObserver(refresh); mutationObserver.observe(main, { childList: true, subtree: true }); return () => { - observer?.disconnect(); + main.removeEventListener('scroll', handleScroll); + main.removeEventListener('section-nav:pin', scheduleCompute); + resizeObserver.disconnect(); mutationObserver.disconnect(); + if (rafId !== null) cancelAnimationFrame(rafId); setSections([]); setActiveSectionId(null); }; diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e8fb8a0d..40ed2559 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -66,7 +66,7 @@ Any change to a brand/status token must keep ≥4.5:1 contrast against its paire - **Forms:** use the shadcn `Form`/`FieldGroup` primitives with `FormLabel`; every input has an associated label; surface validation with `data-invalid` / inline `FormMessage`, not ad-hoc text. (Form wiring rules: `ENGINEERING.md` §4.) - **State surfaces:** all three required (`ENGINEERING.md` §4) — `` + skeleton for loading, `error.tsx`/inline error for failure, a designed empty state (icon + one line + primary action) for zero items. Secondary cards on a detail page may opt into `SectionCardEmpty`'s compact variant (one muted line, no icon/action) instead — the roomy form stays the default for dashboards. - **Focus & overlays:** never `outline-none` without a visible replacement; rely on Radix focus trapping in dialogs/sheets — don't break it with custom wrappers. -- **Section sub-nav.** A page section opts into the sidebar's in-page nav by giving its outermost `
` an `id` plus `data-section-nav="