Demo-Result Tracking — Acceptance Spec
Status: APPROVED 2026-05-22. This spec is the source of truth: code that doesn't match it is wrong.
Scope: After a cold-outreach prospect opens their demo, capture how deeply they engaged — viewed the demo, chatted with the demo chatbot (and how many messages), clicked a conversion CTA — and surface it per prospect in the Outreach Engine dashboard. The founder uses it to decide which demos to follow up on first.
The problem this solves
The cold-outreach funnel already tracks the click (outreach_contacts.pro_website_clicked_at) and fires demo funnel events (demo_viewed, demo_chat_message, demo_upgrade_clicked) to PostHog via /api/analytics/demo-event. But those events live only in PostHog — the founder, working from the Outreach Engine dashboard, cannot see per prospect whether a demo landed. A prospect who chatted ten messages and clicked "book a call" looks identical to one who never opened the link.
Foundational decisions
- Persist alongside PostHog, don't replace it. The demo-event route keeps capturing to PostHog (portfolio funnel analytics); it additionally writes per-prospect engagement to
outreach_contacts. The dashboard reads the DB, not PostHog. - Keyed on
demo_slug. The demo-event payload'soutreach_slugmatchesoutreach_contacts.demo_slug. A/s/[slug]that is not a prospect (a real church's site) matches no row — engagement recording is naturally scoped, never errors. - Best-effort, never blocks. Recording engagement is wrapped so a DB hiccup never breaks the demo experience or loses the PostHog event.
- Set-once timestamps + a running count.
*_atcolumns are set on first occurrence (the first view, first chat, first CTA click); the chat message count increments every message;demo_last_engaged_atupdates on every event (the recency signal).
Data model
New columns on outreach_contacts (all additive, nullable; count defaults 0):
| Column | Meaning |
|---|---|
demo_viewed_at | First time the prospect opened the demo page |
demo_chatted_at | First message the prospect sent to the demo chatbot |
demo_chat_message_count | Total messages the prospect sent to the demo chatbot |
demo_cta_clicked_at | First time the prospect clicked a conversion CTA on the demo |
demo_last_engaged_at | Most recent engagement of any kind |
Plus a Postgres function record_demo_engagement(p_slug text, p_event text) — an atomic UPDATE applying the set-once / increment rules, called by the demo-event route.
Event → column mapping
Updated 2026-06-11 (funnel instrumentation repair — migrations/2026-06-11_record-demo-engagement-cta-checkout.sql). demo_cta_clicked_at means "clicked ANY conversion CTA"; demo_claim_clicked_at is the claim-specific subset.
| Demo event (already fired) | Effect on the matching outreach_contacts row |
|---|---|
demo_viewed | demo_viewed_at = COALESCE(demo_viewed_at, now()); demo_last_engaged_at = now() |
demo_chat_message | demo_chatted_at = COALESCE(demo_chatted_at, now()); demo_chat_message_count += 1; demo_last_engaged_at = now() |
demo_upgrade_clicked | demo_cta_clicked_at = COALESCE(demo_cta_clicked_at, now()); demo_last_engaged_at = now() |
demo_claim_clicked | demo_claim_clicked_at = COALESCE(demo_claim_clicked_at, now()); ALSO demo_cta_clicked_at = COALESCE(demo_cta_clicked_at, now()) (the claim CTA is a conversion CTA); demo_last_engaged_at = now() |
demo_checkout_started | demo_checkout_started_at = COALESCE(demo_checkout_started_at, now()) (client-beacon backup; /api/demo/claim stamps it server-side on success, COALESCE first-wins); demo_last_engaged_at = now() |
demo_voice_call_started | demo_voice_started_at = COALESCE(demo_voice_started_at, now()); demo_last_engaged_at = now() |
demo_voice_call_completed | demo_voice_completed_at = COALESCE(demo_voice_completed_at, now()); demo_last_engaged_at = now() |
Slug contract (load-bearing): the beacon's outreach_slug MUST be the prospect's demo_slug (== premium_churches.vanity_slug for 100% of prospects). The pre-2026-06-11 ServiceBusinessTemplate sent the directory church.slug first, which matches demo_slug for only ~9.5% of prospects — record_demo_engagement silently updated zero rows and demo_cta_clicked_at stayed 0 forever. Always derive the analytics slug vanity_slug-first.
Bot filtering: /api/analytics/demo-event classifies the visitor (classifyVisitor) and skips the record_demo_engagement call for likely bots; /api/outreach/track/click only stamps clicked_at for non-bot visitors (bot events are still logged to outreach_events with likely_bot: true for triage). Reporting must never count raw email_clicked events — use distinct non-bot prospects (scripts/demo-funnel.mjs clicked_h).
Expected output — the founder in the Outreach Engine dashboard
/founder/[token]/outreach-engine → Prospects tab → the prospect table gains an Engagement column:
- Never opened the demo: "—" (grey).
- Viewed only: a grey
Viewedchip. - Chatted: a blue
Chatted · Nchip (N = message count). - Clicked a CTA: a green
Clicked CTAchip. - Chips are cumulative — a prospect who did all three shows all three.
- Below the chips, a relative recency line ("3h ago", "2d ago") from
demo_last_engaged_at.
This makes the highest-intent prospects (chatted a lot, clicked the CTA, recently) visually obvious in the list — the founder follows up with those first.
Regression guardrails
- The demo-event route's PostHog capture is unchanged; engagement recording is an additive step before it.
- A non-prospect
/s/[slug](real church) generates demo events that match nooutreach_contactsrow — zero rows updated, no error. - The Outreach Engine dashboard's existing columns and tabs are unchanged; the Engagement column is purely additive (the empty-state
colSpanis bumped to match). - Engagement columns are nullable — every existing
outreach_contactsrow is valid with nulls.
Out of scope (v1)
- Per-message transcript capture (PostHog session view covers ad-hoc inspection).
- Voice-demo call depth beyond a recency touch.
- Email/SMS notifications when a prospect engages — a possible fast-follow (the data now exists to trigger one).