Appearance
EAS enterprise daily report
Enterprise-wide rollup of the per-team daily responder report. One email per day to EAS leadership summarizing yesterday's voice-agent activity across all 6 EAS shops as a single enterprise total plus per-shop breakdowns.
Mirrors the per-team report's pipeline shape. Differs only at the metrics SQL (multi-team aggregation), the email template (branded, with per-shop cards), and the recipient storage (env var, not coach_config).
Why a new email instead of a forwarded rollup
EAS leadership wants one email with the full enterprise picture, not 6 forwarded per-shop emails. The aggregation needs to happen server-side so the email body shows enterprise totals alongside per-shop comparisons.
Audience and cadence
- Enterprise:
id = 117, "EAS Tire & Auto" - Teams: 6 in the DB, 5 in the report (all
America/Denver):- 2678 Ponderosa — excluded from report (testing instance, not a real shop). Still a member of the EAS enterprise in the DB; removal from the enterprise itself is an open decision.
- 2815 Chatfield
- 2816 Crestline
- 2817 Platte
- 2818 Quebec
- 2819 South Park
Exclusion list is in apps/web/lib/email/enterprise-report.ts:ENTERPRISE_REPORT_EXCLUDED_TEAM_IDS. Applied by filterEnterpriseReportTeamIds() before the SQL call. To exclude another team, add the id to the constant; the daily send path, weekly script, and future Inngest path all read from the same set.
- Recipients: Vercel prod env var
EAS_ENTERPRISE_REPORT_RECIPIENTS=email1,email2,... - Schedule: daily, fires from the same 4 AM ET cron as the per-team report. Delivery anchored to MT (Ponderosa's timezone, all teams share it).
What's in the email
Subject: Avoca Voice Dashboard Weekly Report - EAS Tire & Auto
Body, top to bottom:
- Branded header: navy bar, Avoca logo, enterprise name, period label + date range.
- Hero tiles: four prominent number cards across the top — Total Calls (with AI/human split), Leads (with % of calls), Booked (with % of leads), Transferred (with abandoned count as sublabel).
- Calls by Shop: horizontal bar chart, one bar per shop, length proportional to call volume, count on the right.
- Booking Rate by Shop: horizontal bar chart, one bar per shop, percentage with "—" for shops with zero leads.
- Per-Shop Detail: one card per shop with the full 9-row metric breakdown (the original 7 metrics + avg call duration + abandoned).
- Footer: standard Avoca attribution link.
Metrics
Per-shop and enterprise summary include:
| Metric | Source | Notes |
|---|---|---|
| Total calls | COUNT(*) | All display = true rows in window |
| Human calls | derived (total - ai) | |
| AI calls | COUNT(*) FILTER (WHERE ai_or_human = 'AI') | |
| Leads | COUNT(*) FILTER (WHERE is_bookable = true) | Lead rate shown as % of total calls |
| Booked | COUNT(*) FILTER (WHERE is_booked AND is_bookable) | Booking rate shown as % of leads |
| Recovered | COUNT(*) FILTER (WHERE NOT is_booked AND is_recovered) | Team-marked recoveries |
| Transferred | COUNT(*) FILTER (WHERE is_transferred) | |
| Avg call duration | AVG(duration) (seconds) | Formatted as m:ss |
| Abandoned | COUNT(*) FILTER (WHERE bad_hang_count > 0) | Proxy: public.calls has no explicit is_abandoned. Closest signal is bad_hang_count (set when a call ends abnormally). Swap to a different proxy if bad_hang_count proves misleading. |
Enterprise rollup uses SUM for counts and a call-weighted average for avg_duration_seconds.
Architecture
Two parallel crons feed the same handler. The weekly cron is the production path; the test-loop cron exists for the validation period and is silenced by unsetting one env var.
Code surfaces
Sibling to the per-team comp, lives in the same apps/web/lib/email/ and apps/web/emails/ directories. Per-team files untouched.
| Stage | File / function | Status |
|---|---|---|
| Weekly cron | lib/inngest/functions/enterprise-report-schedule.ts:scheduleEnterpriseReportWeekly (cron TZ=America/New_York 0 9 * * 1) | Code complete |
| Test-loop cron | lib/inngest/functions/enterprise-report-schedule.ts:scheduleEnterpriseReportTestLoop (cron */5 * * * *, gated by env) | Code complete |
| Inngest event | enterprise.send-report (payload includes period: 'daily' | 'weekly') | Code complete |
| Handler | lib/inngest/functions/enterprise-report-schedule.ts:sendEnterpriseResponderEmailReport (registered in app/api/inngest/route.ts) | Code complete |
| Build + send | apps/web/lib/email/enterprise-report.ts:send_enterprise_responder_email_report (now accepts period param) | Shipped |
| Metrics SQL | apps/web/lib/email/responder-report.ts:getResponderReportForTeams | Shipped |
| Template | apps/web/emails/enterprise/EnterpriseResponderReportEmail.tsx (accepts optional endDate + periodLabel) | Shipped |
| Test scripts | apps/web/scripts/send-eas-enterprise-report-test.ts (daily) + send-eas-enterprise-report-trailing-week-test.ts (weekly) | Shipped |
| Prod env var (recipients) | EAS_ENTERPRISE_REPORT_RECIPIENTS | Pending: set in Vercel after PR merge |
| Prod env var (test loop) | EAS_ENTERPRISE_REPORT_TEST_LOOP=true (unset to silence) | Pending: set in Vercel after PR merge |
Tester-caller filter
The display = true filter on calls already excludes Hamming simulations and team test phones from every reporting surface. What it didn't catch until this PR: calls FROM specific caller numbers (e.g., an FDE's cell phone calling into a real shop to validate a flow).
What the PR adds
A new helper isTesterCaller(callerId, teamId) queries the existing test_phone_numbers table for a per-team match on formatted_phone_number. Wired into apps/web/lib/call/prepare.ts:118 so:
ts
const shouldHideCall = isSimulation || isTestPhone || isTester;Result: when an FDE or Avoca employee calls into a shop from a number listed in that shop's test_phone_numbers, the resulting calls row gets display = false. Every reporting surface that reads display = true (AI Calls view, enterprise rollup, customer analytics, ST responder dashboards) silently filters it out.
What it does NOT do
- Workflow side-effects still run.
EndOfCallReportInngestFunction.ts:1007computestestCall = isHamming || isTestPhoneindependently —isTesteris NOT added to that calc. Tester calls still fire post-call workflows, still hit CRMs, still create real jobs. This is intentional: testing the post-call workflow against a mock CRM is a primary FDE use case, and we don't want the new tester flag to also gate side-effects. - Tool-call mocking is unaffected.
resolveToolCallMockonly mocks Hamming simulations. Tester calls hit real tools by design.
Storage choice
Reuses test_phone_numbers (team_id, formatted_phone_number, name). The table is team-scoped, already populated by the dashboard's Team → Outbound → Test Phone Numbers UI, and already has one reader (the outbound lead-attribution dashboard query in dashboard-api.ts).
The table's name reflects its original outbound context. The reporting-exclusion use case is a semantic stretch worth flagging:
Naming caveat
A shop admin adding a number to "Test Phone Numbers" today might reasonably expect it to only affect outbound lead attribution. With this PR, the same row also hides inbound calls from that number across all reporting. If this causes confusion, rename the table (or split into test_callers + test_phone_numbers) once a second use case adds pressure. For now, the data shape is identical and the UI surface is already there.
How to add a tester
- Get the tester's caller ID in E.164 format (e.g.,
+12035551234). - In the dashboard, navigate to Team → Outbound → Test Phone Numbers for the team being tested.
- Add the number with a name like "Sandy Corsillo (FDE)".
- Next call from that number to that team's voice agent will land with
display = false. Verify in the AI Calls view (won't appear) or by queryingcalls WHERE caller_id = '+1...'(will appear withdisplay = false).
Key design choices
Aggregate at SQL time, not at email-build time
getResponderReportForTeams(teamIds, start, end) uses team_id = ANY($1) with GROUP BY team_id in a single query, then sums in code to produce the enterprise rollup. One round trip to Postgres, structured { perShop[], enterprise } output.
The alternative would have been to call getResponderReportForTeam six times and concat. Chose SQL aggregation because:
- One round trip vs six.
- The enterprise rollup is a single derived shape — easier to extend (cross-team comparisons, ratios) without refactoring later.
- Reuses the existing query's filters (
display = true, etc.) verbatim. Less drift risk.
Returns explicit zero shape (not undefined like the per-team variant)
getResponderReportForTeam returns undefined when zero calls because the per-team caller wants to silently skip the team. Different semantics for the enterprise variant: a shop with zero activity should still render in the per-shop breakdown so leadership sees "South Park had zero calls today" not just "South Park missing."
The enterprise-level zero-activity decision (do we send the email at all when the whole enterprise had zero calls?) is made one level up in send_enterprise_responder_email_report. Default: skip the send entirely if enterprise.total_calls === 0. Switch to "send anyway with explicit zero framing" if Kareem prefers after seeing the first sample.
New template instead of reusing CoachReportEmail
CoachReportEmail could in principle handle this layout via its existing summaryData + tablesData props. Chose a new template because:
- EAS leadership is a different audience than per-shop GMs. Branded output (navy header, Avoca logo, per-shop cards as visual blocks rather than tabular rows) signals "enterprise-tier deliverable."
- Keeps Peter's existing template untouched. Any future redesign of the enterprise template doesn't risk breaking the per-team report.
- Color tokens borrowed from
apps/web/lib/api/scorecard-diagnostics-report.tsso the enterprise email's brand palette aligns with the Tier 2 Scorecard report (the v1 upgrade target).
Recipients in an env var, not coach_config
coach_config.email_recipients is per-team and doesn't have a natural shape for "enterprise-level recipient list." Three options were on the table:
- New table column (
enterprise_config.email_recipients) - Env var (
EAS_ENTERPRISE_REPORT_RECIPIENTS) - Hardcode
Picked env var for v0: simplest, prod-gated (empty env var means no email), and easy to flip without a deploy. v2 (the dashboard tool) moves this into a enterprise_report_configs table, but the v0/v1 path stays env-driven.
Test path
Local script (Path A)
Daily variant:
bash
cd apps/web && pnpm tsx scripts/send-eas-enterprise-report-test.tsWeekly variant (trailing 7 days):
bash
cd apps/web && pnpm tsx scripts/send-eas-enterprise-report-trailing-week-test.tsBoth hardcoded to enterprise 117, recipient sandy.corsillo@avoca.ai. Bypass cron + Inngest. Hit prod Resend + prod Supabase. Run as many times as needed to iterate the template; each run sends a fresh email.
Production test loop (post-deploy validation)
After the PR merges and the Vercel deploy lands:
- Set
EAS_ENTERPRISE_REPORT_RECIPIENTS=sandy.corsillo@avoca.ai(or any address you own) in Vercel production env. - Set
EAS_ENTERPRISE_REPORT_TEST_LOOP=truein Vercel production env. - Trigger a redeploy so the new env vars take effect.
- Within 5 minutes, the test-loop cron fires and emits the event. The handler sends the trailing-7d report. Check the inbox.
The test loop keeps firing every 5 minutes until EAS_ENTERPRISE_REPORT_TEST_LOOP is unset (or set to anything other than true). Leave it running through the weekend to validate reliability under repeated fires. Each fire sends the same trailing-7d window (since "yesterday" doesn't move within a 5-min window), so the content is consistent.
First production weekly fire
Once the test loop has run for a stretch and the shape is approved:
- Unset
EAS_ENTERPRISE_REPORT_TEST_LOOPin Vercel (or set tofalse) and redeploy. - The weekly cron continues to fire Mondays 9 AM ET as long as
EAS_ENTERPRISE_REPORT_RECIPIENTSis set. - When Kareem signs off, add EAS leadership to
EAS_ENTERPRISE_REPORT_RECIPIENTS=email1,email2,email3,...(comma-separated, no quotes) and redeploy.
Roadmap
v0 (Mon 2026-05-25 first real fire)
Tier 1b branded email. Weekly cadence with a validation-period test loop.
Status: Phases 1-5 code complete on feat/eas-enterprise-report. Awaiting PR review, merge, deploy, and Vercel env-var setup.
What lands in the PR:
getResponderReportForTeamsSQL helper (multi-team rollup, Ponderosa-excluded)EnterpriseResponderReportEmailtemplate (branded, accepts daily or weekly date range)send_enterprise_responder_email_reportbuild + send fn (period-aware)enterprise.send-reportInngest event typesendEnterpriseResponderEmailReporthandlerscheduleEnterpriseReportWeeklycron (Mon 9 AM ET, gated by recipients env var)scheduleEnterpriseReportTestLoopcron (every 5 min, gated by test-loop env var)- Test scripts (daily + weekly)
Post-merge operational steps live in Test path → Production test loop.
v1 (follow-on plan: enterprise-report-tier2-polish)
Tier 2 polish. Puppeteer-rendered branded report matching the Scorecard product's visual quality (summary tiles, per-shop comparison bars, methodology footer).
Two delivery options:
- Option A — PDF attachment. Email body becomes "Your report is attached." ~1 day.
- Option B — Hosted HTML page with email CTA. ~2 days, needs auth scaffolding.
Recommendation: Option A first. Option B becomes interesting after v2 ships and the hosted page can share auth surface with the dashboard tool.
v2 (follow-on plan: enterprise-reporting-dashboard-tool)
Self-serve enterprise reporting in the dashboard. Per-enterprise admin page where an Avoca employee picks schedule (daily/weekly/monthly), recipients, and included teams. Persisted in a new enterprise_report_configs table read by scheduleEmailReports.
Triggered when a 2nd enterprise asks for the same feature. Generalizing config storage for one user (EAS) is premature.
Plan + related docs
- Brief:
.indusk/planning/eas-weekly-enterprise-report/brief.md - Research:
.indusk/planning/eas-weekly-enterprise-report/research.md - Impl:
.indusk/planning/eas-weekly-enterprise-report/impl.md - Test plan:
.indusk/planning/eas-weekly-enterprise-report/test-plan.md - Comp: Per-team daily responder report
- Shared infra: Reporting architecture