Skip to content

feat(invites): ADR-0011 Phase 2C — send pipeline + Stripe workflow + queue#420

Open
theianjones wants to merge 3 commits intofeat/competition-invites-phase-2-claimfrom
feat/competition-invites-phase-2-send
Open

feat(invites): ADR-0011 Phase 2C — send pipeline + Stripe workflow + queue#420
theianjones wants to merge 3 commits intofeat/competition-invites-phase-2-claimfrom
feat/competition-invites-phase-2-send

Conversation

@theianjones
Copy link
Copy Markdown
Contributor

@theianjones theianjones commented Apr 23, 2026

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:

issueInvitesFn         → issueInvitesForRecipients + reissueInvite for drafts
                       → render CompetitionInviteEmail per recipient
                       → enqueue InviteEmailMessage on BROADCAST_EMAIL_QUEUE
                              │
broadcast-queue-consumer  ←──┘  (dispatches on kind: "competition-invite")
                       → Resend POST with Idempotency-Key: invite-<id>-<attempt>
                       → flip emailDeliveryStatus: sent | failed

athlete clicks claim   → /compete/$slug/claim/$token (from PR #419)
                       → registers via initiateRegistrationPaymentFn(inviteToken)
                              │
                              ↓ tags purchase metadata with inviteId
Stripe webhook → Workflow → update-competition-invite-status step
                       → status: pending → accepted_paid, paidAt, claimedRegistrationId
                       → claim_token_hash nulled (replay-safe)

What changed

Email infrastructure

  • src/server/broadcast-queue-consumer.ts — added kind discriminator. Same BROADCAST_EMAIL_QUEUE binding carries broadcasts and invites; broadcast messages without kind stay backward-compatible.
  • New InviteEmailMessage shape — one invite per message, pre-rendered HTML (unique claim URL per token), Resend Idempotency-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 on MANAGE_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

  • initiateRegistrationPaymentFn accepts optional inviteToken. When supplied:
    • Resolves + claimability-checks + email-matches the token against the session (server-side — not trusting the client);
    • Requires one of the selected items to match the invite's locked championshipDivisionId;
    • Bypasses the registration-window check — invite holders can register before public-open / after close;
    • Tags the matching purchase's metadata with inviteId so the webhook workflow can flip the status.
  • Multi-division registrations: only the item matching the invite's division gets the tag. Other items in the same checkout stay non-invite.

Stripe workflow

  • RegistrationStepResult threads inviteId through from the purchase metadata.
  • New update-competition-invite-status step flips pending → accepted_paid, sets paidAt + claimedRegistrationId, nulls claimTokenHash so a replay of the original email link short-circuits. Idempotent via status = "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)

  • Organizer-facing buttons — the Send button on the roster, bespoke add dialog, bulk paste dialog. Without these, issueInvitesFn has no UI caller.
  • The claim page's "Continue to registration" CTA still hands off to /compete/$slug/register without ?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 calling initiateRegistrationPaymentFn({ inviteToken, ... }) from a test.
  • Invite-expiry cron sweep.
  • Integration test covering signed-in claim → checkout → accepted_paid.

Test plan

  • pnpm test -- test/server/competition-invites test/lib/competition-invites — 64 green
  • pnpm type-check clean
  • Dev smoke with seeded data:
    • Call issueInvitesFn with a seeded source row → check competition_invites for new sent emailDeliveryStatus + new claim_token_hash; with no RESEND_API_KEY, look for [Email Preview] log line and skipped status.
    • Manually call initiateRegistrationPaymentFn({ competitionId, items: [...], inviteToken: '<plaintext>' }) → purchase row gets inviteId in metadata → run processCheckoutInline (or real Stripe webhook) → invite flips to accepted_paid with claimedRegistrationId populated.
    • Attempt initiateRegistrationPaymentFn with a token whose email doesn't match session → rejects with "Invite is locked to a different email".
    • Attempt to reuse the claim URL after accepted_paid → claim route (from feat(invites): ADR-0011 Phase 2B — claim resolution + athlete routes + auth #419) shows "already registered" page; claim_token_hash is NULL so no replay path.

🤖 Generated with Claude Code


Summary 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, renders CompetitionInviteEmail, and enqueues InviteEmailMessage to BROADCAST_EMAIL_QUEUE with Resend idempotency invite-<inviteId>-<sendAttempt>.
    • Queue consumer: adds kind routing; handles competition-invite messages, flips competition_invites.emailDeliveryStatus (sent | failed | skipped), acks failures to avoid duplicates; broadcast messages without kind still work.
    • Registration: initiateRegistrationPaymentFn accepts inviteToken, validates email/division, bypasses the window, and tags the matching purchase with inviteId.
    • Stripe workflow: threads inviteId and adds update-competition-invite-status to flip pending → accepted_paid, set paidAt and claimedRegistrationId, and null the token; the step now fails loudly on error (with retries) to avoid stranded pending invites; processCheckoutInline mirrors this for local dev.
    • Email template: new branded CompetitionInviteEmail with claim CTA, decline link, and division/source/deadline details.
    • Bespoke: createBespokeInviteFn and createBespokeInvitesBulkFn wrap helpers with permission gates and logging.
  • Migration

    • No breaking changes. Bind BROADCAST_EMAIL_QUEUE and set RESEND_API_KEY to send real emails; otherwise previews are logged and delivery is marked skipped.

Written for commit 1e245b1. 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: 1eaaddfa-db14-4d9e-99d8-b2dde3bfebcb

📥 Commits

Reviewing files that changed from the base of the PR and between dd5fa64 and 1e245b1.

📒 Files selected for processing (8)
  • apps/wodsmith-start/src/react-email/competition-invites/invite-email.tsx
  • apps/wodsmith-start/src/server-fns/competition-invite-fns.ts
  • apps/wodsmith-start/src/server-fns/registration-fns.ts
  • apps/wodsmith-start/src/server/broadcast-queue-consumer.ts
  • apps/wodsmith-start/src/workflows/stripe-checkout-workflow.ts
  • apps/wodsmith-start/test/server-fns/competition-invite-fns.sources.test.ts
  • apps/wodsmith-start/test/workflows/stripe-checkout-workflow.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-send

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.

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.

Comment thread apps/wodsmith-start/src/server-fns/registration-fns.ts
Comment thread apps/wodsmith-start/src/workflows/stripe-checkout-workflow.ts Outdated
@theianjones theianjones force-pushed the feat/competition-invites-phase-2-send branch from f87ba56 to 0330e7e Compare April 23, 2026 23:15
@theianjones theianjones force-pushed the feat/competition-invites-phase-2-claim branch from e731ec5 to aa49035 Compare April 23, 2026 23:16
@theianjones theianjones force-pushed the feat/competition-invites-phase-2-send branch from 0330e7e to 8c645f7 Compare April 23, 2026 23:16
theianjones and others added 3 commits April 23, 2026 17:40
…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.
@theianjones theianjones force-pushed the feat/competition-invites-phase-2-claim branch from aa49035 to dd5fa64 Compare April 23, 2026 23:53
@theianjones theianjones force-pushed the feat/competition-invites-phase-2-send branch from 8c645f7 to 1e245b1 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