Atomic invite claim — prevents duplicate Submissions#15
Merged
Conversation
Alexa Roberson's I-9 landed as TWO Pending Review rows in the open-i9
admin, both linked to externalId NTX-2001. The cause:
The invite-consume path was read-then-conditional-update:
1. findUnique({ token }) → look at usedAt
2. if (!usedAt) { update({ id }, { usedAt: now }); create Submission }
Step 1 and step 2 aren't atomic. Two concurrent POSTs (double-click,
mobile network retry, browser back+resubmit) both saw usedAt=null,
both passed the guard, both created Submissions. The externalId-based
Employee lookup added in PR #14 then funneled them onto the SAME
Employee row, which is why both submissions show the same NTX-2001 —
but you still get two Submission rows for one filled form.
Fix: collapse the read+guard+update into a single updateMany whose
WHERE clause is the entire guard:
updateMany({
where: { token, usedAt: null, expiresAt: { gt: now } },
data: { usedAt: now },
})
Postgres serializes writes against the same row, so exactly one
concurrent caller's UPDATE matches and returns count=1. Everyone
else gets count=0, and we short-circuit:
- Invite missing → 400
- Invite expired → 410
- Already-used + has → 200 with the original Submission's id
submission (idempotent — the wizard's "we're done"
flow doesn't see a 4xx for work the user
already finished, and no new Submission
row gets inserted)
- Already-used + no → 409 (rare: prior Submission insert failed
submission after the claim landed)
Staff-portal client (lib/i9-submission.ts) reads json.id and
checks res.ok, so it handles 200 and 201 identically — no client
change needed.
Doesn't repair the existing dup row in prod; that's a separate cleanup.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Replace the racy read-then-conditional-update on
Invite.usedAtwith an atomicupdateManyclaim. Eliminates duplicate Submission rows when a wizard submits twice (double-click, mobile retry, browser back+resubmit).Why
Alexa Roberson's I-9 landed as two Pending Review rows in the open-i9 admin Submissions table, both linked to the same
externalId=NTX-2001. Investigation showed both POSTs hit before eitherupdate({ usedAt })committed, so both sawusedAt=null, both passed the guard, both inserted a Submission. PR #14'sexternalIdlookup then funneled both onto the same Employee — which is why both rows share NTX-2001 but you still see two of them.How
Postgres serializes writes against the same row, so exactly one concurrent caller's UPDATE matches. Everyone else gets
count=0and either:400 { error: "Invalid invite token" }410 { error: "Invite has expired" }200 { id, success: true, idempotent: true }409 { error: "Invite already used" }Client compatibility
Staff-portal's
lib/i9-submission.tsreadsjson.idand checksres.ok, so it handles200and201identically. No client change needed.What this doesn't do
Doesn't repair the existing Alexa duplicate row in prod — that's a separate cleanup after merge. Plan is to wipe her Employee + both Submissions + Invite, reset her staff-portal Form, and have her re-submit on the fixed code path.
Test plan
/api/submissionswith sameinviteToken→ first returns 201, second returns 200 with same submission id