Skip to content

feat(invites): ADR-0011 Phase 2A — schema, tokens, issue + bespoke helpers#418

Open
theianjones wants to merge 2 commits intomainfrom
feat/competition-invites-phase-2
Open

feat(invites): ADR-0011 Phase 2A — schema, tokens, issue + bespoke helpers#418
theianjones wants to merge 2 commits intomainfrom
feat/competition-invites-phase-2

Conversation

@theianjones
Copy link
Copy Markdown
Contributor

@theianjones theianjones commented Apr 23, 2026

Summary

Phase 2 sub-arc A of ADR-0011 — the data + pure-logic groundwork for email-locked single-send invites. No UI, no routes, no email yet. Follow-up PRs land those in sub-arcs B (claim + auth), C (Stripe workflow + queue + server fns), and D (organizer UI + cron + integration tests).

What changed

  • competition_invites table with the four indexes that enforce the invite correctness model:
    • UNIQUE (championshipCompetitionId, email, championshipDivisionId, activeMarker) — "at most one active invite per (championship, division, email)" via MySQL's multiple-NULL semantics. Terminal rows null activeMarker so they accumulate without collision.
    • UNIQUE claim_token_hash — tokens are globally unique while live; NULL on terminal transitions so historical rows coexist.
    • Secondary indexes on roundId, sourceId, origin, status, email, championshipCompetitionId, userId.
    • championshipCompetitionId and championshipDivisionId are varchar(64) instead of varchar(255) so the unique key fits MySQL's 3072-byte limit (ULIDs are ~30 chars, 64 is plenty).
    • roundId is varchar(255) NOT NULL default "" — Phase 2 sentinel; Phase 3 replaces with a real round FK + backfill.
  • src/lib/competition-invites/tokens.ts — 32-byte URL-safe random token (getRandomValues + base64url-no-padding), SHA-256 hash (oslojs), last4 extraction. Pure functions.
  • src/server/competition-invites/issue.tsissueInvitesForRecipients (new inserts, { inserted, alreadyActive } return) + reissueInvite (rotate token, bump sendAttempt, cover both extend-expired and activate-draft-bespoke paths). Both reject $0-fee divisions (free comps are out of scope per ADR Capacity Math).
  • src/server/competition-invites/bespoke.tscreateBespokeInvite (single-add) + createBespokeInvitesBulk (CSV/TSV/one-email-per-line paste, 500-row cap, structured duplicate/invalid reporting).
  • Seed updates: 20-competition-invites.ts now inserts 6 competition_invites rows (pending source, accepted_paid source, expired source, declined source, draft bespoke, sent bespoke) so the lifecycle states are inspectable in Drizzle Studio. Deterministic tokens logged on seed run.
  • lat.md/competition-invites.md sections for invites schema, token helpers, issue helpers, bespoke helpers, and Phase-2 seed additions.

Stacked follow-ups (local stack — stacked PRs disabled at repo level, so posting as a sequence)

  • B claim resolution + /compete/$slug/claim/$token routes + sign-in/sign-up ?claim= extension
  • C registration-fn inviteToken param + Stripe workflow invite-status step + email queue consumer + invite server fns
  • D organizer bespoke dialogs + roster send UX + invite-expiry cron + integration test

Test plan

  • pnpm test -- test/lib/competition-invites test/server/competition-invites — 50 tests
  • pnpm type-check clean
  • DATABASE_URL='mysql://…/wodsmith-db' pnpm db:push — table + indexes created, no drift
  • pnpm db:seed — 6 invite rows land in competition_invites, one per lifecycle state; deterministic tokens printed
  • Attempt to insert a duplicate active invite for a seeded (championship, email, division) → MySQL rejects with ER_DUP_ENTRY
  • Attempt to insert the same (championship, email, division) with active_marker = NULL → succeeds (terminal row accumulation works)

🤖 Generated with Claude Code


Summary by cubic

