feat(invites): ADR-0011 Phase 2D — organizer UI, claim→register, expiry sweep#421
Conversation
WalkthroughThe 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
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 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.
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.
f5d01b2 to
c4835c9
Compare
f87ba56 to
0330e7e
Compare
c4835c9 to
e72ee2b
Compare
0330e7e to
8c645f7
Compare
…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.
dd190fc to
ff8aa9d
Compare
8c645f7 to
1e245b1
Compare
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.
There was a problem hiding this comment.
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.
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>
There was a problem hiding this comment.
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) || |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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 | 🟠 MajorInvite-locked registrations are blocked when the registration window is closed.
isInviteLockedbypasses 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!registrationOpenalone. 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.
issueInvitesInputSchemacapssubjectat 255 andbodyTextat 10,000 chars. AddingmaxLengthto theInput/Textareagives users early feedback instead of a server-side validation error after they click Send. Same idea for therecipients500-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
📒 Files selected for processing (17)
apps/wodsmith-start/src/components/organizer/invites/add-bespoke-invitee-dialog.tsxapps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsxapps/wodsmith-start/src/components/organizer/invites/championship-roster-table.tsxapps/wodsmith-start/src/components/organizer/invites/send-invites-dialog.tsxapps/wodsmith-start/src/components/registration/registration-form.tsxapps/wodsmith-start/src/routes/compete/$slug/claim/$token.tsxapps/wodsmith-start/src/routes/compete/$slug/register.tsxapps/wodsmith-start/src/routes/compete/organizer/$competitionId/invites/index.tsxapps/wodsmith-start/src/server-fns/competition-invite-fns.tsapps/wodsmith-start/src/server-fns/registration-fns.tsapps/wodsmith-start/src/server/competition-invites/expiry.tsapps/wodsmith-start/src/server/competition-invites/roster.tsapps/wodsmith-start/test/components/championship-roster-table.test.tsxapps/wodsmith-start/test/server/competition-invites/roster.test.tsdocs/adr/0011-competition-invites.mddocs/plans/0011-competition-invites-execution.mdlat.md/competition-invites.md
| invalidLines: result.invalid.map( | ||
| (r) => `Row ${r.rowNumber}: ${r.reason}`, | ||
| ), | ||
| duplicateEmails: result.duplicates.map( | ||
| (d) => `${d.email} (${d.reason})`, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
wc -l apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsxRepository: wodsmith/thewodapp
Length of output: 148
🏁 Script executed:
sed -n '80,120p' apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsxRepository: wodsmith/thewodapp
Length of output: 1071
🏁 Script executed:
sed -n '1,50p' apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsxRepository: wodsmith/thewodapp
Length of output: 1420
🏁 Script executed:
sed -n '100,180p' apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsxRepository: wodsmith/thewodapp
Length of output: 2886
🏁 Script executed:
grep -n "duplicateEmails\|invalidLines" apps/wodsmith-start/src/components/organizer/invites/bulk-add-invitees-dialog.tsxRepository: 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.
| return ( | ||
| <Dialog open={open} onOpenChange={onOpenChange}> |
There was a problem hiding this comment.
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.
| 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.
| 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], | ||
| ) |
There was a problem hiding this comment.
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 hasathleteEmail, including rows for athletes who already have an active invite. The "Send invites (N)" chip then reflectsrecipients.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.
| 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 } |
There was a problem hiding this comment.
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).
| // 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", |
There was a problem hiding this comment.
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.
| 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 } |
There was a problem hiding this comment.
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.
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_paidafter the athlete finishes Stripe Checkout.What changed
Organizer dialogs (new in
src/components/organizer/invites/)add-bespoke-invitee-dialog.tsx— single-email form wrappingcreateBespokeInviteFn. 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. ReportssentCount/skippedCountafter submit.Roster table (
championship-roster-table.tsx)selectedKeys,onToggleSelection,onToggleAll) — opt-in, so read-only consumers stay clean.athleteEmailare rendered with a disabled checkbox +"No email on file"tooltip.Roster data hydration (
server/competition-invites/roster.ts)RosterRowgainsathleteEmail: string | null.getChampionshipRosterdoes a single bulkuserTablelookup keyed by the row'suserIdto fill it. One extra query per roster render.Route wiring (
routes/compete/organizer/$competitionId/invites/index.tsx)Claim → register hand-off
/compete/$slug/register?invite=<token>.validateSearchacceptsinvite; routes it toRegistrationFormasinviteToken.RegistrationFormpassesinviteTokentoinitiateRegistrationPaymentFn. 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 toaccepted_paid.Invite expiry sweep (new in
server/competition-invites/expiry.ts)sweepExpiredInvites()— paginates pending invites withexpiresAt < now, batched at 200/tick. NullsactiveMarker+claimTokenHash. Idempotent via re-assertedstatus = pendingpredicate on the update.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:
accepted_paidE2E. Worth a follow-up PR.StatusPillis still "Not invited" for every row. A later phase swaps in real state fromactiveInvites.alchemy.run.ts— Phase 3 adds it with round-based deadlines.Test plan
pnpm test -- test/server/competition-invites test/lib/competition-invites— 64 greenpnpm type-checkclean;lat checkpassespnpm devwith seeded data:/compete/organizer/comp_inv_championship/invites→ Add invitee, Bulk add, and Send buttons render.created / duplicates / invalidwith expandable row-level details.athleteEmailset) + check a draft bespoke → Send → dialog submits → Drizzle Studio shows tokens +sentemailDeliveryStatus on both rows. WithoutRESEND_API_KEY, look for[Email Preview]in dev console andskippedstatus.?invite=<token>→ complete Stripe Checkout → workflow flips the invite toaccepted_paid, registration links viaclaimedRegistrationId.sweepExpiredInvites()manually against seeded data —cinv_seed_pending_mikepast its expiry should flip toexpired.🤖 Generated with Claude Code
mainSummary 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
issueInvitesFnreports sent/skipped; per-recipient claim links with a locale-safe default deadline./compete/$slug/register?invite=<token>&divisionId=<id>;RegistrationFormlocks to the invited division and forwardsinviteTokento payment; server requires a single item matching the invited division; Stripe workflow flips the invite toaccepted_paid.RosterRow.athleteEmailhydrated via one bulkuserTablelookup;ChampionshipRosterTablesupports optional selection.listActiveInvitesFnreturns only active invites for the active division (filters by division andactiveMarker); Send dialog usesissueInvitesFn.sweepExpiredInvites()batch-expires pending invites pastexpiresAt, clearsactiveMarker/claimTokenHash, and is idempotent.Bug Fixes
Route.useParams()in the claim route to top-level to satisfy hook rules.inviteTokenis present.Written for commit 3339b67. Summary will update on new commits.
Summary by CodeRabbit
New Features
Tests
Documentation