Skip to content

feat(invites): ADR-0011 Phase 2B — claim resolution + athlete routes + auth#419

Open
theianjones wants to merge 3 commits intofeat/competition-invites-phase-2from
feat/competition-invites-phase-2-claim
Open

feat(invites): ADR-0011 Phase 2B — claim resolution + athlete routes + auth#419
theianjones wants to merge 3 commits intofeat/competition-invites-phase-2from
feat/competition-invites-phase-2-claim

Conversation

@theianjones
Copy link
Copy Markdown
Contributor

@theianjones theianjones commented Apr 23, 2026

Summary

Stacked on top of #418 (Phase 2A). Adds the athlete-facing claim flow: token resolution, identity-match, the three routes the email link points at, and ?invite=<token>&email=<email> support on sign-in / sign-up.

Flow (mike@wodsmith.com seeded pending invite)

  1. Athlete clicks /compete/2026-wodsmith-invitational/claim/<token>.
  2. Loader calls getInviteByTokenFn — hashes the token, looks up the row on claim_token_hash, verifies championship slug matches, checks claimability, resolves the target division label + whether a WODsmith account exists for the invited email.
  3. Loader runs identityMatch(context.session, invite, { accountExistsForInviteEmail }) and branches:
    • Signed in as the invite email → render pre-attached registration CTA (hands off to existing /compete/$slug/register flow; sub-arc C wires the inviteToken param end-to-end through Stripe).
    • Signed in as a different email → render "this invite is for a different account" page, link to sign out + sign in as the invited email.
    • Signed out, account existsredirect to /sign-in?redirect=<claim-url>&email=<invite.email>&invite=<token>.
    • Signed out, no accountredirect to /sign-up?redirect=<claim-url>&email=<invite.email>&invite=<token>.
  4. After sign-in/sign-up, the redirect param lands the user back on the claim URL; the loader re-runs against the new session cookie and resolves to the happy path.

What changed

  • src/server/competition-invites/claim.tsresolveInviteByToken, assertInviteClaimable (distinct reason per terminal state), identityMatch (pure, case-insensitive).
  • src/server/competition-invites/decline.ts — atomic pending → declined transition. Nulls activeMarker + claimTokenHash so the link dies immediately and a re-invite is unblocked.
  • src/server-fns/competition-invite-fns.ts:
    • getInviteByTokenFn — public (session-free) server fn used by the claim loader. Returns a discriminated union so the loader branches once.
    • declineInviteFn — authenticated. Re-runs resolve + identity-match on the server so a forged POST can't bypass the email-lock.
  • Routes:
    • /compete/$slug/claim/$token.tsx — claim landing.
    • /compete/$slug/claim/$token/decline.tsx — decline confirmation.
    • /compete/$slug/invite-pending.tsx — wrong-account recovery landing.
  • /sign-in + /sign-up?email=&invite= search params; when invite is present the email field is pre-filled and locked.
  • 14 new unit tests covering claimability reasons, identity-match cases, and case-insensitive email comparison (64 total Phase 2 tests).
  • lat.md/competition-invites.md — Claim resolution, Claim routes, Auth route extensions sections.

Design notes

  • Why ?invite=<token> and not ?claim=<token>? The existing claim param is wired to validateClaimTokenFn, which reads a plaintext KV entry (claim-token:{token}) pointing at an existing placeholder users row. Invite tokens are hashed at DB and the user account may not exist yet. Overloading claim would muddy both flows; a fresh param keeps the two lookup mechanisms cleanly separate.
  • Email verification: Sub-arc B does not auto-verify email on an invite-flow sign-up. If Ryan signs up via an invite link, he still gets the "check your email" step. Reasonable extension for a follow-up PR: pass inviteToken to signUpFn and auto-verify when a valid hash resolves — similar to the KV-backed placeholder path.
  • No status transition on claim click. Per ADR-0011, the invite stays pending through the Stripe-in-flight window. The actual flip to accepted_paid lands in sub-arc C when the Stripe workflow reads inviteId from purchase metadata.

