Skip to content

feat: link interests to users + funnel continuity (#44) - #44

Merged
unforced merged 1 commit into
mainfrom
feat/interests-link-to-users
May 4, 2026
Merged

feat: link interests to users + funnel continuity (#44)#44
unforced merged 1 commit into
mainfrom
feat/interests-link-to-users

Conversation

@unforced

@unforced unforced commented May 4, 2026

Copy link
Copy Markdown
Collaborator

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)

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 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; 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 at insert; interest-then-signup back-links at user creation.

Admin /admin/interests

  • New Account column with a ✓ Linked badge (clickable → /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 (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 --noEmit clean (CI ci: type-check on PR + push to main #39 will run too)
  • npm run db:migrate:local applies cleanly; column added, backfill ran
  • After remote migration + deploy:
    • Submit /interest with an email that matches an existing user → DB row has user_id populated; /admin/interests shows ✓ Linked
    • Sign up a new Clerk account with an email that matches an existing unlinked interest row → after /auth/callback runs, the row has user_id set
    • /admin/interests?account=linked and ?account=unlinked filters work; reset link clears them
    • Signed-in visit to / → homepage hero shows "Welcome back" treatment, no inline form
    • Signed-out visit to / → email signup form (unchanged)
    • /interest/success differs by auth state
    • /dashboard shows the "Thanks for joining" banner only when an interest row is linked to the user

Notes for review

  1. Migration approval needed before I run 0021 — will ping per standing rule.
  2. Backfill is best-effort. If two users rows somehow share the same lowercased email (shouldn't happen with Clerk, but defensive), LIMIT 1 picks one arbitrarily. Acceptable since matched edge case.
  3. Already-existing duplicate interest rows for the same email all get the same user_id from the backfill, which is correct (they're all the same person).
  4. Dashboard banner picks the earliest signup as the canonical "joined the list on" date if the user has multiple linked rows. ORDER BY created_at ASC + LIMIT 1.
  5. /dashboard access — 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.
  6. First PR exercising CI on a substantial change — should see the typecheck job run.

🤖 Generated with Claude Code

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>
@unforced
unforced merged commit 72a0f7f into main May 4, 2026
1 check passed
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.

1 participant