Skip to content

feat(linking): externalId on Invite + Employee for partner-system sync#14

Merged
tx-joshg merged 1 commit into
mainfrom
feat/external-id-linking
May 28, 2026
Merged

feat(linking): externalId on Invite + Employee for partner-system sync#14
tx-joshg merged 1 commit into
mainfrom
feat/external-id-linking

Conversation

@tx-joshg

Copy link
Copy Markdown
Owner

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

  • Schema: `Invite.externalId` (non-unique, indexed) + `Employee.externalId` (sparse-unique). New migration; no data migration needed.
  • API: POST `/api/invites` accepts `externalId`. POST `/api/submissions` reuses an existing Employee with matching externalId if present (renewals!) or stamps it on the new one. GET endpoints lift `externalId` to top of each row.
  • Admin UI: External ID column on Employees / Submissions / Invites tables. Hidden when no visible row has one — standalone installs keep their original layout untouched.

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

  • Typecheck + build clean
  • After merge: deploy → migration runs → wipe production data → end-to-end flow from staff-portal proves PATH 0 linking works

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.
@tx-joshg tx-joshg merged commit d140bda into main May 28, 2026
5 checks passed
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.
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