Phase 2A of ADR-0011: adds the invites data layer and pure helpers for email‑locked single‑send invites. Now includes safety fixes in reissue logic, email validation, and CSV parsing. No UI or email routes yet.

  • New Features

    • Schema: competition_invites with one‑active‑invite uniqueness via activeMarker, unique claimTokenHash, roundId empty‑string sentinel, shortened ULID FKs (varchar(64)), delivery/status fields, and createCompetitionInviteId.
    • Tokens: 32‑byte URL‑safe tokens, SHA‑256 hashing, last‑4 helper in src/lib/competition-invites/tokens.ts (uses @oslojs/encoding); pure and unit‑tested.
    • Issue: issueInvitesForRecipients (transactional inserts; returns plaintext tokens) and reissueInvite (rotate token, bump expiry/sendAttempt, restore active; activate draft bespoke or extend expired). Normalizes emails; rejects $0‑fee divisions.
    • Bespoke: createBespokeInvite and createBespokeInvitesBulk (CSV/TSV/one‑per‑line, header‑aware, auto delimiter, 500‑row cap) with structured invalid/duplicate reporting; draft rows are “active but no token”.
    • Seeds/Docs/Tests: six competition_invites rows covering lifecycle states with deterministic tokens; cleanup order updated; docs in lat.md; unit tests for tokens/issue/bespoke.
  • Bug Fixes

    • Reissue race: UPDATE pinned to status IN (pending, expired); re‑read on 0 affected rows to surface precise concurrent transition.
    • Draft detection: avoid classifying accepted_paid (hash nulled) as draft by requiring status === pending.
    • Bespoke create: wrap existence check + insert in a transaction to prevent ER_DUP_ENTRY leaks.
    • Email validation: shared EMAIL_PATTERN applied to issue and bespoke paths (was normalize‑only).
    • CSV parsing: quote‑aware splitter with """ decoding; TSV stays simple split. Tests cover new edge cases.

Written for commit 90c4381. Summary will update on new commits.

Summary by CodeRabbit

Release Notes

New Features

  • Added competition invite system supporting single and bulk creation
  • CSV/TSV bulk import with automatic deduplication
  • Invite lifecycle tracking (pending, accepted, declined, expired) and email delivery monitoring
  • Token-based invite claims with cryptographic hashing
  • Validation enforcing paid-only competitions
  • Invite reissuance functionality with token rotation

Tests

  • Added comprehensive test coverage for invite utilities and creation workflows

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

Walkthrough

This PR implements Phase 2 competition invite functionality, adding a new competition_invites database table with support for multiple invite statuses (pending, accepted, declined, expired, revoked). It includes token generation and hashing utilities, transactional issuance and reissuance helpers, bespoke invite staging with email parsing, comprehensive test coverage, seed data, and documentation.

Changes

Cohort / File(s) Summary
Database Schema & Helpers
apps/wodsmith-start/src/db/schemas/common.ts, apps/wodsmith-start/src/db/schemas/competition-invites.ts
New competitionInvitesTable with columns for invite metadata, hashed tokens, expiry/send tracking, lifecycle statuses, and unique indexes enforcing active invite caps per (competition, division, email) and global claim token uniqueness. Exported constants for origin, status, and delivery status enums, plus new createCompetitionInviteId() helper.
Token Generation & Hashing
apps/wodsmith-start/src/lib/competition-invites/tokens.ts
New module exporting utilities for generating URL-safe base64url random tokens, computing SHA-256 hashes, extracting last-4 suffix, and bundling all artifacts together for consistency.
Issue & Reissue Logic
apps/wodsmith-start/src/server/competition-invites/issue.ts
Transactional database helpers: issueInvitesForRecipients() validates recipients, checks existing active invites, generates claim tokens, and bulk-inserts PENDING rows; reissueInvite() rotates tokens, increments send attempts, transitions expired/draft states back to active, and optionally updates round assignment.
Bespoke Invite Staging
apps/wodsmith-start/src/server/competition-invites/bespoke.ts
Single and bulk invite creation with email normalization, free-division rejection, duplicate prevention, and CSV/TSV/line parsing. Bulk operation deduplicates within paste and against existing active invites, enforces max 500 rows, and returns structured invalid/duplicate results.
Test Coverage
apps/wodsmith-start/test/lib/competition-invites/tokens.test.ts, apps/wodsmith-start/test/server/competition-invites/bespoke.test.ts, apps/wodsmith-start/test/server/competition-invites/issue.test.ts
Comprehensive test suites validating token generation/hashing determinism and format, bespoke parsing across CSV/TSV/line formats and header detection, email normalization, and issue flow validation including free-division rejection.
Seed Data & Cleanup
apps/wodsmith-start/scripts/seed/seeders/20-competition-invites.ts, apps/wodsmith-start/scripts/seed/cleanup.ts
New seeder generating deterministic claim tokens via SHA-256 hashing and bulk-inserting six Phase 2 lifecycle scenarios (pending, accepted+paid, expired, declined, draft-not-sent, sent). Cleanup routine adds competition_invites to ordered deletion list.
Documentation
lat.md/competition-invites.md
Phase 2 specification covering table structure, invariants, token utilities, DB-side issue/reissue semantics, bespoke staging behavior, and seed data determinism.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Server
    participant TokenService as Token Service
    participant Database
    
    Client->>Server: issueInvitesForRecipients(recipients)
    Server->>Server: Validate origin & email normalization
    Server->>Server: Check division fee > $0
    Server->>Database: Query existing active invites
    Database-->>Server: Return active invites
    Server->>Server: Filter new recipients (exclude already-active)
    
    loop For each new recipient
        Server->>TokenService: generateInviteClaimToken()
        TokenService-->>Server: {plaintext, hash, last4}
    end
    
    Server->>Database: BEGIN TRANSACTION
    Server->>Database: INSERT PENDING invites with claim token hash
    Database-->>Server: Return inserted rows
    Server->>Database: COMMIT
    
    Server-->>Client: {issued: [invites+plaintext], alreadyActive: [...]}
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~28 minutes

Possibly related PRs

Poem

🐰 A table sprouts with tokens grand,
Hashed secure across the land,
Invites bloom with bespoke care,
Phase Two's dance floats through the air!
CSV parsed with parse delight,

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% 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
Title check ✅ Passed The PR title clearly and concisely describes the main change: implementing Phase 2A of ADR-0011 with schema, token utilities, and helper functions for competition invites. It directly corresponds to the primary changes across database schema, token generation, and server-side logic in the changeset.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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

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

@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: 7

🧹 Nitpick comments (3)
apps/wodsmith-start/test/server/competition-invites/issue.test.ts (2)

11-13: Module-level mock prevents testing the paid path.

getRegistrationFee is mocked at module-scope to always return 0, so every call through issueInvitesForRecipients in this file will hit FreeCompetitionNotEligibleError before reaching recipient validation or insertion logic. This is fine for the two control-flow tests here, but any future "happy path" / alreadyActive / draft-detection tests will need vi.mocked(getRegistrationFee).mockResolvedValueOnce(...) overrides. Consider moving this to beforeEach or using per-test overrides to make the gating explicit.

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

In `@apps/wodsmith-start/test/server/competition-invites/issue.test.ts` around
lines 11 - 13, The module-level mock of getRegistrationFee forces a zero fee for
every test which prevents exercising paid-path logic in
issueInvitesForRecipients and causes FreeCompetitionNotEligibleError to always
be hit; move the vi.mock(...) or at least the
vi.mocked(getRegistrationFee).mockResolvedValue(...) setup into a beforeEach
block (or use per-test vi.mocked(...).mockResolvedValueOnce(...)) so individual
tests can override the returned fee when they need to exercise the paid path,
and update tests that rely on the zero-fee behavior to explicitly set
mockResolvedValueOnce(0) where appropriate.

1-111: Missing // @lat: references.

Per test coding guidelines, each test section should carry a // @lat: [[section-id]] comment tying it to the corresponding lat.md specification section. This file currently has none, despite lat.md/competition-invites.md defining matching sections (Issue helpers, Token helpers, Bespoke helpers). Consider tagging at least the top-level describe blocks:

🧷 Example tagging
 describe("normalizeInviteEmail", () => {
+  // `@lat`: [[competition-invites#Issue helpers]]
   it("lowercases", () => {

As per coding guidelines: "Add // @lat: [[section-id]] comments in test code to tie source code to test specifications in lat.md" and "Place exactly one @lat: code reference comment next to the relevant test, not at the top of the file".

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

In `@apps/wodsmith-start/test/server/competition-invites/issue.test.ts` around
lines 1 - 111, This file is missing the required "// `@lat`: [[section-id]]"
annotations; add a single `@lat` comment immediately next to each relevant
top-level describe block to map tests to the spec (e.g. add "// `@lat`: [[Token
helpers]]" next to describe("normalizeInviteEmail"), "// `@lat`: [[Bespoke
helpers]]" next to describe("assertRecipientOriginValid"), and "// `@lat`: [[Issue
helpers]]" next to describe("issueInvitesForRecipients") so each describe is
tied to its lat.md section as required by the guidelines.
apps/wodsmith-start/src/server/competition-invites/bespoke.ts (1)

204-207: Use a named object parameter here.

parseBespokePasteLine(line, rowNumber) has multiple parameters; switching to { line, rowNumber } keeps call sites self-documenting.

♻️ Proposed refactor
 export function parseBespokePasteLine(
-  line: string,
-  rowNumber: number,
+  params: {
+    line: string
+    rowNumber: number
+  },
 ): BulkRowInput | null {
+  const { line, rowNumber } = params
   const trimmed = line.trim()
-    const parsed = parseBespokePasteLine(line, rowNumber)
+    const parsed = parseBespokePasteLine({ line, rowNumber })

As per coding guidelines, “Use named object parameters for functions with more than one parameter.”

🤖 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/bespoke.ts` around lines
204 - 207, Change parseBespokePasteLine to accept a single named object
parameter (e.g., { line, rowNumber }) instead of two positional args so callers
are self-documenting; update the function signature in parseBespokePasteLine and
all call sites to pass an object with properties line and rowNumber, preserve
the return type BulkRowInput | null and ensure any internal references use the
new destructured names.
🤖 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/scripts/seed/seeders/20-competition-invites.ts`:
- Around line 360-366: The seed token labeled SEED_EXPIRED_TOKEN is actually
reused for the "bespoke sent" invite, so rename/reinterpret it and update uses:
replace SEED_EXPIRED_TOKEN with a clearly named SEED_BESPOKE_SENT_TOKEN (and
update ryanToken = tokenArtifacts(...) to use that constant), change the
console.log to print "bespoke sent" instead of "expired", and in the seeder row
that currently references the expired token hash use bespokeSentToken.hash and
bespokeSentToken.last4 (instead of referencing SEED_EXPIRED_TOKEN/ryanToken) so
the row points to the correct bespoke-sent token values.
- Around line 421-426: The isDraft classifier currently treats any row with a
null claimTokenHash as a draft, which misclassifies terminal states like
accepted_paid/expired/declined; update the logic in issue.ts (the isDraft check
around line 216) to require both a null claimTokenHash and status === "pending"
(i.e., isDraft: !existingRow.claimTokenHash && existingRow.status === "pending")
so only pending invites with no hash are considered drafts; locate the isDraft
calculation in issue.ts (reference symbol isDraft and
existingRow.claimTokenHash) and change the boolean expression accordingly.

In `@apps/wodsmith-start/src/server/competition-invites/bespoke.ts`:
- Around line 121-156: The insert currently builds a CompetitionInvite row with
an explicit id using createCompetitionInviteId and passes it to
db.insert(competitionInvitesTable).values(row); remove the id property from the
row payload so the database/schema CUID2 default generates the id, stop calling
createCompetitionInviteId for inserts, and instead use the project’s
generated-ID pattern: perform the insert without id and then fetch/return the
created row (or its id) using the post-insert retrieval logic used elsewhere;
apply the same change to the other occurrence that also constructs a row with id
(the block referenced in the comment).
- Around line 92-156: The read-before-insert in createBespokeInvite (and
similarly in createBespokeInvitesBulk) can race and surface DB unique-index
errors; wrap the select-check-and-insert in a single db.transaction() so the
existence check and db.insert(competitionInvitesTable).values(...) execute
atomically, and if you still want defensive handling also catch DB
unique-constraint errors (e.g. duplicate active-invite) and rethrow as
InviteIssueValidationError with the same message; update references to the
existing select query, the created row object, and the insert call inside that
transaction block.

In `@apps/wodsmith-start/src/server/competition-invites/issue.ts`:
- Around line 231-280: The rows being inserted into competitionInvitesTable
include a manually generated id via createCompetitionInviteId() (and the id
field on row), which violates the rule that IDs are auto-generated (CUID2);
remove id from each row pushed into rowsToInsert and let the DB default produce
the id, then adjust the post-insert flow to retrieve the created rows/IDs using
the project's generated-ID retrieval pattern (i.e., use
tx.insert(...).values(rowsToInsert) with the standard returning/mapping approach
your project uses) and populate the inserted array with the returned row and
plaintextToken instead of the pre-created id.
- Around line 173-176: The current mapping that builds normalized =
input.recipients.map(...) calls assertRecipientOriginValid and
normalizeInviteEmail but does not validate email syntax; update this flow to
validate each recipient email before normalization using the shared invite email
validator (or move the email regex into a shared invite-validation helper and
import it here), and throw or reject the request when an address fails that
validation; specifically, add a call to the shared validator (or inline the
shared pattern) inside the map (near assertRecipientOriginValid and
normalizeInviteEmail) so malformed addresses are rejected prior to conflict
lookup/token generation.
- Around line 316-362: The update lacks optimistic locking: after reading
competitionInvitesTable into existing and computing nextSendAttempt you must
ensure the UPDATE only succeeds if the row wasn't changed concurrently by adding
a condition that the stored updateCounter equals existing.updateCounter (i.e.,
include and(eq(competitionInvitesTable.updateCounter, existing.updateCounter))
in the WHERE of the update), and increment updateCounter in the SET so failed
updates can be retried; alternatively perform the read+write inside the same
db.transaction() as issueInvite() and use SELECT ... FOR UPDATE semantics if
supported to serialize generateInviteClaimToken and sendAttempt updates.

---

Nitpick comments:
In `@apps/wodsmith-start/src/server/competition-invites/bespoke.ts`:
- Around line 204-207: Change parseBespokePasteLine to accept a single named
object parameter (e.g., { line, rowNumber }) instead of two positional args so
callers are self-documenting; update the function signature in
parseBespokePasteLine and all call sites to pass an object with properties line
and rowNumber, preserve the return type BulkRowInput | null and ensure any
internal references use the new destructured names.

In `@apps/wodsmith-start/test/server/competition-invites/issue.test.ts`:
- Around line 11-13: The module-level mock of getRegistrationFee forces a zero
fee for every test which prevents exercising paid-path logic in
issueInvitesForRecipients and causes FreeCompetitionNotEligibleError to always
be hit; move the vi.mock(...) or at least the
vi.mocked(getRegistrationFee).mockResolvedValue(...) setup into a beforeEach
block (or use per-test vi.mocked(...).mockResolvedValueOnce(...)) so individual
tests can override the returned fee when they need to exercise the paid path,
and update tests that rely on the zero-fee behavior to explicitly set
mockResolvedValueOnce(0) where appropriate.
- Around line 1-111: This file is missing the required "// `@lat`: [[section-id]]"
annotations; add a single `@lat` comment immediately next to each relevant
top-level describe block to map tests to the spec (e.g. add "// `@lat`: [[Token
helpers]]" next to describe("normalizeInviteEmail"), "// `@lat`: [[Bespoke
helpers]]" next to describe("assertRecipientOriginValid"), and "// `@lat`: [[Issue
helpers]]" next to describe("issueInvitesForRecipients") so each describe is
tied to its lat.md section as required by the guidelines.
🪄 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: 54fb064e-260e-4ae1-a888-4aadd5153b8f

📥 Commits

Reviewing files that changed from the base of the PR and between 8d6fa1a and 9514d8c.

📒 Files selected for processing (11)
  • apps/wodsmith-start/scripts/seed/cleanup.ts
  • apps/wodsmith-start/scripts/seed/seeders/20-competition-invites.ts
  • apps/wodsmith-start/src/db/schemas/common.ts
  • apps/wodsmith-start/src/db/schemas/competition-invites.ts
  • apps/wodsmith-start/src/lib/competition-invites/tokens.ts
  • apps/wodsmith-start/src/server/competition-invites/bespoke.ts
  • apps/wodsmith-start/src/server/competition-invites/issue.ts
  • apps/wodsmith-start/test/lib/competition-invites/tokens.test.ts
  • apps/wodsmith-start/test/server/competition-invites/bespoke.test.ts
  • apps/wodsmith-start/test/server/competition-invites/issue.test.ts
  • lat.md/competition-invites.md

Comment on lines +360 to +366
const SEED_EXPIRED_TOKEN = "seed-invite-ryan-expired-men-rx-phase2"
const mikeToken = tokenArtifacts(SEED_PENDING_TOKEN)
const ryanToken = tokenArtifacts(SEED_EXPIRED_TOKEN)

console.log(
` seed tokens — pending: ${SEED_PENDING_TOKEN} / expired: ${SEED_EXPIRED_TOKEN}`,
)
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

Misleading seed token name + log message.

SEED_EXPIRED_TOKEN is never actually installed on the expired row (Alex's row at line 455 has claim_token_hash: null per the terminal-transition invariant). Its hash is reused by row #6 (cinv_seed_bespoke_sent_sponsor, line 556). The console log at line 365 labels the value as "expired", which will mislead anyone pasting it into a claim URL — it resolves to the sent bespoke invite, not an expired one.

🔧 Suggested fix
-  const SEED_PENDING_TOKEN = "seed-invite-mike-pending-men-rx-phase2"
-  const SEED_EXPIRED_TOKEN = "seed-invite-ryan-expired-men-rx-phase2"
-  const mikeToken = tokenArtifacts(SEED_PENDING_TOKEN)
-  const ryanToken = tokenArtifacts(SEED_EXPIRED_TOKEN)
-
-  console.log(
-    `    seed tokens — pending: ${SEED_PENDING_TOKEN} / expired: ${SEED_EXPIRED_TOKEN}`,
-  )
+  const SEED_PENDING_TOKEN = "seed-invite-mike-pending-men-rx-phase2"
+  const SEED_BESPOKE_SENT_TOKEN = "seed-invite-bespoke-sent-sponsor-phase2"
+  const mikeToken = tokenArtifacts(SEED_PENDING_TOKEN)
+  const bespokeSentToken = tokenArtifacts(SEED_BESPOKE_SENT_TOKEN)
+
+  console.log(
+    `    seed tokens — pending (mike): ${SEED_PENDING_TOKEN} / bespoke-sent (sponsor): ${SEED_BESPOKE_SENT_TOKEN}`,
+  )

Then update line 556-557 to reference bespokeSentToken.hash / bespokeSentToken.last4.

📝 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
const SEED_EXPIRED_TOKEN = "seed-invite-ryan-expired-men-rx-phase2"
const mikeToken = tokenArtifacts(SEED_PENDING_TOKEN)
const ryanToken = tokenArtifacts(SEED_EXPIRED_TOKEN)
console.log(
` seed tokens — pending: ${SEED_PENDING_TOKEN} / expired: ${SEED_EXPIRED_TOKEN}`,
)
const SEED_PENDING_TOKEN = "seed-invite-mike-pending-men-rx-phase2"
const SEED_BESPOKE_SENT_TOKEN = "seed-invite-bespoke-sent-sponsor-phase2"
const mikeToken = tokenArtifacts(SEED_PENDING_TOKEN)
const bespokeSentToken = tokenArtifacts(SEED_BESPOKE_SENT_TOKEN)
console.log(
` seed tokens — pending (mike): ${SEED_PENDING_TOKEN} / bespoke-sent (sponsor): ${SEED_BESPOKE_SENT_TOKEN}`,
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/wodsmith-start/scripts/seed/seeders/20-competition-invites.ts` around
lines 360 - 366, The seed token labeled SEED_EXPIRED_TOKEN is actually reused
for the "bespoke sent" invite, so rename/reinterpret it and update uses: replace
SEED_EXPIRED_TOKEN with a clearly named SEED_BESPOKE_SENT_TOKEN (and update
ryanToken = tokenArtifacts(...) to use that constant), change the console.log to
print "bespoke sent" instead of "expired", and in the seeder row that currently
references the expired token hash use bespokeSentToken.hash and
bespokeSentToken.last4 (instead of referencing SEED_EXPIRED_TOKEN/ryanToken) so
the row points to the correct bespoke-sent token values.

Comment on lines +421 to +426
claim_token_hash: null, // nulled on terminal transition
claim_token_last4: null,
expires_at: futureDatetime(14),
send_attempt: 1,
status: "accepted_paid",
paid_at: now(),
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify how isDraft is classified in issue.ts relative to claim_token_hash null + status
rg -nP --type=ts -C4 '\bisDraft\b|classifyAlreadyActive|claim_?[Tt]oken[Hh]ash\s*(?:===|is)\s*null' -g '!**/test/**'

Repository: wodsmith/thewodapp

Length of output: 1692


🏁 Script executed:

#!/bin/bash
# Check how accepted_paid status is handled in the codebase
rg -n 'accepted_paid' --type=ts -C3 -g '!**/test/**'

Repository: wodsmith/thewodapp

Length of output: 5247


🏁 Script executed:

#!/bin/bash
# Check reissueInvite and related lookup logic
rg -n 'reissueInvite|claim_token_hash' --type=ts -C5 -g '!**/test/**' | head -100

Repository: wodsmith/thewodapp

Length of output: 9606


Fix isDraft classifier to distinguish draft invites from other null-hash terminal states.

The seed data correctly nulls claim_token_hash for accepted_paid per the schema policy (line 182-184 of schemas), but the isDraft logic at issue.ts:216 checks only hash nullness. This incorrectly classifies accepted_paid, expired, and declined rows (all terminal with null hashes) as drafts. The check should verify status as well: isDraft: !existingRow.claimTokenHash && existingRow.status === "pending".

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

In `@apps/wodsmith-start/scripts/seed/seeders/20-competition-invites.ts` around
lines 421 - 426, The isDraft classifier currently treats any row with a null
claimTokenHash as a draft, which misclassifies terminal states like
accepted_paid/expired/declined; update the logic in issue.ts (the isDraft check
around line 216) to require both a null claimTokenHash and status === "pending"
(i.e., isDraft: !existingRow.claimTokenHash && existingRow.status === "pending")
so only pending invites with no hash are considered drafts; locate the isDraft
calculation in issue.ts (reference symbol isDraft and
existingRow.claimTokenHash) and change the boolean expression accordingly.

Comment on lines +92 to +156
const existing = await db
.select()
.from(competitionInvitesTable)
.where(
and(
eq(
competitionInvitesTable.championshipCompetitionId,
input.championshipCompetitionId,
),
eq(
competitionInvitesTable.championshipDivisionId,
input.championshipDivisionId,
),
eq(competitionInvitesTable.email, email),
eq(
competitionInvitesTable.activeMarker,
COMPETITION_INVITE_ACTIVE_MARKER,
),
),
)
.limit(1)
.then((rows) => rows[0] ?? null)

if (existing) {
throw new InviteIssueValidationError(
`${email} already has an active invite for this championship division`,
)
}

const id = createCompetitionInviteId()
const now = new Date()
const row: CompetitionInvite = {
id,
championshipCompetitionId: input.championshipCompetitionId,
roundId: "",
origin: COMPETITION_INVITE_ORIGIN.BESPOKE,
sourceId: null,
sourceCompetitionId: null,
sourcePlacement: null,
sourcePlacementLabel: null,
bespokeReason: input.bespokeReason ?? null,
championshipDivisionId: input.championshipDivisionId,
email,
userId: null,
inviteeFirstName: input.inviteeFirstName ?? null,
inviteeLastName: input.inviteeLastName ?? null,
claimTokenHash: null,
claimTokenLast4: null,
expiresAt: null,
sendAttempt: 0,
status: COMPETITION_INVITE_STATUS.PENDING,
paidAt: null,
declinedAt: null,
revokedAt: null,
revokedByUserId: null,
claimedRegistrationId: null,
emailDeliveryStatus: COMPETITION_INVITE_EMAIL_DELIVERY_STATUS.SKIPPED,
emailLastError: null,
activeMarker: COMPETITION_INVITE_ACTIVE_MARKER,
createdAt: now,
updatedAt: now,
updateCounter: 0,
}

await db.insert(competitionInvitesTable).values(row)
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify all bespoke invite inserts that are preceded by active-invite lookups.
# Expected: each read-before-insert path either runs in a transaction or catches duplicate-key conflicts.
rg -n -C4 'createBespokeInvite|createBespokeInvitesBulk|activeMarker|insert\(competitionInvitesTable\)' --type=ts

Repository: wodsmith/thewodapp

Length of output: 24611


Wrap read-before-insert in a transaction to prevent concurrent duplicate violations.

Both createBespokeInvite and createBespokeInvitesBulk query for existing active invites, then insert new rows without atomic protection. A concurrent request can pass the conflict check and trigger the unique-active-invite index, surfacing a raw database error instead of the intended validation result. Use db.transaction() to atomically check and insert, or catch and classify ERR_DUP_ENTRY violations.

Also applies to: 299-372

🤖 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/bespoke.ts` around lines
92 - 156, The read-before-insert in createBespokeInvite (and similarly in
createBespokeInvitesBulk) can race and surface DB unique-index errors; wrap the
select-check-and-insert in a single db.transaction() so the existence check and
db.insert(competitionInvitesTable).values(...) execute atomically, and if you
still want defensive handling also catch DB unique-constraint errors (e.g.
duplicate active-invite) and rethrow as InviteIssueValidationError with the same
message; update references to the existing select query, the created row object,
and the insert call inside that transaction block.

Comment on lines +121 to +156
const id = createCompetitionInviteId()
const now = new Date()
const row: CompetitionInvite = {
id,
championshipCompetitionId: input.championshipCompetitionId,
roundId: "",
origin: COMPETITION_INVITE_ORIGIN.BESPOKE,
sourceId: null,
sourceCompetitionId: null,
sourcePlacement: null,
sourcePlacementLabel: null,
bespokeReason: input.bespokeReason ?? null,
championshipDivisionId: input.championshipDivisionId,
email,
userId: null,
inviteeFirstName: input.inviteeFirstName ?? null,
inviteeLastName: input.inviteeLastName ?? null,
claimTokenHash: null,
claimTokenLast4: null,
expiresAt: null,
sendAttempt: 0,
status: COMPETITION_INVITE_STATUS.PENDING,
paidAt: null,
declinedAt: null,
revokedAt: null,
revokedByUserId: null,
claimedRegistrationId: null,
emailDeliveryStatus: COMPETITION_INVITE_EMAIL_DELIVERY_STATUS.SKIPPED,
emailLastError: null,
activeMarker: COMPETITION_INVITE_ACTIVE_MARKER,
createdAt: now,
updatedAt: now,
updateCounter: 0,
}

await db.insert(competitionInvitesTable).values(row)
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.

🛠️ Refactor suggestion | 🟠 Major

Do not pass generated IDs into inserts.

These insert payloads manually set id, but the schema already defines the CUID2 default. Let the DB/schema default generate the ID, then fetch/return the created row through the project’s generated-ID pattern.

As per coding guidelines, “Never pass id when inserting records; IDs are auto-generated with CUID2.”

Also applies to: 338-372

🤖 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/bespoke.ts` around lines
121 - 156, The insert currently builds a CompetitionInvite row with an explicit
id using createCompetitionInviteId and passes it to
db.insert(competitionInvitesTable).values(row); remove the id property from the
row payload so the database/schema CUID2 default generates the id, stop calling
createCompetitionInviteId for inserts, and instead use the project’s
generated-ID pattern: perform the insert without id and then fetch/return the
created row (or its id) using the post-insert retrieval logic used elsewhere;
apply the same change to the other occurrence that also constructs a row with id
(the block referenced in the comment).

Comment on lines +173 to +176
const normalized = input.recipients.map((r) => {
assertRecipientOriginValid(r)
return { ...r, email: normalizeInviteEmail(r.email) }
})
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

Validate recipient email syntax before issuing tokens.

This path normalizes but never rejects malformed addresses, so invalid emails can be inserted and queued. Reuse the same validation used by bespoke staging before conflict lookup/token generation.

🛡️ Proposed fix
   const normalized = input.recipients.map((r) => {
     assertRecipientOriginValid(r)
-    return { ...r, email: normalizeInviteEmail(r.email) }
+    const email = normalizeInviteEmail(r.email)
+    if (!EMAIL_PATTERN.test(email)) {
+      throw new InviteIssueValidationError(`Invalid email: ${r.email}`)
+    }
+    return { ...r, email }
   })

You’d need to move the email pattern into a shared invite-validation helper or define it in this module.

🤖 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/issue.ts` around lines 173
- 176, The current mapping that builds normalized = input.recipients.map(...)
calls assertRecipientOriginValid and normalizeInviteEmail but does not validate
email syntax; update this flow to validate each recipient email before
normalization using the shared invite email validator (or move the email regex
into a shared invite-validation helper and import it here), and throw or reject
the request when an address fails that validation; specifically, add a call to
the shared validator (or inline the shared pattern) inside the map (near
assertRecipientOriginValid and normalizeInviteEmail) so malformed addresses are
rejected prior to conflict lookup/token generation.

Comment on lines +231 to +280
const id = createCompetitionInviteId()
const now = new Date()
const row: CompetitionInvite = {
id,
championshipCompetitionId: input.championshipCompetitionId,
roundId: input.roundId ?? "",
origin: r.origin,
sourceId: r.origin === COMPETITION_INVITE_ORIGIN.SOURCE
? r.sourceId ?? null
: null,
sourceCompetitionId:
r.origin === COMPETITION_INVITE_ORIGIN.SOURCE
? r.sourceCompetitionId ?? null
: null,
sourcePlacement:
r.origin === COMPETITION_INVITE_ORIGIN.SOURCE
? r.sourcePlacement ?? null
: null,
sourcePlacementLabel: r.sourcePlacementLabel ?? null,
bespokeReason:
r.origin === COMPETITION_INVITE_ORIGIN.BESPOKE
? r.bespokeReason ?? null
: null,
championshipDivisionId: input.championshipDivisionId,
email: r.email,
userId: r.userId ?? null,
inviteeFirstName: r.inviteeFirstName ?? null,
inviteeLastName: r.inviteeLastName ?? null,
claimTokenHash: artifacts.hash,
claimTokenLast4: artifacts.last4,
expiresAt: input.rsvpDeadlineAt,
sendAttempt: 0,
status: COMPETITION_INVITE_STATUS.PENDING,
paidAt: null,
declinedAt: null,
revokedAt: null,
revokedByUserId: null,
claimedRegistrationId: null,
emailDeliveryStatus: COMPETITION_INVITE_EMAIL_DELIVERY_STATUS.QUEUED,
emailLastError: null,
activeMarker: COMPETITION_INVITE_ACTIVE_MARKER,
createdAt: now,
updatedAt: now,
updateCounter: 0,
}
rowsToInsert.push(row)
inserted.push({ invite: row, plaintextToken: artifacts.token })
}

await tx.insert(competitionInvitesTable).values(rowsToInsert)
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.

🛠️ Refactor suggestion | 🟠 Major

Do not pass generated IDs into invite inserts.

id is manually generated and inserted here even though the table default already generates invite IDs. Use the schema default and return the created row through the project’s generated-ID retrieval pattern.

As per coding guidelines, “Never pass id when inserting records; IDs are auto-generated with CUID2.”

🤖 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/issue.ts` around lines 231
- 280, The rows being inserted into competitionInvitesTable include a manually
generated id via createCompetitionInviteId() (and the id field on row), which
violates the rule that IDs are auto-generated (CUID2); remove id from each row
pushed into rowsToInsert and let the DB default produce the id, then adjust the
post-insert flow to retrieve the created rows/IDs using the project's
generated-ID retrieval pattern (i.e., use tx.insert(...).values(rowsToInsert)
with the standard returning/mapping approach your project uses) and populate the
inserted array with the returned row and plaintextToken instead of the
pre-created id.

Comment on lines +316 to +362
const existing = await db
.select()
.from(competitionInvitesTable)
.where(eq(competitionInvitesTable.id, input.inviteId))
.limit(1)
.then((rows) => rows[0] ?? null)

if (!existing) {
throw new InviteIssueValidationError(
`Invite ${input.inviteId} does not exist`,
)
}

if (
existing.status === COMPETITION_INVITE_STATUS.ACCEPTED_PAID ||
existing.status === COMPETITION_INVITE_STATUS.DECLINED ||
existing.status === COMPETITION_INVITE_STATUS.REVOKED
) {
throw new InviteIssueValidationError(
`Invite ${input.inviteId} cannot be reissued from status ${existing.status}`,
)
}

await assertDivisionIsPaid({
competitionId: existing.championshipCompetitionId,
divisionId: existing.championshipDivisionId,
})

const artifacts = await generateInviteClaimToken()
const nextSendAttempt = existing.sendAttempt + 1
const now = new Date()

await db
.update(competitionInvitesTable)
.set({
claimTokenHash: artifacts.hash,
claimTokenLast4: artifacts.last4,
expiresAt: input.newExpiresAt,
sendAttempt: nextSendAttempt,
status: COMPETITION_INVITE_STATUS.PENDING,
activeMarker: COMPETITION_INVITE_ACTIVE_MARKER,
emailDeliveryStatus: COMPETITION_INVITE_EMAIL_DELIVERY_STATUS.QUEUED,
emailLastError: null,
roundId: input.roundId ?? existing.roundId,
updatedAt: now,
})
.where(eq(competitionInvitesTable.id, input.inviteId))
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect reissue callers and update-counter usage to choose the safest optimistic-lock pattern.
# Expected: reissue should either serialize the read/modify/write or verify affected rows with updateCounter/status.
rg -n -C4 'reissueInvite|updateCounter|sendAttempt|claimTokenHash' --type=ts

Repository: wodsmith/thewodapp

Length of output: 50374


🏁 Script executed:

# Check full reissueInvite function including any transaction wrapper
sed -n '300,380p' apps/wodsmith-start/src/server/competition-invites/issue.ts

Repository: wodsmith/thewodapp

Length of output: 2622


🏁 Script executed:

# Search for updateCounter in WHERE clauses to see optimistic lock patterns
rg 'where.*updateCounter|eq.*updateCounter' --type ts -A2 -B2

Repository: wodsmith/thewodapp

Length of output: 558


🏁 Script executed:

# Check if db.transaction is used in this file and how
rg 'db\.transaction|\.transaction\(' apps/wodsmith-start/src/server/competition-invites/issue.ts -A5 -B2

Repository: wodsmith/thewodapp

Length of output: 233


🏁 Script executed:

# Find other SELECT + UPDATE patterns that use db.transaction with updateCounter
rg 'transaction.*async.*tx' apps/wodsmith-start/src/server/ -A20 | head -100

Repository: wodsmith/thewodapp

Length of output: 1935


🏁 Script executed:

# Search for WHERE conditions with updateCounter for optimistic locking
rg 'where.*and.*updateCounter|where.*eq.*updateCounter' --type ts -B3 -A3

Repository: wodsmith/thewodapp

Length of output: 44


🏁 Script executed:

# Check if reissueInvite has any calling context that serializes access
rg 'reissueInvite' apps/wodsmith-start/src --type ts -B5 -A5 | grep -E '(lock|mutex|serial|queue|await reissue)'

Repository: wodsmith/thewodapp

Length of output: 44


🏁 Script executed:

# Get more context on the transaction pattern already in the issue.ts file
rg -n 'db\.transaction' apps/wodsmith-start/src/server/competition-invites/issue.ts -B2 -A30 | head -80

Repository: wodsmith/thewodapp

Length of output: 1212


🏁 Script executed:

# Check Drizzle ORM documentation or usage for pessimistic locking patterns
rg 'FOR UPDATE|pessimistic|lock' apps/wodsmith-start/src --type ts -i

Repository: wodsmith/thewodapp

Length of output: 46323


🏁 Script executed:

# Verify the schema and how updateCounter auto-increment works
cat apps/wodsmith-start/src/db/schemas/common.ts

Repository: wodsmith/thewodapp

Length of output: 6150


Add optimistic locking to prevent concurrent token rotation race conditions.

The SELECT → increment sendAttempt → UPDATE sequence lacks concurrency protection. Two concurrent reissues both read the same sendAttempt, generate different tokens, and both successfully update—the first email receives an invalid token. Add and(eq(competitionInvitesTable.updateCounter, existing.updateCounter)) to the UPDATE WHERE clause to reject updates when the row changed since the SELECT, forcing the caller to retry.

Alternatively, wrap the operation in db.transaction() as used in issueInvite() at line 181, though MySQL row-level locking will require SELECT ... FOR UPDATE support in Drizzle to prevent phantom reads.

🤖 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/issue.ts` around lines 316
- 362, The update lacks optimistic locking: after reading
competitionInvitesTable into existing and computing nextSendAttempt you must
ensure the UPDATE only succeeds if the row wasn't changed concurrently by adding
a condition that the stored updateCounter equals existing.updateCounter (i.e.,
include and(eq(competitionInvitesTable.updateCounter, existing.updateCounter))
in the WHERE of the update), and increment updateCounter in the SET so failed
updates can be retried; alternatively perform the read+write inside the same
db.transaction() as issueInvite() and use SELECT ... FOR UPDATE semantics if
supported to serialize generateInviteClaimToken and sendAttempt updates.

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.

6 issues found across 11 files

Prompt for AI agents (unresolved issues)

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


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

<violation number="1" location="apps/wodsmith-start/src/server/competition-invites/bespoke.ts:156">
P2: The check-then-insert in `createBespokeInvite` is not atomic. A concurrent request can pass the active-invite check and trigger a raw `ER_DUP_ENTRY` from the unique index instead of the intended validation error. Wrap in `db.transaction()` (as done in `issueInvitesForRecipients`), or catch and classify the duplicate-key error.</violation>

<violation number="2" location="apps/wodsmith-start/src/server/competition-invites/bespoke.ts:212">
P2: The CSV parser is not quote-aware, so valid CSV rows with quoted commas are misparsed and can write incorrect invite metadata.</violation>
</file>

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

<violation number="1" location="apps/wodsmith-start/src/server/competition-invites/issue.ts:175">
P2: Email syntax is not validated before issuing tokens. `normalizeInviteEmail` only lowercases and trims — it doesn't reject malformed addresses. The bespoke path validates with `EMAIL_PATTERN` but this path skips it, so invalid emails can be inserted and later queued for delivery. Add an `EMAIL_PATTERN.test()` check after normalization.</violation>

<violation number="2" location="apps/wodsmith-start/src/server/competition-invites/issue.ts:216">
P1: `isDraft` misclassifies `accepted_paid` rows as drafts. The check only tests for a null `claimTokenHash`, but `accepted_paid` rows also null the hash while keeping `activeMarker = "active"`. Since the query above filters on `activeMarker = "active"`, an `accepted_paid` row will match and be reported as a draft. Add a status check: `isDraft: !existingRow.claimTokenHash && existingRow.status === "pending"`.</violation>

<violation number="3" location="apps/wodsmith-start/src/server/competition-invites/issue.ts:316">
P1: TOCTOU race: the `select` → status-check → `update` in `reissueInvite` is not wrapped in a transaction. If the invite is concurrently claimed, declined, revoked, or expired between the read and the write, the update will overwrite the terminal state back to `pending`. Wrap the read + guard + update in `db.transaction()` (as `issueInvitesForRecipients` already does).</violation>
</file>

<file name="apps/wodsmith-start/scripts/seed/seeders/20-competition-invites.ts">

<violation number="1" location="apps/wodsmith-start/scripts/seed/seeders/20-competition-invites.ts:360">
P3: `SEED_EXPIRED_TOKEN` is misleadingly named — its hash is actually installed on the bespoke-sent row (#6), not the expired row (#3, which correctly nulls its token on terminal transition). A dev pasting this token into a claim URL will hit the bespoke-sent invite, not an expired one. Rename to `SEED_BESPOKE_SENT_TOKEN` and update the log message to match.</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/competition-invites/issue.ts
Comment thread apps/wodsmith-start/src/server/competition-invites/issue.ts Outdated
Comment thread apps/wodsmith-start/src/server/competition-invites/bespoke.ts Outdated
Comment thread apps/wodsmith-start/src/server/competition-invites/bespoke.ts Outdated
Comment thread apps/wodsmith-start/src/server/competition-invites/issue.ts Outdated
}

const SEED_PENDING_TOKEN = "seed-invite-mike-pending-men-rx-phase2"
const SEED_EXPIRED_TOKEN = "seed-invite-ryan-expired-men-rx-phase2"
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

P3: SEED_EXPIRED_TOKEN is misleadingly named — its hash is actually installed on the bespoke-sent row (#6), not the expired row (#3, which correctly nulls its token on terminal transition). A dev pasting this token into a claim URL will hit the bespoke-sent invite, not an expired one. Rename to SEED_BESPOKE_SENT_TOKEN and update the log message to match.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/wodsmith-start/scripts/seed/seeders/20-competition-invites.ts, line 360:

<comment>`SEED_EXPIRED_TOKEN` is misleadingly named — its hash is actually installed on the bespoke-sent row (#6), not the expired row (#3, which correctly nulls its token on terminal transition). A dev pasting this token into a claim URL will hit the bespoke-sent invite, not an expired one. Rename to `SEED_BESPOKE_SENT_TOKEN` and update the log message to match.</comment>

<file context>
@@ -328,6 +336,242 @@ export async function seed(client: Connection): Promise<void> {
+	}
+
+	const SEED_PENDING_TOKEN = "seed-invite-mike-pending-men-rx-phase2"
+	const SEED_EXPIRED_TOKEN = "seed-invite-ryan-expired-men-rx-phase2"
+	const mikeToken = tokenArtifacts(SEED_PENDING_TOKEN)
+	const ryanToken = tokenArtifacts(SEED_EXPIRED_TOKEN)
</file context>
Fix with Cubic

…lpers

- Add competition_invites table (Phase 2 schema) with:
  - origin / status / activeMarker / emailDeliveryStatus enums
  - unique index (championshipCompetitionId, email, championshipDivisionId,
    activeMarker) — correctness pivot for "at most one active invite per
    (championship, division, email)" via MySQL's multiple-NULL semantics
  - unique index on claim_token_hash (terminal transitions NULL the hash)
  - shortened ULID columns to varchar(64) so the unique key fits MySQL's
    3072-byte limit
  - roundId varchar(255) NOT NULL default "" sentinel until Phase 3
- src/lib/competition-invites/tokens.ts — 32-byte URL-safe random token,
  SHA-256 hash, last4. Uses @oslojs/encoding. Pure functions, unit-tested.
- src/server/competition-invites/issue.ts — issueInvitesForRecipients,
  reissueInvite (activate-draft + extend-expired paths). Rejects $0 fee
  divisions. Normalizes emails at every write.
- src/server/competition-invites/bespoke.ts — createBespokeInvite +
  createBespokeInvitesBulk with CSV/TSV/one-email-per-line paste parser,
  500-row cap, structured duplicate/invalid reporting.
- Seed updates: 6 competition_invites rows (one per lifecycle state) plus
  cleanup ordering. Deterministic seed tokens logged on run.
- lat.md/competition-invites.md sections for invites schema, tokens,
  issue, and bespoke helpers; lat check passes.

50 unit tests passing. No UI or server fns yet — routes, auth
extensions, Stripe workflow, email queue, and organizer UI are sub-arcs
B/C/D of Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@theianjones theianjones force-pushed the feat/competition-invites-phase-2 branch from 9514d8c to 79ec211 Compare April 23, 2026 23:16
cubic + coderabbit review on PR #418. Five fixes, all in the bespoke /
issue layer:

- reissueInvite race: SELECT → check → UPDATE could be interleaved with a
  concurrent claim/decline/revoke. The terminal transition would silently
  get overwritten back to pending. Pin the UPDATE on `status IN (pending,
  expired)` and re-read the row when affectedRows = 0 so the caller sees
  a precise "concurrently transitioned to <X>" error.
- isDraft misclassification: `accepted_paid` rows null `claimTokenHash`
  while keeping `activeMarker = "active"`. The bare `!claimTokenHash`
  test would have surfaced them as drafts. Add the `status === pending`
  guard.
- createBespokeInvite race: existence check + insert weren't atomic.
  Wrap in db.transaction() so the active-marker unique index stops
  leaking ER_DUP_ENTRY through to callers.
- Email syntax: issueInvitesForRecipients normalized but never validated.
  Hoist the EMAIL_PATTERN constant out of bespoke.ts to issue.ts and
  apply it to both paths.
- CSV parser: was a naive `split(",")` that mangled `"Doe, Jane",...`.
  Replace with a quote-aware splitter that also decodes `""` → `"`. TSV
  path stays a plain split — tabs can't appear inside a TSV field.

Tests cover both new edge cases (CSV quoting + email rejection).
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/server/competition-invites/issue.ts">

<violation number="1" location="apps/wodsmith-start/src/server/competition-invites/issue.ts:394">
P1: The affected-row check reads the Drizzle MySQL update result with the wrong shape, so concurrent-transition failures can be missed.</violation>
</file>

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

Comment on lines +394 to +395
const affected = (updateResult as unknown as { affectedRows?: number })
.affectedRows
Copy link
Copy Markdown
Contributor

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

Choose a reason for hiding this comment

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

P1: The affected-row check reads the Drizzle MySQL update result with the wrong shape, so concurrent-transition failures can be missed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/wodsmith-start/src/server/competition-invites/issue.ts, line 394:

<comment>The affected-row check reads the Drizzle MySQL update result with the wrong shape, so concurrent-transition failures can be missed.</comment>

<file context>
@@ -359,7 +381,29 @@ export async function reissueInvite(
+      ),
+    )
+
+  const affected = (updateResult as unknown as { affectedRows?: number })
+    .affectedRows
+  if (affected === 0) {
</file context>
Suggested change
const affected = (updateResult as unknown as { affectedRows?: number })
.affectedRows
const affected = updateResult[0]?.affectedRows ?? 0
Fix with Cubic

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