Appearance
Reporting architecture
The shared pipeline every responder report rides. One cron, one Inngest event family, one Resend send path. Per-report variants plug in at the metrics SQL, the email template, and the build-and-send function.
Pipeline
Stages
Cron fire
apps/web/lib/inngest/functions/coach-report-schedule.ts:scheduleEmailReports on Inngest cron expression 0 4 * * * interpreted in ET. Single-concurrency function.
Prod-only gate. Lines 193-199 hard-return when either process.env.NODE_ENV !== 'production' or process.env.NEXT_PUBLIC_VERCEL_ENV !== 'production'. Means the cron never fires locally, in CI, or on Vercel preview deployments. Only the production Vercel deployment runs the actual scheduler.
Implication for development
The full cron-to-Resend path is impossible to exercise outside production. Local iteration must bypass the cron entirely. See each report's "Test path" section.
Fan-out per recipient group
Same file. The scheduler:
- Iterates
RESPONDER_DAILY_REPORT_TEAM_IDS(hardcoded constant near the top of the file). - For each team id, queries
coach_config.email_recipientsto get the recipient list. Skips the team if zero recipients. - Calls
getTeamDeliveryTimes(teamIds, cronFireTime)to compute adeliverAtper team based on each team's timezone (default 9 AM local). Groups teams that share a delivery time so each Inngest event covers one delivery group. - Emits
inngest.send({ name: 'responder.send-daily-report', data: { deliverAt, cronFireTime, reportParams: [{ teamId, emails }, ...] } })per delivery group.
cronFireTime is captured once at the top of scheduleEmailReports and threaded through every downstream call. Stable across retries so the date-range query is deterministic.
Handler
apps/web/app/api/inngest/route.ts:sendResponderEmailReport. Listens for responder.send-daily-report. Two steps:
step.sleepUntil('wait-for-deliver-at', deliverAt)— Inngest holds the workflow until the team's local delivery time. Survives function deploys.step.run('send', () => send_batch_responder_email_reports({ reportParams, cronFireTime }))— actual send.
Concurrency { limit: 1 } per the function id.
Build + send
apps/web/lib/email/email.ts:send_batch_responder_email_reports (line 193 as of main). Loops reportParams. Per param:
- Skips if team id is in
disabledTeamIds = [204, 441]. - Skips if no recipients.
- Looks up team name from
teams.name. - Computes start/end via
getReportDateRange(teamId, cronFireTime). - Calls the metrics SQL helper.
- Calls
getResponderEmailPayload(...)to build the React element + subject + recipient list.
Returns when the batch is built; sends all payloads in one resend.batch.send(emailRequests) call.
Metrics SQL
apps/web/lib/email/responder-report.ts:getResponderReportForTeam. Single-team COUNT(*) FILTER (WHERE ...) aggregation on the calls table. Filter: team_id = $1 AND display = true AND created_at BETWEEN $start AND $end.
display = true is load-bearing: it's how test phones, Hamming simulations, and other non-customer-facing rows are excluded from the metrics. Any future metrics query that drops display = true will silently start counting test traffic.
Returns undefined when the team had zero calls in the window, which send_batch_responder_email_reports interprets as "skip this team entirely, no email."
Template
apps/web/emails/coach/CoachReportEmail.tsx. Plain @react-email/components template. No logo. No brand color palette. Black labels, blue right-aligned values, grey background, white card. Optimized for cross-client deliverability, not visual polish.
Naming
The template is called CoachReportEmail for historical reasons. It serves both the Coach daily report (an older product) and the Voice Dashboard daily responder report (the surface this section documents). Same template, same scheduler function, same recipient table (coach_config.email_recipients). Differs only in metrics query, subject line, and Inngest event name. Treat the shared name as load-bearing, not a refactor target.
Resend send
resend.batch.send([...payloads]) from apps/web/lib/resend.ts. Returns { data: [{ id: '<uuid>' }, ...] | null, error: ResendError | null }. Build/send functions throw if error is non-null.
Resend dashboard logs every send. The Resend id returned is the lookup key for delivery status.
Patterns
Recipient storage
Today's surface uses coach_config.email_recipients which is per-team. Three failure modes the scheduler accounts for:
- Team has no
coach_configrow → query returns no rows → skip. - Team has a row but
email_recipientsis empty → skip. - Team is in
disabledTeamIds→ skip even if recipients exist.
For a report whose recipient set isn't per-team (e.g. enterprise rollup), coach_config doesn't fit. See the enterprise weekly report for the env-var-based recipient pattern.
Validation-period test loops
For high-confidence rollouts of new report surfaces (especially weekly cadence reports where waiting until the first Monday fire to find a bug is expensive), pair the production cron with a test-loop cron at */5 * * * * that's gated by a separate env var. Both crons emit the same Inngest event with the same payload; the handler is identical for both. Unset the test-loop env var to silence. See the enterprise weekly report's Production test loop for the pattern.
Delivery-time computation
getReportDateRange(teamId, cronFireTime) in apps/web/lib/email/report-schedule.ts takes the team's timezone and returns yesterday's midnight-to-midnight as UTC Date objects. Two implications:
- A single cron fire at 4 AM ET produces 24+ different start/end pairs across teams (one per team timezone). The date range is correct relative to each team's calendar day.
- For an enterprise where every team shares one timezone (e.g. EAS is all
America/Denver), passing any team id yields the same range.
Zero-activity handling
Per-team report: SQL returns undefined when zero calls → email is skipped silently.
Enterprise report: SQL returns an explicit zero shape (per-shop and enterprise rollup all zero) → the build-and-send function explicitly decides whether to email or skip. See the enterprise doc for why.
Test paths
Three documented patterns for exercising the pipeline outside production:
Path A (local script). Direct call into the build-and-send function from a script under apps/web/scripts/. Bypasses cron and Inngest entirely. Uses the same Resend API key (production). Fastest iteration. The pattern is import './_load-env' first, then import { send_xxx } from '@/lib/email/...'. See per-team and enterprise pages for example scripts.
Path B (local Inngest dev UI). With INNGEST_DEV=true, manually queue the Inngest event from the dev UI or a local API route. Tests the handler + send chain. Doesn't test the scheduler hook.
Path C (prod cron). Ship to prod, wait for the 4 AM ET cron, watch logs. End-to-end real, one-shot per day, requires a deploy.
Default: Path A for iteration; Path C only after stakeholder sign-off on the email shape.
Gotchas
- Imports that touch
@/lib/supabasepull in the observability chain.apps/web/lib/supabase/supabase.tsimportsLoggerwhich transitively pulls in@avoca/observability/register.tswhich imports@vercel/otel. The Vercel OTel module is ESM-only and tsx (the script runner) can't compile it cleanly, so any script that imports through@/lib/supabasecrashes withReferenceError: Cannot access 'require' before initialization. Workaround: scripts and script-callable helpers should importcreateServiceClientfrom@/utils/supabase/servicedirectly. Next.js builds are unaffected. @/lib/email/email.tsre-exports fromcoach-report.tswhich uses Logger. Same chain. New script-callable helpers should live in their own file underlib/email/and only get re-exported fromemail.tsafter Phase 4 wire-up; the script imports the per-file path.- Scripts that import React templates need
import * as React from 'react'at the top of the template file. The repo'stsconfig.jsonsetsjsx: "preserve", which Next.js handles fine but esbuild (via tsx) leaves un-transformed without an explicit React in scope.