Skip to content

Atomic invite claim — prevents duplicate Submissions#15

Merged
tx-joshg merged 1 commit into
mainfrom
fix/atomic-invite-consume
May 28, 2026
Merged

Atomic invite claim — prevents duplicate Submissions#15
tx-joshg merged 1 commit into
mainfrom
fix/atomic-invite-consume

Conversation

@tx-joshg

Copy link
Copy Markdown
Owner

What

Replace the racy read-then-conditional-update on Invite.usedAt with an atomic updateMany claim. 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 either update({ usedAt }) committed, so both saw usedAt=null, both passed the guard, both inserted a Submission. PR #14's externalId lookup then funneled both onto the same Employee — which is why both rows share NTX-2001 but you still see two of them.

How

const claim = await prisma.invite.updateMany({
  where: { token, usedAt: null, expiresAt: { gt: now } },
  data: { usedAt: now },
});

if (claim.count === 0) {
  // Already used / expired / missing — look up the original
  // Submission and return its id with 200 (idempotent),
  // OR 400 / 410 / 409 as appropriate.
}

Postgres serializes writes against the same row, so exactly one concurrent caller's UPDATE matches. Everyone else gets count=0 and either:

State Response
Invite missing 400 { error: "Invalid invite token" }
Invite expired 410 { error: "Invite has expired" }
Already used, prior submission found 200 { id, success: true, idempotent: true }
Already used, no submission 409 { error: "Invite already used" }

Client compatibility

Staff-portal's lib/i9-submission.ts reads json.id and checks res.ok, so it handles 200 and 201 identically. 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

  • Deploy ships clean
  • Alexa wiped in both DBs
  • Alexa re-runs wizard → exactly one Submission row in open-i9
  • Stretch: manual double-POST to /api/submissions with same inviteToken → first returns 201, second returns 200 with same submission id

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.
@tx-joshg tx-joshg merged commit 16df68f into main May 28, 2026
5 checks passed
@tx-joshg tx-joshg deleted the fix/atomic-invite-consume branch May 28, 2026 17:55
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