Repository navigation
feat(#562): record that somebody asked to book, before they buy anything - #1164
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
leadstable, acreate-leadEdge Function that is its only writer, and aBookingCtaon/pricingthat 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
/scheduleasecond 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
leadshas no client write path at all:anonandauthenticatedare REVOKEd, the only policypermitting anything is an admin SELECT, and the function holds the service-role key. No
SECURITY DEFINERRPC — 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.mjsasserts grants only for tables the filehas 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,emailandstatusare 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_attemptsattempt_type CHECK is widened by DROP+ADD, because an inline edit to aCREATE TABLE IF NOT EXISTSis a silent no-op on a provisioned database — the exact shape #784 hitwith
'contact_form'. Verified live after applying: the table exists with RLS on,anonrevoked,authenticatedSELECT-only,service_rolefull, andbooking_leada permittedattempt_type.Only the new block was applied — never the whole monolith, which carries a
DELETE FROM auth.usersand two
DROP TABLE … CASCADE. The extraction script asserts neither appears in what it sends.create-leadis deployed (v1, ACTIVE,verify_jwt: false) and its two test rows have been deleted.Testing
Every decision lives in a dependency-free
resolve.tsso Vitest can load it. Nothing in CI executesan Edge Function —
tsconfigexcludessupabase/, Vitest excludessupabase/functions/**, anddeno checkis deliberately not a required check. A rule written inside aDeno.servehandler is arule nothing tests.
5,814 unit tests, 961 script tests, 141/141 structure,
deno checkclean across 17 functions.Also adds
leadsto the generated Supabase types, whichscripts/__tests__/supabase-types-schema.test.jsrequires — that guard exists because the types oncedescribed 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