Skip to main content

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

  1. 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.
  2. Keyed on demo_slug. The demo-event payload's outreach_slug matches outreach_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.
  3. Best-effort, never blocks. Recording engagement is wrapped so a DB hiccup never breaks the demo experience or loses the PostHog event.
  4. Set-once timestamps + a running count. *_at columns are set on first occurrence (the first view, first chat, first CTA click); the chat message count increments every message; demo_last_engaged_at updates on every event (the recency signal).

Data model

New columns on outreach_contacts (all additive, nullable; count defaults 0):

ColumnMeaning
demo_viewed_atFirst time the prospect opened the demo page
demo_chatted_atFirst message the prospect sent to the demo chatbot
demo_chat_message_countTotal messages the prospect sent to the demo chatbot
demo_cta_clicked_atFirst time the prospect clicked a conversion CTA on the demo
demo_last_engaged_atMost 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_vieweddemo_viewed_at = COALESCE(demo_viewed_at, now()); demo_last_engaged_at = now()
demo_chat_messagedemo_chatted_at = COALESCE(demo_chatted_at, now()); demo_chat_message_count += 1; demo_last_engaged_at = now()
demo_upgrade_clickeddemo_cta_clicked_at = COALESCE(demo_cta_clicked_at, now()); demo_last_engaged_at = now()
demo_voice_call_started / demo_voice_call_completeddemo_last_engaged_at = now()

Expected output — the founder in the Outreach Engine dashboard

/founder/[token]/outreach-engineProspects tab → the prospect table gains an Engagement column:

  • Never opened the demo: "—" (grey).
  • Viewed only: a grey Viewed chip.
  • Chatted: a blue Chatted · N chip (N = message count).
  • Clicked a CTA: a green Clicked CTA chip.
  • 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

  1. The demo-event route's PostHog capture is unchanged; engagement recording is an additive step before it.
  2. A non-prospect /s/[slug] (real church) generates demo events that match no outreach_contacts row — zero rows updated, no error.
  3. The Outreach Engine dashboard's existing columns and tabs are unchanged; the Engagement column is purely additive (the empty-state colSpan is bumped to match).
  4. Engagement columns are nullable — every existing outreach_contacts row 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).