feat(linking): externalId on Invite + Employee for partner-system sync#14
Merged
Conversation
Sister services (specifically NyTex staff-portal) need a deterministic
way to match open-i9 submissions back to their own staff records. The
existing path matches via invite.token → submission.employeeId, which
breaks when:
- the staff was never sent through the invite flow (admin created on
open-i9 directly), or
- the partner's local invite token didn't survive (data wipe, lost
Form record, etc.).
Staff-portal worked around it with name-matching in PR #90 — fine at
~40 employees, fragile beyond. Better: thread the partner's stable
identifier (their staffNumber like "NTX-2053") through both ends of
the pipe so sync joins on a real ID.
Schema:
- Invite.externalId String? — non-unique; admins may re-mint after
expiry, both invites converge on the same Employee.
- Employee.externalId String? @unique — sparse-unique; once stamped,
one Employee per externalId. Standalone open-i9 installs keep
working unchanged (all nulls).
API:
- POST /api/invites accepts optional externalId in body.
- GET /api/invites surfaces externalId.
- POST /api/submissions: when the inviteToken's invite has externalId,
reuses an existing Employee with that externalId (renewals, etc.)
instead of creating a duplicate; otherwise creates a fresh Employee
with externalId stamped on.
- GET /api/submissions (list + single) join through Employee and lift
externalId to the top of each row so callers don't dig.
Admin UI:
- External ID column added to Employees, Submissions, and Invites
tables. Conditionally hidden when no visible row carries an
externalId — standalone installs see the original layout untouched.
- Monospace styling so NTX-#### scans next to the name.
Migration is a pure schema add. Recommend wiping prod data first if the
partner is doing a fresh start; otherwise legacy rows just carry null
and partner-side fallbacks (name / token) keep them working.
4 tasks
tx-joshg
added a commit
that referenced
this pull request
May 28, 2026
#15) 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.
Why
Partner services (specifically NyTex staff-portal) need a deterministic way to match open-i9 submissions back to their own staff records. The existing path — `invite.token` → `submission.employeeId` — breaks when the staff was never sent through the invite flow, or when the partner's local invite token didn't survive a wipe. NyTex worked around it with name-matching; fine at ~40 employees, fragile beyond.
This PR adds an `externalId` cross-system anchor — partner passes its stable ID (e.g. `NTX-2053`) at invite time, open-i9 stores it on Employee, sync joins on a real ID.
What
Standalone backward compat
All fields are nullable. If a partner doesn't pass externalId, behavior is identical to before. The admin UI columns hide themselves.
Test plan