Test plan

  • pnpm test -- test/server/competition-invites test/lib/competition-invites — 64 green
  • pnpm type-check clean
  • pnpm dev with seeded DB — click /compete/2026-wodsmith-invitational/claim/seed-invite-mike-pending-men-rx-phase2:
    • Signed in as mike@wodsmith.com → claim CTA page
    • Signed in as another account → wrong-account page
    • Signed out → redirects to /sign-in?email=mike@wodsmith.com&invite=…; email field pre-filled and disabled
  • Click decline on the claim page → confirmation → row flips to declined, activeMarker = NULL, claim_token_hash = NULL; subsequent clicks of the original URL show "invite declined"
  • Token for a different competition (mispaste) → generic "invalid link" page, no leak

🤖 Generated with Claude Code


Summary by cubic

Adds the athlete-facing invite claim and decline flow with secure token resolution and email-locked identity. Also forwards the invited division to registration and tightens token handling for safer links.

  • New Features

    • Routes: /compete/$slug/claim/$token, /compete/$slug/claim/$token/decline, /compete/$slug/invite-pending.
    • Server fns: getInviteByTokenFn (slug-checked token resolve) and declineInviteFn (revalidates identity; transitions pendingdeclined and deactivates link).
    • Auth: /sign-in and /sign-up accept ?invite and ?email; the email field is prefilled and locked only when both are present.
    • Registration handoff: claim CTA now forwards divisionId; /compete/$slug/register accepts ?invite and ?divisionId for preselecting the invited division (Phase 2D will wire invite through payment).
    • 14 new unit tests; docs updated for claim resolution, routes, and auth params.
  • Bug Fixes

    • Split pure helpers into src/server/competition-invites/identity.ts (re-exported from claim.ts) so client routes don’t pull getDb; fixes Vite bundling crash.
    • resolveInviteByToken now also requires activeMarker = "active" (defense-in-depth against stale tokens).

Written for commit dd5fa64. Summary will update on new commits.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

Warning

Rate limit exceeded

@theianjones has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 21 minutes and 29 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 21 minutes and 29 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9c238fb3-69a6-4aa1-aaa1-6ae36bb985c8

📥 Commits

Reviewing files that changed from the base of the PR and between 90c4381 and dd5fa64.

📒 Files selected for processing (13)
  • apps/wodsmith-start/src/routeTree.gen.ts
  • apps/wodsmith-start/src/routes/_auth/sign-in.tsx
  • apps/wodsmith-start/src/routes/_auth/sign-up.tsx
  • apps/wodsmith-start/src/routes/compete/$slug/claim/$token.tsx
  • apps/wodsmith-start/src/routes/compete/$slug/claim/$token/decline.tsx
  • apps/wodsmith-start/src/routes/compete/$slug/invite-pending.tsx
  • apps/wodsmith-start/src/routes/compete/$slug/register.tsx
  • apps/wodsmith-start/src/server-fns/competition-invite-fns.ts
  • apps/wodsmith-start/src/server/competition-invites/claim.ts
  • apps/wodsmith-start/src/server/competition-invites/decline.ts
  • apps/wodsmith-start/src/server/competition-invites/identity.ts
  • apps/wodsmith-start/test/server/competition-invites/claim.test.ts
  • lat.md/competition-invites.md
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/competition-invites-phase-2-claim

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 11 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/wodsmith-start/src/routes/_auth/sign-in.tsx">

<violation number="1" location="apps/wodsmith-start/src/routes/_auth/sign-in.tsx:78">
P2: Don’t lock invite flow when the invite email is missing; it can disable the email field while leaving it empty, blocking sign-in.</violation>
</file>

<file name="apps/wodsmith-start/src/routes/_auth/sign-up.tsx">

<violation number="1" location="apps/wodsmith-start/src/routes/_auth/sign-up.tsx:121">
P1: Invite mode is activated without requiring an invite email, which can lock the email field empty and make sign-up impossible.</violation>
</file>

<file name="apps/wodsmith-start/src/server/competition-invites/claim.ts">

<violation number="1" location="apps/wodsmith-start/src/server/competition-invites/claim.ts:97">
P2: `resolveInviteByToken` is missing the `activeMarker = active` predicate, so stale terminal rows with a leftover token hash can still resolve.</violation>
</file>

<file name="apps/wodsmith-start/src/routes/compete/$slug/claim/$token.tsx">

<violation number="1" location="apps/wodsmith-start/src/routes/compete/$slug/claim/$token.tsx:158">
P1: The claim CTA drops the resolved invite `divisionId`, so the registration step is no longer tied to the invited division.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread apps/wodsmith-start/src/routes/_auth/sign-up.tsx Outdated
Comment thread apps/wodsmith-start/src/routes/compete/$slug/claim/$token.tsx Outdated
Comment thread apps/wodsmith-start/src/routes/_auth/sign-in.tsx Outdated
Comment thread apps/wodsmith-start/src/server/competition-invites/claim.ts Outdated
theianjones and others added 3 commits April 23, 2026 17:33
…+ auth

- `src/server/competition-invites/claim.ts`:
  - resolveInviteByToken — hash + DB lookup. Returns null on miss so the
    route can render a generic "invalid link" page without leaking whether
    the token ever existed.
  - assertInviteClaimable — throws InviteNotClaimableError with a distinct
    reason per terminal state (expired / declined / revoked / already_paid /
    not_found). Belt-and-suspenders: also fails if claimTokenHash is NULL
    or activeMarker drifted (race safety).
  - identityMatch — pure function returning the discriminated result the
    claim route branches on: { ok: true } or { ok: false, reason:
    wrong_account | needs_sign_in | needs_sign_up }. Case-insensitive via
    normalizeInviteEmail.
- `src/server/competition-invites/decline.ts` — atomic pending → declined
  transition. Nulls activeMarker + claimTokenHash so the link dies and the
  unique-active index unblocks a future re-invite. Idempotent against a
  re-read when affectedRows is 0.
- `src/server-fns/competition-invite-fns.ts`:
  - getInviteByTokenFn — public (session-free) server fn used by the claim
    route loader. Discriminated return: not_claimable or claimable +
    { invite, championshipName, divisionLabel, accountExistsForInviteEmail }.
    Verifies the invite's championship slug matches the URL so a mispasted
    link doesn't silently resolve to the wrong competition.
  - declineInviteFn — authenticated decline. Re-runs resolve + identity
    match on the server side so a forged POST can't bypass the email-lock.
- Athlete routes:
  - `/compete/$slug/claim/$token` — loader runs getInviteByTokenFn +
    identityMatch and branches to happy-path CTA, wrong-account rejection,
    or redirects to sign-in/sign-up with ?email=&invite= pre-fill.
  - `/compete/$slug/claim/$token/decline` — confirmation dialog, POSTs to
    declineInviteFn.
  - `/compete/$slug/invite-pending` — informational landing for
    wrong-account recovery.
- Sign-in + sign-up extended with ?email=&invite= search params. Email
  field is pre-filled and disabled while invite flow is active. Uses a
  dedicated `invite` param (not the existing `claim`) so the KV-backed
  placeholder-user flow and the hash-backed invite flow stay separate.
- 14 new claim unit tests (64 total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rkers

Vite couldn't bundle the claim/decline routes because both transitively
imported `getDb` (which imports `cloudflare:workers`) through
`server/competition-invites/claim.ts`. The dev server saw the unresolvable
import in the client graph and crashed every request, taking the e2e
suite with it.

Move the pure helpers (`identityMatch`, `assertInviteClaimable`, types,
`InviteNotClaimableError`) into a sibling `identity.ts` and re-export from
`claim.ts` for back-compat. Routes now import from `identity.ts` only.

Also drops the useless `case "not_found":` fallthrough flagged by biome —
`default` already covers it.
…mode, add active filter

cubic + coderabbit review on PR #419. Four fixes in the claim handoff:

- sign-in / sign-up: invite mode flipped on a bare `?invite=` token even
  when no email was supplied, locking the email field empty and blocking
  the user from typing one in. Require both `invite` and `email` before
  enabling the locked-field invite flow.
- claim CTA: the "Continue to registration" link forwarded `canceled:
  undefined` and dropped the resolved division id, so the registration
  form had no way to pre-select the invited division. Forward
  `divisionId` through and extend the register route's search schema to
  accept it (plus the upcoming `invite=<token>` from Phase 2D).
- resolveInviteByToken: filtered only on `claimTokenHash`, even though
  the docstring claimed an `activeMarker = "active"` filter. Add the
  predicate so a stale terminal row that somehow kept its hash never
  resolves — pure defense-in-depth, the live re-read still asserts
  claimability.
@theianjones theianjones force-pushed the feat/competition-invites-phase-2-claim branch from aa49035 to dd5fa64 Compare April 23, 2026 23: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.

1 participant