feat: link interests to users + funnel continuity (#44) - #44
Merged
Conversation
Interest signups are now the structural first step of the funnel — interest → signup → application → enrollment, all linked by user_id. Closes the silo where someone joining the list and later creating an account had no DB connection between the two events. ## Schema (migration 0021) ALTER TABLE interests ADD COLUMN user_id INTEGER REFERENCES users(id); UPDATE interests SET user_id = ( SELECT id FROM users WHERE LOWER(users.email) = LOWER(interests.email) LIMIT 1 ) WHERE user_id IS NULL; Backfill is case-insensitive since legacy interest rows pre-date the lowercase-on-insert convention. Schema-only addition; nullable FK so unmatched legacy rows stay. ## Bidirectional auto-link - src/routes/interest.tsx (POST /api/interests): query users by email before insert; set userId on the new row if a match exists. - src/lib/auth.ts (syncUser): after upserting a new Clerk user, run UPDATE interests SET user_id WHERE LOWER(email) = LOWER(?) AND user_id IS NULL. Best-effort, logged on failure but doesn't block signup. The two paths are symmetric — signup-then-interest links forward, interest-then-signup back-links. ## Admin /admin/interests - New "Account" column with a "✓ Linked" badge (clickable through to /admin/accounts/<userId>) when user_id is set; "—" otherwise. - New filter: ?account=linked|unlinked. Same URL-as-source-of-truth pattern as the existing filters. ## Funnel continuity - Homepage hero: signed-in visitors see "Welcome back, <name>. You're already in. [Go to your dashboard →]" instead of the email signup form. Signed-out visitors see the existing inline form. - /interest/success: signed-in visitors see "Want to take the next step?" with [Apply] + [Dashboard] CTAs. Signed-out visitors see a subtle "Want to go deeper now? [Create an account →]" link. - /dashboard: small banner at the top "Thanks for joining the interest list on <date>." when the user has a linked interest row. ## CLAUDE.md Added a Funnel model section documenting the bidirectional linking pattern so future agents understand the invariant. Closes #44. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merged
2 tasks
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.
Summary
Interest signups are now the structural first step of the funnel — interest → signup → application → enrollment, all linked by
user_id. Closes the silo where someone joining the list and later creating an account had no DB connection between the two events.Closes #44.
Schema (migration 0021)
Backfill is case-insensitive since legacy interest rows pre-date the lowercase-on-insert convention from #25. Schema-only addition; FK is nullable so unmatched legacy rows stay valid.
Local migrate verified — column added, existing rows backfilled where users.email matches.
Bidirectional auto-link
src/routes/interest.tsx(POST /api/interests): query users by email before insert; setuserIdon the new row if a match exists.src/lib/auth.ts(syncUser): after upserting a new Clerk user, runUPDATE interests SET user_id WHERE LOWER(email) = LOWER(?) AND user_id IS NULL. Best-effort — logged on failure but doesn't block signup.The two paths are symmetric: signup-then-interest links forward at insert; interest-then-signup back-links at user creation.
Admin
/admin/interests✓ Linkedbadge (clickable →/admin/accounts/<userId>) whenuser_idis set;—otherwise.?account=linked|unlinked. Same URL-as-source-of-truth pattern as the existing filters.Funnel continuity
<name>. You're already in. [Go to your dashboard →]" instead of the email signup form. Signed-out visitors see the existing inline form (post-feat: simplify interest signup to email-only #43)./interest/success: signed-in visitors see "Want to take the next step?" with [Apply →] + [Dashboard →] CTAs. Signed-out visitors see a subtle "Want to go deeper now? [Create an account →]" link./dashboard: small banner at the top — "Thanks for joining the interest list on<date>." — when the signed-in user has a linked interest row.CLAUDE.md
Added a Funnel model section documenting the bidirectional linking pattern so future agents understand the invariant.
Test plan
npx tsc --noEmitclean (CI ci: type-check on PR + push to main #39 will run too)npm run db:migrate:localapplies cleanly; column added, backfill ran/interestwith an email that matches an existing user → DB row hasuser_idpopulated;/admin/interestsshows ✓ Linked/auth/callbackruns, the row hasuser_idset/admin/interests?account=linkedand?account=unlinkedfilters work; reset link clears them/→ homepage hero shows "Welcome back" treatment, no inline form/→ email signup form (unchanged)/interest/successdiffers by auth state/dashboardshows the "Thanks for joining" banner only when an interest row is linked to the userNotes for review
usersrows somehow share the same lowercased email (shouldn't happen with Clerk, but defensive),LIMIT 1picks one arbitrarily. Acceptable since matched edge case.user_idfrom the backfill, which is correct (they're all the same person).created_atASC + LIMIT 1./dashboardaccess — the route already redirects unauthed users to/sign-in, so the interest-banner read is always for the signed-in user. No public-page leakage.🤖 Generated with Claude Code