Skip to content

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:

  1. Iterates RESPONDER_DAILY_REPORT_TEAM_IDS (hardcoded constant near the top of the file).
  2. For each team id, queries coach_config.email_recipients to get the recipient list. Skips the team if zero recipients.
  3. Calls getTeamDeliveryTimes(teamIds, cronFireTime) to compute a deliverAt per 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.
  4. 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:

  1. step.sleepUntil('wait-for-deliver-at', deliverAt) — Inngest holds the workflow until the team's local delivery time. Survives function deploys.
  2. 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_config row → query returns no rows → skip.
  • Team has a row but email_recipients is 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/supabase pull in the observability chain. apps/web/lib/supabase/supabase.ts imports Logger which transitively pulls in @avoca/observability/register.ts which 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/supabase crashes with ReferenceError: Cannot access 'require' before initialization. Workaround: scripts and script-callable helpers should import createServiceClient from @/utils/supabase/service directly. Next.js builds are unaffected.
  • @/lib/email/email.ts re-exports from coach-report.ts which uses Logger. Same chain. New script-callable helpers should live in their own file under lib/email/ and only get re-exported from email.ts after 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's tsconfig.json sets jsx: "preserve", which Next.js handles fine but esbuild (via tsx) leaves un-transformed without an explicit React in scope.