Skip to content

feat(invites): ADR-0011 Phase 2D — organizer UI, claim→register, expiry sweep#421

Open
theianjones wants to merge 6 commits intofeat/competition-invites-phase-2-sendfrom
feat/competition-invites-phase-2-ui
Open

feat(invites): ADR-0011 Phase 2D — organizer UI, claim→register, expiry sweep#421
theianjones wants to merge 6 commits intofeat/competition-invites-phase-2-sendfrom
feat/competition-invites-phase-2-ui

Conversation

@theianjones
Copy link
Copy Markdown
Contributor

@theianjones theianjones commented Apr 23, 2026

Summary

Stacked on #420 (Phase 2C). Closes the full Phase 2 loop — organizer now has real clickable buttons on the invites route and a sent invite leads end-to-end to accepted_paid after the athlete finishes Stripe Checkout.

Organizer                         Athlete
─────────                         ───────
[Add invitee]  ──┐
[Bulk add]     ──┼──  drafts  ──┐
[Send invites] ──┤              │
       │ select rosters + drafts│
       ↓                        ↓
   issueInvitesFn     ──→ email (queue from #420) ──→ inbox
                                                       │
                                              /claim/$token (from #419)
                                                       │
                              /compete/$slug/register?invite=<token>  (NEW)
                                                       │
                                          initiateRegistrationPaymentFn
                                                  ({...inviteToken})
                                                       │
                                               Stripe Checkout
                                                       │
                                    Stripe webhook workflow (from #420)
                                                       │
                                    invite: pending → accepted_paid ✓

What changed

Organizer dialogs (new in src/components/organizer/invites/)

  • add-bespoke-invitee-dialog.tsx — single-email form wrapping createBespokeInviteFn. Stages a draft row; no email sent until Send.
  • bulk-add-invitees-dialog.tsx — textarea + division select. Accepts CSV, TSV (Google Sheets paste), or one-per-line. Surfaces duplicates + invalid rows inline with row numbers (expandable details).
  • send-invites-dialog.tsx — subject, deadline, optional body, recipient preview chips. Reports sentCount / skippedCount after submit.

Roster table (championship-roster-table.tsx)

  • Optional selection props (selectedKeys, onToggleSelection, onToggleAll) — opt-in, so read-only consumers stay clean.
  • Rows without athleteEmail are rendered with a disabled checkbox + "No email on file" tooltip.

Roster data hydration (server/competition-invites/roster.ts)

  • RosterRow gains athleteEmail: string | null.
  • getChampionshipRoster does a single bulk userTable lookup keyed by the row's userId to fill it. One extra query per roster render.

Route wiring (routes/compete/organizer/$competitionId/invites/index.tsx)

  • Header gets three buttons: Add invitee, Bulk add, Send invites (recipient-count chip).
  • Roster tab gains checkboxes + a bespoke-drafts section below the source table. Checked drafts + checked source rows union into the Send dialog's recipient list.
  • Router invalidates on successful add/bulk/send so drafts just activated fall off the list.

Claim → register hand-off

  • Claim page CTA links to /compete/$slug/register?invite=<token>.
  • Register route's validateSearch accepts invite; routes it to RegistrationForm as inviteToken.
  • RegistrationForm passes inviteToken to initiateRegistrationPaymentFn. Combined with feat(invites): ADR-0011 Phase 2C — send pipeline + Stripe workflow + queue #420's purchase-metadata + Stripe-workflow wiring, a paid registration flips the invite to accepted_paid.

Invite expiry sweep (new in server/competition-invites/expiry.ts)

  • sweepExpiredInvites() — paginates pending invites with expiresAt < now, batched at 200/tick. Nulls activeMarker + claimTokenHash. Idempotent via re-asserted status = pending predicate on the update.
  • Cron wiring deferred to Phase 3 alongside round-based deadlines — Phase 2 ships the helper for manual invocation / future wiring.

New server fn

  • listActiveInvitesFn — permission-gated list of active invites for a championship. Drives the drafts section + (future) invite-state overlays on source rows.

What's still out of scope

Per the original execution plan, a few Phase 2 items intentionally don't land here:

  • Integration test (2.18) — full signed-in claim → checkout → accepted_paid E2E. Worth a follow-up PR.
  • Source roster invite-state overlays — the StatusPill is still "Not invited" for every row. A later phase swaps in real state from activeInvites.
  • Hourly cron trigger in alchemy.run.ts — Phase 3 adds it with round-based deadlines.
  • Revoke action — Phase 3 along with the round builder.

Test plan

  • pnpm test -- test/server/competition-invites test/lib/competition-invites — 64 green
  • pnpm type-check clean; lat check passes
  • pnpm dev with seeded data:
    • Open /compete/organizer/comp_inv_championship/invites → Add invitee, Bulk add, and Send buttons render.
    • Click Add invitee → submit a new email → returns and draft row appears in the bespoke section.
    • Click Bulk add → paste 3 rows (CSV/TSV/plain mix) including one duplicate and one invalid → summary reports created / duplicates / invalid with expandable row-level details.
    • Check one source row (mike, with athleteEmail set) + check a draft bespoke → Send → dialog submits → Drizzle Studio shows tokens + sent emailDeliveryStatus on both rows. Without RESEND_API_KEY, look for [Email Preview] in dev console and skipped status.
    • Click the pending seed claim link → Continue to registration → the register URL now has ?invite=<token> → complete Stripe Checkout → workflow flips the invite to accepted_paid, registration links via claimedRegistrationId.
  • Run sweepExpiredInvites() manually against seeded data — cinv_seed_pending_mike past its expiry should flip to expired.

🤖 Generated with Claude Code


Summary by cubic

Adds an end-to-end organizer invite flow with Add/Bulk/Send UI, a claim→register handoff that locks to the invited division, and an expiry sweep. Docs reflect the final lifecycle (no “accepted”), re-send behavior (sendAttempt), free-comp restriction, and divisionId forwarding.

  • New Features

    • Organizer UI: Add invitee, Bulk add, and Send dialogs; roster checkboxes (rows without email disabled with tooltip); draft bespoke list; recipient count; auto-refresh.
    • Send flow: Union of selected roster rows and draft bespoke rows; issueInvitesFn reports sent/skipped; per-recipient claim links with a locale-safe default deadline.
    • Claim → register: CTA to /compete/$slug/register?invite=<token>&divisionId=<id>; RegistrationForm locks to the invited division and forwards inviteToken to payment; server requires a single item matching the invited division; Stripe workflow flips the invite to accepted_paid.
    • Roster data: RosterRow.athleteEmail hydrated via one bulk userTable lookup; ChampionshipRosterTable supports optional selection.
    • Server APIs: listActiveInvitesFn returns only active invites for the active division (filters by division and activeMarker); Send dialog uses issueInvitesFn.
    • Expiry sweep: sweepExpiredInvites() batch-expires pending invites past expiresAt, clears activeMarker/claimTokenHash, and is idempotent.
  • Bug Fixes

    • Organizer loader skips active-invites fetch when no division; draft list no longer shows terminal rows.
    • Send dialog blocks empty/invalid deadlines and uses a stable local-date default.
    • Add/Bulk dialogs reset local state on close to avoid stale input/errors.
    • Hoisted Route.useParams() in the claim route to top-level to satisfy hook rules.
    • Claim handoff: registration now pins to the invited division (UI hides multi-select and bypasses the closed-window disable); server rejects wrong/multi-division submissions when inviteToken is present.

Written for commit 3339b67. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Organizers can now send invitations to athletes with customizable RSVP deadlines
    • Support for both individual and bulk athlete invitations
    • Streamlined registration flow for invited athletes with automated division assignment
    • Roster selection interface with athlete email tracking and display
  • Tests

    • Updated test fixtures to include athlete data
  • Documentation

    • Updated technical specifications for invite lifecycle and registration workflows

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

Walkthrough

The PR adds a complete invite management system for competitions, including UI components to add single/bulk bespoke invitees and send invites, updates the roster table to support row selection, modifies the registration form to accept invite tokens and lock division selection, implements server functions to list active invites and sweep expired ones, and enhances roster computation with athlete email hydration.

Changes

Cohort / File(s) Summary
Invite Management Dialogs
apps/wodsmith-start/src/components/organizer/invites/add-bespoke-invitee-dialog.tsx, bulk-add-invitees-dialog.tsx, send-invites-dialog.tsx
New React components for single/bulk invitee creation and invite dispatch. Manage form state, validation, server submission via useServerFn, error alerts, and field reset on dialog close. Send dialog includes deadline validation, RSVP timestamp conversion, and success/skipped alerts.
Organizer Invites Page
apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/index.tsx
Enhanced loader to fetch active invites per division. UI now supports roster row selection with select-all, displays draft bespoke invite checklist, consolidates recipient list from roster and bespoke selections, and manages dialog state for adding/sending invites. Post-send, selections clear and route invalidates.
Roster Table Selection
apps/wodsmith-start/src/components/organizer/invites/championship-roster-table.tsx
Added checkbox column for row selection (enabled only when athleteEmail exists). Exported rosterRowKey helper and extended props with selectedKeys/onToggleSelection/onToggleAll. Dynamic colSpan adjustment for cutoff separator.
Registration Invite Integration
apps/wodsmith-start/src/components/registration/registration-form.tsx
Added inviteToken and inviteDivisionId props to lock division selection and bypass registration-window checks. Form preselects invited division, prevents division toggle, seeds team state, and forwards token with payment. Submit/messaging UI gated by invite-lock status.
Claim & Register Routes
apps/wodsmith-start/src/routes/compete/$slug/claim/$token.tsx, register.tsx
Claim route extracts token and passes to ClaimablePage. Register route extracts invite and divisionId search params and forwards them to RegistrationForm as inviteToken and inviteDivisionId.
Server Invite Functions
apps/wodsmith-start/src/server-fns/competition-invite-fns.ts, registration-fns.ts
Added listActiveInvitesFn to fetch active invites per division with permission checks. Updated registration-fns to validate invite-based registration accepts exactly one entry matching the invited division.
Server Invite Logic
apps/wodsmith-start/src/server/competition-invites/roster.ts, expiry.ts
Roster computation now hydrates athleteEmail via bulk user lookup after aggregation. New sweepExpiredInvites function transitions pending expired invites to EXPIRED, clears claim tokens and active markers, and returns transitioned count.
Test Fixtures
apps/wodsmith-start/test/components/championship-roster-table.test.tsx, test/server/competition-invites/roster.test.ts
Updated RosterRow test helpers to include athleteEmail: null property.
Documentation
docs/adr/0011-competition-invites.md, docs/plans/0011-competition-invites-execution.md, lat.md/competition-invites.md
Updated invite lifecycle semantics: added sendAttempt tracking, revised token/expiry rotation on re-send, replaced intermediate accepted state with webhook-only pending → accepted_paid transition, adjusted activeMarker rules. Expanded execution plan and LAT to cover roster email hydration, selection UI, claim→register hand-off via URL search params, and expiry sweep helper.

Sequence Diagram(s)

sequenceDiagram
    actor Organizer
    participant Dialog as Send Invites Dialog
    participant Server as issueInvitesFn
    participant Queue as Email Queue
    participant DB as Database

    Organizer->>Dialog: Select roster/bespoke invitees
    Organizer->>Dialog: Set RSVP deadline & submit
    Dialog->>Dialog: Validate deadline format
    Dialog->>Server: Call issueInvitesFn with recipients & deadline
    Server->>DB: Fetch division fee & active invites
    Server->>DB: Create/update invite records with deadline timestamp
    Server->>Queue: Enqueue invite emails (idempotent by sendAttempt)
    Queue-->>Server: Return sentCount & skipped list
    Server-->>Dialog: Return result
    Dialog->>Dialog: Display queued alerts & close button
    Organizer->>Dialog: Close dialog
    Dialog->>Server: Invalidate route & refresh data
Loading
sequenceDiagram
    actor Athlete
    participant ClaimLink as Claim Route
    participant RegForm as Registration Form
    participant Payment as Payment Initiation
    participant Server as Registration Server

    Athlete->>ClaimLink: Click invite claim link with token
    ClaimLink->>RegForm: Navigate to register with invite & divisionId params
    RegForm->>RegForm: Lock division & bypass window check
    RegForm->>RegForm: Preseed division & team state
    Athlete->>RegForm: Fill form & submit
    RegForm->>Payment: Forward inviteToken with payment request
    Payment->>Server: Initiate payment with invite context
    Server->>Server: Validate invite division match
    Server-->>Payment: Return payment session
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 A fluffy hop through invite lanes,
Rosters selected, divisions explained!
From claim-link whisper to payment's call,
Invites now flow through it all!
Athletes locked in, organizers set free,
Registration's journey, invite-guaranteed! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.41% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main changes: organizer UI components, claim-to-register flow, and expiry sweep functionality for Phase 2D of the invites feature (ADR-0011).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/competition-invites-phase-2-ui

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.

7 issues found across 14 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/components/organizer/invites/add-bespoke-invitee-dialog.tsx">

<violation number="1" location="apps/wodsmith-start/src/components/organizer/invites/add-bespoke-invitee-dialog.tsx:175">
P2: Cancel closes the dialog without resetting local form state, so stale inputs/errors can reappear on reopen.</violation>
</file>

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

<violation number="1" location="apps/wodsmith-start/src/server-fns/competition-invite-fns.ts:1125">
P1: `listActiveInvitesFn` does not enforce division or active-state filters, so terminal invites can be returned and treated as draft invites in the organizer UI.</violation>
</file>

<file name="apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsx">

<violation number="1" location="apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsx:193">
P2: Close button bypasses local reset, so dialog state can persist unexpectedly across reopen.</violation>
</file>

<file name="apps/wodsmith-start/src/components/organizer/invites/send-invites-dialog.tsx">

<violation number="1" location="apps/wodsmith-start/src/components/organizer/invites/send-invites-dialog.tsx:55">
P2: Default RSVP deadline can be off by one day because the date is derived from a UTC ISO string instead of local calendar date.</violation>

<violation number="2" location="apps/wodsmith-start/src/components/organizer/invites/send-invites-dialog.tsx:195">
P1: Send can proceed with an empty deadline, producing an invalid `rsvpDeadlineAt` date.</violation>
</file>

<file name="apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/index.tsx">

<violation number="1" location="apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/index.tsx:113">
P1: The loader passes an empty `championshipDivisionId` when no division exists, which violates server input validation and breaks page load for competitions without divisions.</violation>

<violation number="2" location="apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/index.tsx:192">
P2: Draft bespoke filtering misses `activeMarker` and can include non-active invites as sendable drafts.</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/competition-invite-fns.ts
Comment thread apps/wodsmith-start/src/components/organizer/invites/send-invites-dialog.tsx Outdated
Comment thread apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/index.tsx Outdated
Comment thread apps/wodsmith-start/src/components/organizer/invites/send-invites-dialog.tsx Outdated
@theianjones theianjones force-pushed the feat/competition-invites-phase-2-ui branch from f5d01b2 to c4835c9 Compare April 23, 2026 23:15
@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-ui branch from c4835c9 to e72ee2b 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 4 commits April 23, 2026 17:43
…ry sweep

Closes the loop: organizer clicks Send → invite email dispatches → athlete
claims → pre-attached registration → Stripe → invite flips to
accepted_paid. Organizer now has three real buttons on the invites route.

Organizer UI
- Add invitee dialog (single bespoke) — wraps createBespokeInviteFn.
- Bulk add dialog — textarea accepts CSV/TSV/one-per-line, surfaces
  duplicate + invalid rows inline with row numbers.
- Send invites dialog — subject, deadline, optional body text, recipient
  preview chips. Reports sent/skipped counts.
- Roster table: optional selection checkboxes (opt-in via
  selectedKeys/onToggleSelection/onToggleAll props); rows without an
  athleteEmail are disabled with a tooltip.
- Bespoke drafts section below the roster — lists all draft bespoke
  invites for the active division with per-row checkboxes.
- Send dialog union's source + draft-bespoke selections; router
  invalidates on success so drafts that just got tokens fall off the list.

Roster data
- RosterRow gains `athleteEmail: string | null`. `getChampionshipRoster`
  hydrates via a single bulk `userTable` lookup by userId.
- listActiveInvitesFn — permission-gated list of active invites for a
  championship; route loader uses it to render the drafts section and
  overlay invite state on source rows.

Claim → register → Stripe
- Claim route CTA now links to `/compete/$slug/register?invite=<token>`.
- Register route's validateSearch accepts `invite`; passes as
  `inviteToken` to RegistrationForm, which forwards to
  initiateRegistrationPaymentFn. Combined with Phase 2C metadata + Stripe
  workflow, a paid registration flips the invite to accepted_paid.

Invite expiry sweep
- `sweepExpiredInvites` — batched (200/tick) flip of pending invites past
  their expiresAt. Nulls activeMarker + claimTokenHash. Idempotent via
  re-asserted status=pending predicate. Cron wiring comes in Phase 3
  alongside round-based deadlines.

64 tests green; type-check clean; lat check passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Biome's `useHookAtTopLevel` flagged the second `Route.useParams()` call
inside ClaimPage — it sat after two early-return branches, so it could be
skipped on subsequent renders. Destructure both `slug` and `token` at the
top instead.
Three resolutions made during Phase 2 implementation never made it back
into the design docs:

- No `accepted` status: the lifecycle is pending → accepted_paid (or one
  of declined/expired/revoked). The Stripe-in-flight window is just
  pending with a commercePurchase in progress.
- `sendAttempt` column: increments on each re-send and is suffixed onto
  the Resend Idempotency-Key (`invite-<id>-<attempt>`) so re-issues
  actually dispatch instead of being silently deduplicated.
- Free comps are rejected at issueInvitesFn time, not gracefully handled
  downstream — there is no synchronous flip to accepted_paid because no
  free-comp invite ever exists.
cubic review on PR #421. Six fixes across the organizer surface:

- listActiveInvitesFn: filtered only on championshipCompetitionId, so
  terminal rows from any division leaked into the response and were
  treated as drafts. Add championshipDivisionId + activeMarker = active.
- Organizer loader: passed an empty-string championshipDivisionId when
  no division existed, failing the server fn's Zod input. Skip the call
  entirely on no-division and return an empty invites array — the
  downstream UI already renders an empty-state for missing divisions.
- Send-invites dialog deadline: defaulted via toISOString().slice(0, 10),
  which shifted by a day for users east of UTC at the time-of-day
  boundary. Build the YYYY-MM-DD from local-date components instead.
- Send-invites dialog Send: was reachable with an empty / invalid
  deadline, which then constructed `new Date("T23:59:59")` (NaN). Block
  on the Send button and re-validate inside onSubmit so a typed-then-
  cleared deadline is rejected with a clear error message.
- Add-bespoke + bulk-add dialogs: external `open={false}` toggles bypass
  Radix's onOpenChange, so the cleanup wrapper that called reset() never
  fired. Add a useEffect that wipes local state on every open=false
  transition, closing the stale-input + stale-error reopen issues both
  reviewers flagged.
- Draft-bespoke filter: filtered on origin + division + token-null but
  not activeMarker, so a row that lost its "active" status (declined,
  expired, revoked) but kept origin + draftiness could still appear as a
  sendable draft. Add the activeMarker = "active" predicate.
@theianjones theianjones force-pushed the feat/competition-invites-phase-2-ui branch from dd190fc to ff8aa9d 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
The `/compete/$slug/register` search params now accept both `invite`
(plaintext token, for the Stripe-workflow flip) and `divisionId` (to
pre-select the invited division in the form). Documented as part of the
claim→register hand-off section so future readers see the full shape
passed through.
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.

1 issue found across 1 file (changes from recent commits).

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="lat.md/competition-invites.md">

<violation number="1" location="lat.md/competition-invites.md:157">
P3: `divisionId` is not wired into `RegistrationForm`, so it does not currently pre-select or pin a division.</violation>
</file>

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

Comment thread lat.md/competition-invites.md Outdated
Previously the claim → register hand-off forwarded ?invite=<token>&divisionId=<id>
but the register page ignored divisionId and left the multi-select open. The
server fn's items.some(...) check also accepted the invited division alongside
extras. Lock both ends:

- Forward divisionId from search into RegistrationForm as inviteDivisionId.
- When inviteToken + inviteDivisionId resolve to a real division, pre-seed
  selectedDivisionIds + teamEntries, swap the multi-select for a read-only
  "Invited Division" display, and bypass the !registrationOpen disable / banner
  / submit-button copy so the form mirrors the server's window-skip.
- Tighten initiateRegistrationPaymentFn: when inviteToken is set, require
  items.length === 1 && items[0].divisionId === invite.championshipDivisionId.

Updates lat.md/competition-invites.md to reflect the single-division enforcement
on both the server fn and the form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 issue found across 4 files (changes from recent commits).

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/components/registration/registration-form.tsx">

<violation number="1" location="apps/wodsmith-start/src/components/registration/registration-form.tsx:767">
P1: Invite-locked registrations bypass window checks, but required form fields remain disabled when registration is closed, which blocks invitees from completing registration.</violation>
</file>

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

const submitDisabled =
isSubmitting ||
!registrationOpen ||
(!registrationOpen && !isInviteLocked) ||
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 24, 2026

Choose a reason for hiding this comment

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

P1: Invite-locked registrations bypass window checks, but required form fields remain disabled when registration is closed, which blocks invitees from completing registration.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/wodsmith-start/src/components/registration/registration-form.tsx, line 767:

<comment>Invite-locked registrations bypass window checks, but required form fields remain disabled when registration is closed, which blocks invitees from completing registration.</comment>

<file context>
@@ -719,11 +758,13 @@ export function RegistrationForm({
   const submitDisabled =
     isSubmitting ||
-    !registrationOpen ||
+    (!registrationOpen && !isInviteLocked) ||
     competitionFull ||
     !hasSelectedDivisions ||
</file context>
Fix with Cubic

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/wodsmith-start/src/components/registration/registration-form.tsx (1)

995-1000: ⚠️ Potential issue | 🟠 Major

Invite-locked registrations are blocked when the registration window is closed.

isInviteLocked bypasses the registration-window check for submit-button enablement (Line 767) and for the "Registration Closed" banner/label, but many form inputs are still gated on !registrationOpen alone. When an invite-holder arrives outside the window, they hit an enabled Submit button but can't fill in:

  • AffiliateCombobox (Line 999) — affiliate is required for submission
  • Registration question inputs (Lines 1029, 1049, 1057)
  • Team name/teammate fields (Lines 1183, 1270, 1288, 1304, 1322)
  • Waiver checkboxes (Line 1376)

Since affiliate and required questions/waivers are validated in onSubmit, the athlete sees the form as broken — the whole point of invite-lock is they can register before open / after close.

🔧 Proposed fix — gate the same way as submit

Replace every disabled={isSubmitting || !registrationOpen} in the form body with the invite-aware variant, e.g.:

-              disabled={isSubmitting || !registrationOpen}
+              disabled={isSubmitting || (!registrationOpen && !isInviteLocked)}

Apply to the AffiliateCombobox (Line 999), Select/Input controls in the questions section (Lines 1029, 1049, 1057), team name Input (Line 1183), teammate Email/First/Last/Affiliate inputs (Lines 1270, 1288, 1304, 1322), and the waiver Checkbox (Line 1376). A small helper like const fieldsDisabled = isSubmitting || (!registrationOpen && !isInviteLocked) makes this a single edit point.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/wodsmith-start/src/components/registration/registration-form.tsx` around
lines 995 - 1000, The form currently disables many inputs using
disabled={isSubmitting || !registrationOpen}, which blocks invite-locked users;
change those disabled checks to respect isInviteLocked by computing a single
helper (e.g. const fieldsDisabled = isSubmitting || (!registrationOpen &&
!isInviteLocked)) and use fieldsDisabled for AffiliateCombobox, all registration
question inputs, team name and teammate fields, and the waiver Checkbox so
invite-holders can edit fields that are validated in onSubmit while keeping the
submit gating behavior intact.
🧹 Nitpick comments (1)
apps/wodsmith-start/src/components/organizer/invites/send-invites-dialog.tsx (1)

132-160: Optional: mirror server-side length limits on the inputs.

issueInvitesInputSchema caps subject at 255 and bodyText at 10,000 chars. Adding maxLength to the Input/Textarea gives users early feedback instead of a server-side validation error after they click Send. Same idea for the recipients 500-item ceiling — worth a client-side guard or helper text if the caller can exceed it.

🎨 Suggested diff
             <Input
               id="send-subject"
               value={subject}
               onChange={(e) => setSubject(e.target.value)}
               disabled={submitting}
+              maxLength={255}
               required
             />
@@
             <Textarea
               id="send-body"
               value={bodyText}
               onChange={(e) => setBodyText(e.target.value)}
               disabled={submitting}
+              maxLength={10_000}
               placeholder="Leave blank to use the default invitation copy."
               className="h-24"
             />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/wodsmith-start/src/components/organizer/invites/send-invites-dialog.tsx`
around lines 132 - 160, The form lacks client-side limits matching
issueInvitesInputSchema; add maxLength={255} to the subject Input
(id="send-subject") and maxLength={10000} to the body Textarea (id="send-body")
so users get immediate feedback, and add a client-side guard/display near the
recipients list (the component managing recipients) to prevent or warn when
recipients exceed 500 (show a count/helper text and disable adding above 500).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsx`:
- Around line 96-100: The duplicateEmails mapping drops rowNumber, causing loss
of context and non-unique labels/keys; update the duplicateEmails mapping in
BulkAddInviteesDialog so it includes the row number (e.g., map result.duplicates
to `Row ${d.rowNumber}: ${d.email} (${d.reason})`) so labels match invalidLines
and produce unique strings for React keys.

In
`@apps/wodsmith-start/src/components/organizer/invites/send-invites-dialog.tsx`:
- Around line 119-120: The dialog keeps transient state (result, error, subject,
bodyText, deadline) between opens; add a useEffect in send-invites-dialog.tsx
that watches the open prop and resets those state variables to their initial
values whenever open transitions to false (or when it becomes true) so reopening
shows a fresh form and the footer reverts to "Send"; locate state setters for
result, error, subject, bodyText, and deadline and clear them in that effect and
ensure rendering logic for the footer and the "Queued N invite emails" alert
uses the cleared states to switch back to the send UI.

In
`@apps/wodsmith-start/src/routes/compete/organizer/`$competitionId/invites/index.tsx:
- Around line 181-214: activeInviteByEmail is computed but not used by the table
rendering or select-all logic, causing checked boxes to be dropped when sending;
update the table row rendering and selection helpers to consult
activeInviteByEmail so already-invited rows are indicated and not selectable.
Specifically, change toggleAllRoster to iterate sendableRosterRows (or filter
roster.rows by existence in activeInviteByEmail using rosterRowKey) instead of
every row with athleteEmail, and in the per-row render (where StatusPill is
shown) use
activeInviteByEmail.get(`${r.championshipDivisionId}::${(r.athleteEmail ??
"").toLowerCase()}`) to render an "Invited" badge and disable the checkbox for
that row (or mark it non-selectable and exclude it from selectedRosterKeys).
Ensure references: activeInviteByEmail, sendableRosterRows, toggleAllRoster,
StatusPill, roster.rows, rosterRowKey, selectedRosterKeys are updated
accordingly.

In `@apps/wodsmith-start/src/server-fns/competition-invite-fns.ts`:
- Around line 1124-1144: The query is returning full competitionInvitesTable
rows (via select()) and leaking sensitive fields like
claimTokenHash/claimTokenLast4; change the select to project only the minimal
DTO fields needed by the client (e.g., id, email, state/status, createdAt,
inviterId) instead of the entire row—update the query around
competitionInvitesTable.select(...) to list explicit columns and map the result
to a lightweight shape before returning { invites }, preserving the existing
where(...) filters (including COMPETITION_INVITE_ACTIVE_MARKER).

In `@apps/wodsmith-start/src/server-fns/registration-fns.ts`:
- Around line 311-320: The invite branch currently locks division selection but
doesn't handle zero-dollar checkouts: when the code later takes the allFree path
it creates a registration without marking the invite (so invite remains
pending). Update registration-fns.ts to either reject 100% discount checkouts
that include inviteIdForPurchase (e.g., validate and throw when
input.inviteIdForPurchase is present and total becomes zero) or, preferably,
mirror the paid flow by updating the allFree branch to transition the invite to
accepted (set invite.status to "accepted_paid" or the equivalent) and record
claimedRegistrationId on the invite record after creating the registration;
reference the identifiers inviteIdForPurchase, allFree, accepted_paid,
claimedRegistrationId, input.items and invite.championshipDivisionId to locate
where to add the check or invite update.

In `@apps/wodsmith-start/src/server/competition-invites/expiry.ts`:
- Around line 61-83: The code currently returns the candidate count (ids.length)
instead of the actual number of rows updated; capture the result of the
db.update(...) call (e.g., assign it to a variable like updateResult) and return
the actual affected-row count from that result (use the ORM/driver field
returned such as rowCount/numUpdatedRows/affectedRows or use a .returning()
count if available) instead of ids.length; keep the existing WHERE predicate
using competitionInvitesTable, COMPETITION_INVITE_STATUS.PENDING and ids to
ensure the real transitioned count is returned.

---

Outside diff comments:
In `@apps/wodsmith-start/src/components/registration/registration-form.tsx`:
- Around line 995-1000: The form currently disables many inputs using
disabled={isSubmitting || !registrationOpen}, which blocks invite-locked users;
change those disabled checks to respect isInviteLocked by computing a single
helper (e.g. const fieldsDisabled = isSubmitting || (!registrationOpen &&
!isInviteLocked)) and use fieldsDisabled for AffiliateCombobox, all registration
question inputs, team name and teammate fields, and the waiver Checkbox so
invite-holders can edit fields that are validated in onSubmit while keeping the
submit gating behavior intact.

---

Nitpick comments:
In
`@apps/wodsmith-start/src/components/organizer/invites/send-invites-dialog.tsx`:
- Around line 132-160: The form lacks client-side limits matching
issueInvitesInputSchema; add maxLength={255} to the subject Input
(id="send-subject") and maxLength={10000} to the body Textarea (id="send-body")
so users get immediate feedback, and add a client-side guard/display near the
recipients list (the component managing recipients) to prevent or warn when
recipients exceed 500 (show a count/helper text and disable adding above 500).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 445361a1-252f-4eca-88db-282336f49eaf

📥 Commits

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

📒 Files selected for processing (17)
  • apps/wodsmith-start/src/components/organizer/invites/add-bespoke-invitee-dialog.tsx
  • apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsx
  • apps/wodsmith-start/src/components/organizer/invites/championship-roster-table.tsx
  • apps/wodsmith-start/src/components/organizer/invites/send-invites-dialog.tsx
  • apps/wodsmith-start/src/components/registration/registration-form.tsx
  • apps/wodsmith-start/src/routes/compete/$slug/claim/$token.tsx
  • apps/wodsmith-start/src/routes/compete/$slug/register.tsx
  • apps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/index.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/competition-invites/expiry.ts
  • apps/wodsmith-start/src/server/competition-invites/roster.ts
  • apps/wodsmith-start/test/components/championship-roster-table.test.tsx
  • apps/wodsmith-start/test/server/competition-invites/roster.test.ts
  • docs/adr/0011-competition-invites.md
  • docs/plans/0011-competition-invites-execution.md
  • lat.md/competition-invites.md

Comment on lines +96 to +100
invalidLines: result.invalid.map(
(r) => `Row ${r.rowNumber}: ${r.reason}`,
),
duplicateEmails: result.duplicates.map(
(d) => `${d.email} (${d.reason})`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

wc -l apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsx

Repository: wodsmith/thewodapp

Length of output: 148


🏁 Script executed:

sed -n '80,120p' apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsx

Repository: wodsmith/thewodapp

Length of output: 1071


🏁 Script executed:

sed -n '1,50p' apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsx

Repository: wodsmith/thewodapp

Length of output: 1420


🏁 Script executed:

sed -n '100,180p' apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsx

Repository: wodsmith/thewodapp

Length of output: 2886


🏁 Script executed:

grep -n "duplicateEmails\|invalidLines" apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsx

Repository: wodsmith/thewodapp

Length of output: 622


Include rowNumber in duplicateEmails, consistent with invalidLines.

The invalidLines mapping preserves rowNumber (e.g., "Row 5: ..."), but duplicateEmails drops it by mapping only email and reason. This leaves the organizer unable to identify which pasted line was duplicate. Additionally, if two rows have the same email and reason, they produce identical React keys. Keep the row number in the duplicate label.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsx`
around lines 96 - 100, The duplicateEmails mapping drops rowNumber, causing loss
of context and non-unique labels/keys; update the duplicateEmails mapping in
BulkAddInviteesDialog so it includes the row number (e.g., map result.duplicates
to `Row ${d.rowNumber}: ${d.email} (${d.reason})`) so labels match invalidLines
and produce unique strings for React keys.

Comment on lines +119 to +120
return (
<Dialog open={open} onOpenChange={onOpenChange}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Dialog state is not reset when the dialog closes.

After a successful send or error, result, error, subject, bodyText, and deadline persist in state. If the parent keeps this component mounted (which it does when it toggles open), reopening the dialog shows the prior "Queued N invite emails" alert and stale form values — and the footer stays in "Close" mode instead of "Send". Consider resetting transient state when open transitions to false (e.g., effect on open) or when it transitions to true.

🛠️ Suggested fix
-import { useState } from "react"
+import { useEffect, useState } from "react"
@@
   const [result, setResult] = useState<{
     sentCount: number
     skippedCount: number
   } | null>(null)
+
+  // Reset transient state whenever the dialog is closed so a reopen
+  // starts fresh (no stale success alert, no stale error).
+  useEffect(() => {
+    if (!open) {
+      setError(null)
+      setResult(null)
+      setSubmitting(false)
+    }
+  }, [open])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (
<Dialog open={open} onOpenChange={onOpenChange}>
import { useEffect, useState } from "react"
// ... other state declarations ...
const [result, setResult] = useState<{
sentCount: number
skippedCount: number
} | null>(null)
// Reset transient state whenever the dialog is closed so a reopen
// starts fresh (no stale success alert, no stale error).
useEffect(() => {
if (!open) {
setError(null)
setResult(null)
setSubmitting(false)
}
}, [open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/wodsmith-start/src/components/organizer/invites/send-invites-dialog.tsx`
around lines 119 - 120, The dialog keeps transient state (result, error,
subject, bodyText, deadline) between opens; add a useEffect in
send-invites-dialog.tsx that watches the open prop and resets those state
variables to their initial values whenever open transitions to false (or when it
becomes true) so reopening shows a fresh form and the footer reverts to "Send";
locate state setters for result, error, subject, bodyText, and deadline and
clear them in that effect and ensure rendering logic for the footer and the
"Queued N invite emails" alert uses the cleared states to switch back to the
send UI.

Comment on lines +181 to +214
const activeInviteByEmail = useMemo(() => {
const map = new Map<string, CompetitionInvite>()
for (const inv of activeInvites) {
if (inv.activeMarker === "active") {
map.set(`${inv.championshipDivisionId}::${inv.email.toLowerCase()}`, inv)
}
}
return map
}, [activeInvites])

const draftBespokeInvites = useMemo(
() =>
activeInvites.filter(
(inv) =>
inv.origin === COMPETITION_INVITE_ORIGIN.BESPOKE &&
inv.championshipDivisionId === activeDivisionId &&
inv.activeMarker === "active" &&
!inv.claimTokenHash,
),
[activeInvites, activeDivisionId],
)

const sendableRosterRows = useMemo(
() =>
roster.rows.filter(
(r: RosterRow) =>
!!r.athleteEmail &&
selectedRosterKeys.has(rosterRowKey(r)) &&
!activeInviteByEmail.get(
`${r.championshipDivisionId}::${(r.athleteEmail ?? "").toLowerCase()}`,
),
),
[roster.rows, selectedRosterKeys, activeInviteByEmail],
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Roster checkboxes silently "swallow" already-invited rows.

activeInviteByEmail is only consulted when computing recipients — the roster table itself still shows every row as selectable and labeled "Not invited" (the StatusPill is hardcoded). Two user-visible consequences:

  • Select-all mismatch: toggleAllRoster (Line 251) checks every row that has athleteEmail, including rows for athletes who already have an active invite. The "Send invites (N)" chip then reflects recipients.length, which is lower than the number of ticked boxes. The organizer has no clue why.
  • Per-row confusion: An organizer can manually check a row for an already-invited athlete; nothing in the UI tells them the row will be dropped on send.

Consider piping activeInviteByEmail into the table so already-invited rows either (a) render a different status pill and are non-selectable, or (b) are at least filtered out of select-all. A lightweight first step is to exclude them from toggleAllRoster and show an "Invited" badge.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/wodsmith-start/src/routes/compete/organizer/`$competitionId/invites/index.tsx
around lines 181 - 214, activeInviteByEmail is computed but not used by the
table rendering or select-all logic, causing checked boxes to be dropped when
sending; update the table row rendering and selection helpers to consult
activeInviteByEmail so already-invited rows are indicated and not selectable.
Specifically, change toggleAllRoster to iterate sendableRosterRows (or filter
roster.rows by existence in activeInviteByEmail using rosterRowKey) instead of
every row with athleteEmail, and in the per-row render (where StatusPill is
shown) use
activeInviteByEmail.get(`${r.championshipDivisionId}::${(r.athleteEmail ??
"").toLowerCase()}`) to render an "Invited" badge and disable the checkbox for
that row (or mark it non-selectable and exclude it from selectedRosterKeys).
Ensure references: activeInviteByEmail, sendableRosterRows, toggleAllRoster,
StatusPill, roster.rows, rosterRowKey, selectedRosterKeys are updated
accordingly.

Comment on lines +1124 to +1144
const invites = await db
.select()
.from(competitionInvitesTable)
.where(
and(
eq(
competitionInvitesTable.championshipCompetitionId,
data.championshipCompetitionId,
),
eq(
competitionInvitesTable.championshipDivisionId,
data.championshipDivisionId,
),
eq(
competitionInvitesTable.activeMarker,
COMPETITION_INVITE_ACTIVE_MARKER,
),
),
)

return { invites }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Project an explicit DTO here instead of returning full invite rows.

select() will serialize every column on competitionInvitesTable back to the organizer client, including internal token fields like claimTokenHash / claimTokenLast4. The consuming route only needs invite-state data for overlays and email exclusion, so this should return a minimal shape instead of the raw table row.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/wodsmith-start/src/server-fns/competition-invite-fns.ts` around lines
1124 - 1144, The query is returning full competitionInvitesTable rows (via
select()) and leaking sensitive fields like claimTokenHash/claimTokenLast4;
change the select to project only the minimal DTO fields needed by the client
(e.g., id, email, state/status, createdAt, inviterId) instead of the entire
row—update the query around competitionInvitesTable.select(...) to list explicit
columns and map the result to a lightweight shape before returning { invites },
preserving the existing where(...) filters (including
COMPETITION_INVITE_ACTIVE_MARKER).

Comment on lines +311 to +320
// Invites are pinned to a single division — reject both wrong-division
// and "invited division plus extras" submissions. The UI hides the
// multi-select when arriving from a claim link; this is the server-side
// backstop.
if (
!input.items.some(
(i) => i.divisionId === invite.championshipDivisionId,
)
input.items.length !== 1 ||
input.items[0].divisionId !== invite.championshipDivisionId
) {
throw new Error(
"Invite is locked to a different division than the one selected",
"Invite is locked to a single division — register only for the invited division",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Handle zero-dollar invite checkouts explicitly.

This locks invite claims to a single division, but an invited athlete can still hit the later allFree path if a 100% coupon zeros out the total. That branch creates the registration directly and returns without ever using inviteIdForPurchase, so the invite stays pending even though the athlete is now registered. Please either reject full-discount invite checkouts or mirror the accepted_paid/claimedRegistrationId transition in the free branch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/wodsmith-start/src/server-fns/registration-fns.ts` around lines 311 -
320, The invite branch currently locks division selection but doesn't handle
zero-dollar checkouts: when the code later takes the allFree path it creates a
registration without marking the invite (so invite remains pending). Update
registration-fns.ts to either reject 100% discount checkouts that include
inviteIdForPurchase (e.g., validate and throw when input.inviteIdForPurchase is
present and total becomes zero) or, preferably, mirror the paid flow by updating
the allFree branch to transition the invite to accepted (set invite.status to
"accepted_paid" or the equivalent) and record claimedRegistrationId on the
invite record after creating the registration; reference the identifiers
inviteIdForPurchase, allFree, accepted_paid, claimedRegistrationId, input.items
and invite.championshipDivisionId to locate where to add the check or invite
update.

Comment on lines +61 to +83
await db
.update(competitionInvitesTable)
.set({
status: COMPETITION_INVITE_STATUS.EXPIRED,
claimTokenHash: null,
claimTokenLast4: null,
activeMarker: null,
updatedAt: now,
})
.where(
and(
inArray(competitionInvitesTable.id, ids),
// Re-check predicate so a status change that happened between
// SELECT and UPDATE (e.g. athlete just claimed) doesn't get
// stomped.
eq(
competitionInvitesTable.status,
COMPETITION_INVITE_STATUS.PENDING,
),
),
)

return { transitioned: ids.length }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Return the actual number of rows updated, not the candidate count.

The UPDATE intentionally re-checks status = "pending", so a concurrent claim or reissue can shrink the affected set after the initial SELECT. Returning ids.length here overstates how many invites were actually transitioned and breaks the function's own contract.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/wodsmith-start/src/server/competition-invites/expiry.ts` around lines 61
- 83, The code currently returns the candidate count (ids.length) instead of the
actual number of rows updated; capture the result of the db.update(...) call
(e.g., assign it to a variable like updateResult) and return the actual
affected-row count from that result (use the ORM/driver field returned such as
rowCount/numUpdatedRows/affectedRows or use a .returning() count if available)
instead of ids.length; keep the existing WHERE predicate using
competitionInvitesTable, COMPETITION_INVITE_STATUS.PENDING and ids to ensure the
real transitioned count is returned.

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