Appearance
Per-team daily responder report
The original daily metrics email. One email per team per day, sent to the per-team recipient list, summarizing yesterday's voice-agent activity for that team.
This is the comp for the EAS enterprise daily report. The enterprise variant mirrors this surface's shape; if a structural change happens here, the enterprise variant probably needs the same change.
Audience and cadence
- Recipients: per-team via
coach_config.email_recipients. - Schedule: cron fires daily at 4 AM ET, but per-team delivery is shifted to that team's configured local hour (default 9 AM in the team's timezone via
coach_config.report_delivery_time_local). - Active team set:
RESPONDER_DAILY_REPORT_TEAM_IDS = [1006]as ofmainon 2026-05-21. Single team in this list today. - Disabled list: teams
[204, 441]are hardcoded skip targets insend_batch_responder_email_reports.
What's in the email
Subject: Avoca Voice Dashboard Daily Report - {teamName}
Body: seven summary rows (no per-shop tables since this is single-team):
| Row | Source |
|---|---|
| All Calls (Human + AI) | COUNT(*) FROM calls WHERE team_id = $1 AND display = true AND ... |
| Human Calls | total_calls - ai_calls |
| AI Calls | COUNT(*) FILTER (WHERE ai_or_human = 'AI') |
| Leads | COUNT(*) FILTER (WHERE is_bookable = true), with (leadRate%) |
| Booked Calls | COUNT(*) FILTER (WHERE is_booked = true AND is_bookable = true), with (bookingRate%) |
| Recovered (team-marked) | COUNT(*) FILTER (WHERE is_booked = false AND is_recovered = true) |
| Transferred | COUNT(*) FILTER (WHERE is_transferred = true) |
leadRate = bookable_calls / total_calls. bookingRate = booked_calls / bookable_calls.
Date range: yesterday's midnight-to-midnight in the team's timezone.
Architecture
This is the canonical five-stage pipeline documented under Shared architecture. Per-stage file references for this surface:
| Stage | File / function |
|---|---|
| Cron + scheduler | lib/inngest/functions/coach-report-schedule.ts:scheduleEmailReports (per-team loop near line 300) |
| Inngest event | responder.send-daily-report |
| Handler | app/api/inngest/route.ts:sendResponderEmailReport |
| Build + send | lib/email/email.ts:send_batch_responder_email_reports (line 193) |
| Metrics SQL | lib/email/responder-report.ts:getResponderReportForTeam |
| Template | emails/coach/CoachReportEmail.tsx |
Why CoachReportEmail
The template name is historical. The Coach product (older) and Voice Dashboard responder reports share the template, the recipient table, and the scheduler function. They differ only in metrics and Inngest event name. See the shared architecture page for the naming inheritance.
Failure modes
- No
coach_configrow OR emptyemail_recipients→ team is silently skipped in the scheduler loop. No event emitted. No log. - Team in
disabledTeamIds→send_batch_responder_email_reportsskips during the build loop. No log. - Zero calls in the window →
getResponderReportForTeamreturnsundefined→ team is silently skipped (no email, no log). - Resend error →
send_batch_responder_email_reportsthrows → Inngest retries thesendstep per its default retry policy. The team's email may eventually fail permanently. - Cron paused / errored in Inngest → no events emitted. Visible in Inngest dashboard, not in app logs.
Operational notes
Adding a team
- Add the team id to
RESPONDER_DAILY_REPORT_TEAM_IDSincoach-report-schedule.ts. Open a PR. - Make sure the team has a
coach_configrow withemail_recipientspopulated and (optionally)report_delivery_time_localset. - After PR merge, the next 4 AM ET cron fire will include the team.
Forcing a test send to one team
Use the Path A pattern: write a one-off script under apps/web/scripts/ that imports the build-and-send helper directly. The pattern from apps/web/scripts/send-eas-enterprise-report-test.ts applies; reduce reportParams to a single { teamId, emails: ['<your-email>'] }.
Direct example sketch:
ts
#!/usr/bin/env node
import './_load-env';
import { send_batch_responder_email_reports } from '@/lib/email/email';
async function main() {
await send_batch_responder_email_reports({
reportParams: [{ teamId: 1006, emails: ['sandy.corsillo@avoca.ai'] }],
cronFireTime: new Date(),
});
}
main().then(() => process.exit(0));Caveat: this hits production Resend and queries production Supabase. Use a recipient you own. Don't point at customer addresses.
Test calls excluded
The display = true filter in getResponderReportForTeam excludes test phone calls and Hamming simulation calls from the metrics. If a future refactor loosens that filter, the per-shop totals will start including test traffic. Pin this in tests when modifying the SQL.