Skip to content

feat(#562): record that somebody asked to book, before they buy anything - #1164

Merged
TortoiseWolfe merged 1 commit into
mainfrom
feat/562-slice1-lead-capture
Sep 10, 2026
Merged

TortoiseWolfe merged 1 commit into
mainfrom
feat/562-slice1-lead-capture

Conversation

@TortoiseWolfe

Copy link
Copy Markdown
Owner

Slice 1 of Feature 050 Phase 3b. The webhook half is deliberately absent — #562's body records
why it cannot work on Cal.com today.

What ships

A leads table, a create-lead Edge Function that is its only writer, and a BookingCta on
/pricing that records the click and then goes to the booking surface. US-6 scenarios 1, 3 and 4,
with no dependency on a webhook.

Why it links to /schedule rather than out to Cal.com

The spec asked for a plain outbound anchor, and gave its reason: the webhook would fill in the
visitor's name and email later, so nobody meets a form before a calendar. Cal.com does not deliver
the identifier that join needs
— it stores auto-tracked UTM parameters in a separate table readable
only in its own logged-in UI, and the request to forward them has been open upstream since
2025-10-29.

With the backfill gone, leaving the site buys a click and nothing more, while making /schedule a
second booking surface that records nothing — the split that produced #1092. So: one surface,
recorded at the door. A lead says somebody wanted to talk and nothing about who, which is honest,
and more than the operator could see before.

Security shape, driven live rather than assumed

anon SELECT on leads   →  401 42501 permission denied
anon INSERT on leads   →  401 42501 permission denied
bad source             →  400 from the function, never reaches Postgres
utm/name/status sent   →  201, and none of it stored
CORS preflight         →  204

leads has no client write path at all: anon and authenticated are REVOKEd, the only policy
permitting anything is an admin SELECT, and the function holds the service-role key. No
SECURITY DEFINER RPC — the function is the reason none is needed.

The REVOKE is load-bearing rather than tidy: silence would leave both client roles holding all seven
privileges through pg_default_acl, including TRUNCATE, which RLS does not gate (#1039, #1073).
It also has a second effect — derive-intended-schema.mjs asserts grants only for tables the file
has REVOKEd from, so a table without one is invisible to the prod drift gate.

FR-024a says the link may carry campaign parameters and the table may not store them. The row is
built by naming two fields rather than by spreading the body and deleting what we don't want,
which is what makes that hold for parameters nobody has thought of yet. name, email and status
are real columns and are not accepted from the request either — only a confirmed booking can fill
them honestly, and accepting them would let any caller write any name against any lead.

The migration is applied, not just written

The rate_limit_attempts attempt_type CHECK is widened by DROP+ADD, because an inline edit to a
CREATE TABLE IF NOT EXISTS is a silent no-op on a provisioned database — the exact shape #784 hit
with 'contact_form'. Verified live after applying: the table exists with RLS on, anon revoked,
authenticated SELECT-only, service_role full, and booking_lead a permitted attempt_type.

Only the new block was applied — never the whole monolith, which carries a DELETE FROM auth.users
and two DROP TABLE … CASCADE. The extraction script asserts neither appears in what it sends.

create-lead is deployed (v1, ACTIVE, verify_jwt: false) and its two test rows have been deleted.

Testing

Every decision lives in a dependency-free resolve.ts so Vitest can load it. Nothing in CI executes
an Edge Function — tsconfig excludes supabase/, Vitest excludes supabase/functions/**, and
deno check is deliberately not a required check. A rule written inside a Deno.serve handler is a
rule nothing tests.

5,814 unit tests, 961 script tests, 141/141 structure, deno check clean across 17 functions.

Also adds leads to the generated Supabase types, which
scripts/__tests__/supabase-types-schema.test.js requires — that guard exists because the types once
described two tables the migration had already dropped (#565).

Refs #562. Does not close it: T039 and T042 wait on Slice 0, the owner-side dashboard experiment
recorded in the issue.

🤖 Generated with Claude Code

Slice 1 of Phase 3b. The webhook half is deliberately not here — see #562's
body for why it cannot work on Cal.com today.

WHAT SHIPS: a `leads` table, a `create-lead` Edge Function that is its only
writer, and a `BookingCta` on /pricing that records the click and then goes to
the booking surface. US-6 scenarios 1, 3 and 4, with no dependency on a webhook.

WHY IT LINKS TO /schedule RATHER THAN OUT TO CAL.COM. The spec asked for a plain
outbound anchor and gave its reason: the webhook would fill in the visitor's
name and email later, so nobody meets a form before a calendar. Cal.com does not
deliver the identifier that join needs — it stores auto-tracked UTM parameters
in a separate table readable only in its own logged-in UI, and the request to
forward them has been open upstream since 2025-10-29. With the backfill gone,
leaving the site buys a click and nothing more, while making /schedule a second
booking surface that records nothing. That is the split that produced #1092.
One surface, recorded at the door.

So a lead says somebody wanted to talk, and nothing about who. That is honest,
and it is more than the operator could see before.

SECURITY SHAPE, ALL DRIVEN LIVE RATHER THAN ASSUMED:

  anon SELECT on leads   -> 401 42501 permission denied
  anon INSERT on leads   -> 401 42501 permission denied
  bad source             -> 400 from the function, never reaches Postgres
  utm/name/status sent   -> 201, and none of it stored

`leads` has no client write path at all: anon and authenticated are REVOKEd, the
only policy that permits anything is an admin SELECT, and the Edge Function holds
the service-role key. No SECURITY DEFINER RPC — the function is the reason none
is needed. The REVOKE is load-bearing rather than tidy: silence would leave both
client roles holding all seven privileges through pg_default_acl, including
TRUNCATE, which RLS does not gate (#1039, #1073).

FR-024a says the link may carry campaign parameters and the table may not store
them. The row is built by NAMING two fields rather than by spreading the body and
deleting what we do not want, which is what makes it hold for parameters nobody
has thought of yet. `name`, `email` and `status` are real columns and are not
accepted from the request either — only a confirmed booking can fill them
honestly, and accepting them would let any caller write any name against any lead.

THE MIGRATION IS APPLIED, NOT JUST WRITTEN. The `rate_limit_attempts`
attempt_type CHECK is widened by DROP+ADD, because an inline edit to a
CREATE TABLE IF NOT EXISTS is a silent no-op on a provisioned database — the
exact shape #784 hit with 'contact_form'. Verified live: the table exists with
RLS on, anon revoked, authenticated SELECT-only, and 'booking_lead' permitted.

Every decision lives in a dependency-free resolve.ts so Vitest can load it.
Nothing in CI executes an Edge Function: tsconfig excludes supabase/, Vitest
excludes supabase/functions/**, and deno check is deliberately not a required
check. A rule written inside a Deno.serve handler is a rule nothing tests.

Also adds `leads` to the generated Supabase types, which
scripts/__tests__/supabase-types-schema.test.js requires — it exists because
types once described two tables the migration had already dropped (#565).

Refs #562. Does not close it: T039 and T042 wait on Slice 0, the owner-side
dashboard experiment recorded in the issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@TortoiseWolfe
TortoiseWolfe merged commit 8871511 into main Sep 10, 2026
26 checks passed
@TortoiseWolfe
TortoiseWolfe deleted the feat/562-slice1-lead-capture branch September 10, 2026 19:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants