feat(invites): ADR-0011 Phase 2C — send pipeline + Stripe workflow + queue#420
Conversation
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
2 issues found across 6 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/server-fns/registration-fns.ts">
<violation number="1" location="apps/wodsmith-start/src/server-fns/registration-fns.ts:293">
P1: `inviteIdForPurchase` is only threaded into paid purchase metadata, so free registrations (all-free path at step 6 and free-item branch in step 8) never flip the invite to `accepted_paid`. For free competitions the Stripe webhook workflow never runs, leaving the invite stuck in `pending` with a live claim token.</violation>
</file>
<file name="apps/wodsmith-start/src/workflows/stripe-checkout-workflow.ts">
<violation number="1" location="apps/wodsmith-start/src/workflows/stripe-checkout-workflow.ts:785">
P1: Do not swallow invite-status step failures; continuing here can permanently leave invite-backed purchases in `PENDING` because subsequent idempotent replays skip the step.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
f87ba56 to
0330e7e
Compare
e731ec5 to
aa49035
Compare
0330e7e to
8c645f7
Compare
…eue consumer
Wires the organizer → athlete → paid-registration flow end-to-end on the
server side.
Email queue + React Email
- Extend broadcast-queue-consumer with a `kind` discriminator. Same
BROADCAST_EMAIL_QUEUE binding carries both shapes; broadcast messages
without `kind` stay backward-compatible.
- New InviteEmailMessage shape — one invite per message. HTML is
pre-rendered per-recipient (unique claim URL per token). Resend
Idempotency-Key uses `invite-<inviteId>-<sendAttempt>` so re-sends after
an extend/reissue actually dispatch. In dev (no RESEND_API_KEY) logs
instead of sending and marks delivery as `skipped`.
- `src/react-email/competition-invites/invite-email.tsx` — branded email:
hero/headline, division + source + deadline card, primary claim CTA,
secondary decline link. Subject/body supplied at send time (Phase 4
introduces templates).
Send pipeline
- `issueInvitesFn` — organizer send button. Permission-gated on
`MANAGE_COMPETITIONS`. Resolves championship + division + organizing
team, inserts new invites via `issueInvitesForRecipients`, activates
draft bespoke rows via `reissueInvite`, renders HTML per invite,
enqueues invite messages.
- `createBespokeInviteFn` + `createBespokeInvitesBulkFn` — thin wrappers
over the helpers from sub-arc A with permission gate + logging. Bulk
returns structured `{ created, duplicates, invalid }`.
Registration hand-off
- `initiateRegistrationPaymentFn` accepts optional `inviteToken`. When
present: resolves + claimability-checks + email-matches against the
session; requires one selected item to match the invite's locked
division; bypasses the registration-window check; tags the matching
purchase's metadata with `inviteId`.
- Stripe workflow: `RegistrationStepResult` carries `inviteId`. New
`update-competition-invite-status` step flips the invite to
`accepted_paid`, sets `paidAt` + `claimedRegistrationId`, nulls
`claimTokenHash` so replay of the original email link short-circuits.
Idempotent via `status = "pending"` guard on the update predicate.
processCheckoutInline runs the same helper in local dev.
64 existing tests still green; type-check clean. lat.md updated with
Email delivery, Send pipeline, and Registration hand-off sections.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three CI follow-ups for Phase 2C: - competition-invite-fns.ts: declare `issueResult: IssueInvitesResult` explicitly. Biome flagged the bare `let issueResult` as an implicit any. - stripe-checkout-workflow.test.ts: bump the expected step count from 3 to 4 and assert the new `update-competition-invite-status` step. The workflow gained that step in this phase to flip pending invites to accepted_paid after the registration row exists. - competition-invite-fns.sources.test.ts: extend the @tanstack/react-start mock with `createServerOnlyFn` and the bare `.handler()` shape. The imported module graph now pulls in `src/lib/env.ts`, which uses both.
cubic flagged the try/catch around `update-competition-invite-status` as
a P1 — Cloudflare Workflows cache successful step outputs, so a swallowed
failure here strands the invite in `pending` forever. Subsequent webhook
deliveries replay the workflow, see step 1 (create-registration) already
succeeded, and skip step 2a entirely.
Drop the catch. The step still has its own retries:{limit:3,...} for
transient faults; a permanent fault now fails the workflow so Stripe
retries the webhook (or pages someone for the truly stuck cases) instead
of silently leaking PENDING invites.
aa49035 to
dd5fa64
Compare
8c645f7 to
1e245b1
Compare
Summary
Stacked on #419 (Phase 2B). Completes the server-side loop from organizer Send to athlete accepted_paid after Stripe webhook. Sub-arc D (next PR) adds the organizer buttons + bespoke dialogs that exercise these server fns and the invite-expiry cron.
Pipeline wired end-to-end:
What changed
Email infrastructure
src/server/broadcast-queue-consumer.ts— addedkinddiscriminator. SameBROADCAST_EMAIL_QUEUEbinding carries broadcasts and invites; broadcast messages withoutkindstay backward-compatible.InviteEmailMessageshape — one invite per message, pre-rendered HTML (unique claim URL per token), ResendIdempotency-Key: invite-<inviteId>-<sendAttempt>so re-sends after an extend/reissue actually dispatch.src/react-email/competition-invites/invite-email.tsx— branded email: headline, division + source + deadline card, primary claim CTA, decline link.Server fns (in
competition-invite-fns.ts)issueInvitesFn— the organizer send button. Resolves championship/division/team, inserts new invites, activates draft bespoke rows (reissue), renders HTML, enqueues messages. Permission-gated onMANAGE_COMPETITIONS.createBespokeInviteFn+createBespokeInvitesBulkFn— thin wrappers over the helpers from PR feat(invites): ADR-0011 Phase 2A — schema, tokens, issue + bespoke helpers #418 with permission gates + logging.Registration hand-off
initiateRegistrationPaymentFnaccepts optionalinviteToken. When supplied:championshipDivisionId;inviteIdso the webhook workflow can flip the status.Stripe workflow
RegistrationStepResultthreadsinviteIdthrough from the purchase metadata.update-competition-invite-statusstep flipspending → accepted_paid, setspaidAt+claimedRegistrationId, nullsclaimTokenHashso a replay of the original email link short-circuits. Idempotent viastatus = "pending"guard on the update predicate so a workflow retry doesn't double-flip.processCheckoutInline(local-dev fallback) runs the same helper to keep behavior consistent without Cloudflare Workflows.What's still missing (sub-arc D)
issueInvitesFnhas no UI caller./compete/$slug/registerwithout?invite=<token>— wiring the register form to pass the invite token through is part of sub-arc D. For now, you can exercise the full pipeline by manually callinginitiateRegistrationPaymentFn({ inviteToken, ... })from a test.Test plan
pnpm test -- test/server/competition-invites test/lib/competition-invites— 64 greenpnpm type-checkcleanissueInvitesFnwith a seeded source row → checkcompetition_invitesfor newsentemailDeliveryStatus + newclaim_token_hash; with noRESEND_API_KEY, look for[Email Preview]log line andskippedstatus.initiateRegistrationPaymentFn({ competitionId, items: [...], inviteToken: '<plaintext>' })→ purchase row getsinviteIdin metadata → runprocessCheckoutInline(or real Stripe webhook) → invite flips toaccepted_paidwithclaimedRegistrationIdpopulated.initiateRegistrationPaymentFnwith a token whose email doesn't match session → rejects with "Invite is locked to a different email".accepted_paid→ claim route (from feat(invites): ADR-0011 Phase 2B — claim resolution + athlete routes + auth #419) shows "already registered" page;claim_token_hashis NULL so no replay path.🤖 Generated with Claude Code
mainSummary by cubic
Completes ADR-0011 Phase 2C by wiring organizer Send → athlete claim → paid registration on the server, including invite email delivery and Stripe workflow. Backward-compatible with broadcasts; dev works without
RESEND_API_KEY.New Features
issueInvitesFn: inserts/reissues invites, rendersCompetitionInviteEmail, and enqueuesInviteEmailMessagetoBROADCAST_EMAIL_QUEUEwith Resend idempotencyinvite-<inviteId>-<sendAttempt>.kindrouting; handlescompetition-invitemessages, flipscompetition_invites.emailDeliveryStatus(sent|failed|skipped), acks failures to avoid duplicates; broadcast messages withoutkindstill work.initiateRegistrationPaymentFnacceptsinviteToken, validates email/division, bypasses the window, and tags the matching purchase withinviteId.inviteIdand addsupdate-competition-invite-statusto flippending → accepted_paid, setpaidAtandclaimedRegistrationId, and null the token; the step now fails loudly on error (with retries) to avoid strandedpendinginvites;processCheckoutInlinemirrors this for local dev.CompetitionInviteEmailwith claim CTA, decline link, and division/source/deadline details.createBespokeInviteFnandcreateBespokeInvitesBulkFnwrap helpers with permission gates and logging.Migration
BROADCAST_EMAIL_QUEUEand setRESEND_API_KEYto send real emails; otherwise previews are logged and delivery is markedskipped.Written for commit 1e245b1. Summary will update on new commits.