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/11] #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/11] #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/11] #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/11] #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/11] #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/11] #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/11] #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/11] #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/11] #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/11] #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/11] #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