feat(invites): ADR-0011 Phase 2A — schema, tokens, issue + bespoke helpers#418
feat(invites): ADR-0011 Phase 2A — schema, tokens, issue + bespoke helpers#418theianjones wants to merge 2 commits intomainfrom
Conversation
WalkthroughThis PR implements Phase 2 competition invite functionality, adding a new Changes
Sequence DiagramsequenceDiagram
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: [...]}
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~28 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.
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.
getRegistrationFeeis mocked at module-scope to always return0, so every call throughissueInvitesForRecipientsin this file will hitFreeCompetitionNotEligibleErrorbefore 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 needvi.mocked(getRegistrationFee).mockResolvedValueOnce(...)overrides. Consider moving this tobeforeEachor 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 correspondinglat.mdspecification section. This file currently has none, despitelat.md/competition-invites.mddefining matching sections (Issue helpers,Token helpers,Bespoke helpers). Consider tagging at least the top-leveldescribeblocks:🧷 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
📒 Files selected for processing (11)
apps/wodsmith-start/scripts/seed/cleanup.tsapps/wodsmith-start/scripts/seed/seeders/20-competition-invites.tsapps/wodsmith-start/src/db/schemas/common.tsapps/wodsmith-start/src/db/schemas/competition-invites.tsapps/wodsmith-start/src/lib/competition-invites/tokens.tsapps/wodsmith-start/src/server/competition-invites/bespoke.tsapps/wodsmith-start/src/server/competition-invites/issue.tsapps/wodsmith-start/test/lib/competition-invites/tokens.test.tsapps/wodsmith-start/test/server/competition-invites/bespoke.test.tsapps/wodsmith-start/test/server/competition-invites/issue.test.tslat.md/competition-invites.md
| 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}`, | ||
| ) |
There was a problem hiding this comment.
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.
| 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.
| 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(), |
There was a problem hiding this comment.
🧩 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 -100Repository: 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.
| 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) |
There was a problem hiding this comment.
🧩 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=tsRepository: 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.
| 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) |
There was a problem hiding this comment.
🛠️ 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).
| const normalized = input.recipients.map((r) => { | ||
| assertRecipientOriginValid(r) | ||
| return { ...r, email: normalizeInviteEmail(r.email) } | ||
| }) |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
🛠️ 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.
| 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)) |
There was a problem hiding this comment.
🧩 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=tsRepository: 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.tsRepository: 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 -B2Repository: 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 -B2Repository: 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 -100Repository: 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 -A3Repository: 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 -80Repository: 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 -iRepository: 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.tsRepository: 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.
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| const SEED_PENDING_TOKEN = "seed-invite-mike-pending-men-rx-phase2" | ||
| const SEED_EXPIRED_TOKEN = "seed-invite-ryan-expired-men-rx-phase2" |
There was a problem hiding this comment.
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>
…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>
9514d8c to
79ec211
Compare
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).
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/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.
| const affected = (updateResult as unknown as { affectedRows?: number }) | ||
| .affectedRows |
There was a problem hiding this comment.
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>
| const affected = (updateResult as unknown as { affectedRows?: number }) | |
| .affectedRows | |
| const affected = updateResult[0]?.affectedRows ?? 0 |
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_invitestable 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 nullactiveMarkerso they accumulate without collision.UNIQUE claim_token_hash— tokens are globally unique while live; NULL on terminal transitions so historical rows coexist.roundId,sourceId,origin,status,email,championshipCompetitionId,userId.championshipCompetitionIdandchampionshipDivisionIdarevarchar(64)instead ofvarchar(255)so the unique key fits MySQL's 3072-byte limit (ULIDs are ~30 chars, 64 is plenty).roundIdisvarchar(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.ts—issueInvitesForRecipients(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.ts—createBespokeInvite(single-add) +createBespokeInvitesBulk(CSV/TSV/one-email-per-line paste, 500-row cap, structured duplicate/invalid reporting).20-competition-invites.tsnow inserts 6competition_invitesrows (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.mdsections 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)
/compete/$slug/claim/$tokenroutes + sign-in/sign-up?claim=extensioninviteTokenparam + Stripe workflow invite-status step + email queue consumer + invite server fnsTest plan
pnpm test -- test/lib/competition-invites test/server/competition-invites— 50 testspnpm type-checkcleanDATABASE_URL='mysql://…/wodsmith-db' pnpm db:push— table + indexes created, no driftpnpm db:seed— 6 invite rows land incompetition_invites, one per lifecycle state; deterministic tokens printedER_DUP_ENTRYactive_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
competition_inviteswith one‑active‑invite uniqueness viaactiveMarker, uniqueclaimTokenHash,roundIdempty‑string sentinel, shortened ULID FKs (varchar(64)), delivery/status fields, andcreateCompetitionInviteId.src/lib/competition-invites/tokens.ts(uses@oslojs/encoding); pure and unit‑tested.issueInvitesForRecipients(transactional inserts; returns plaintext tokens) andreissueInvite(rotate token, bump expiry/sendAttempt, restore active; activate draft bespoke or extend expired). Normalizes emails; rejects $0‑fee divisions.createBespokeInviteandcreateBespokeInvitesBulk(CSV/TSV/one‑per‑line, header‑aware, auto delimiter, 500‑row cap) with structured invalid/duplicate reporting; draft rows are “active but no token”.competition_invitesrows covering lifecycle states with deterministic tokens; cleanup order updated; docs inlat.md; unit tests for tokens/issue/bespoke.Bug Fixes
status IN (pending, expired); re‑read on 0 affected rows to surface precise concurrent transition.accepted_paid(hash nulled) as draft by requiringstatus === pending.ER_DUP_ENTRYleaks.EMAIL_PATTERNapplied to issue and bespoke paths (was normalize‑only).""→"decoding; TSV stays simple split. Tests cover new edge cases.Written for commit 90c4381. Summary will update on new commits.
mainSummary by CodeRabbit
Release Notes
New Features
Tests