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
| 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_voice_call_started / demo_voice_call_completed | demo_last_engaged_at = now() |
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).