feat(organizations): implement IT contacts - #126
danielloader wants to merge 2 commits into
Conversation
The five IT contact endpoints were the whole of the Organizations shortfall, leaving the feature at 5/6 read and 6/10 write. Organizations is now complete. GET /organizations/:organization_id/it_contacts POST /organizations/:organization_id/it_contacts DELETE /organizations/:organization_id/it_contacts/:contact_id POST /organizations/:organization_id/it_contacts/:contact_id/invite POST /organizations/:organization_id/it_contacts/:contact_id/revoke Two production rules come from the spec's prose rather than its schemas, and both are enforced: an address may be an IT contact of a given organization only once, and an organization holds at most one active Admin Portal invitation. Re-inviting the contact who already holds it refreshes their link, since the count stays at one, and revoking clears the organization's invitation through any contact — no route reports which one holds it, so addressing a contact that does not would otherwise answer 204 and leave the caller wedged behind the next 409. Nothing is emailed. The invitation state, including the setup link production would have sent, is stored but never serialized: `ItContact` documents no such fields and both routes answer 204. It is what makes the one-invitation rule enforceable. `403` and `503` are not implemented. The store is not environment-scoped, so the forbidden case cannot arise, and 503 is a production infrastructure state; both remain injectable through the error hooks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Invitation state is stamped with updateSilent, matching the emulator's other non-serialized stamps: none of it reaches the wire, so `updated_at` must not move and claim the resource changed. Three assertions could not fail, found by mutating the implementation against them: the `updated_at` no-op check compared two live timestamps taken inside the same millisecond, a revoke that cleared only `invited_at` passed, and the `.trim()` was unpinned because the blank-address case 422s on shape either way. Each now fails when the behaviour it names is broken. 404 coverage reached DELETE only; invite, revoke and create against an unknown organization are covered too. Also: the catalog entry was inserted between a comment and the entry that comment documents, the duplicate-email comment claimed an index half that is never used (and cannot be, since uniqueness is case-insensitive while the address is stored as spelled), route params were snake_case where every sibling is camelCase, and five route-header comments restated the line below them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
| const contacts = ws.itContacts | ||
| .findBy('organization_id', organizationId) | ||
| .sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id)); | ||
| // The spec gives this route no pagination parameters, so the whole set is one page and | ||
| // both cursors are null — the envelope is still `list`, which the SDKs deserialize. | ||
| return c.json({ | ||
| object: 'list', | ||
| data: contacts.map(formatItContact), | ||
| list_metadata: { before: null, after: null }, |
There was a problem hiding this comment.
IT contact listing ignores pagination When a caller requests a page with
limit or a cursor, this route ignores those parameters, returns every contact, and sets both cursors to null. The caller cannot fetch subsequent pages, and larger organizations receive the full list in one response. Use the existing cursor-pagination path instead of constructing a single-page response.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/workos/routes/it-contacts.ts
Line: 33-41
Comment:
**IT contact listing ignores pagination** When a caller requests a page with `limit` or a cursor, this route ignores those parameters, returns every contact, and sets both cursors to null. The caller cannot fetch subsequent pages, and larger organizations receive the full list in one response. Use the existing cursor-pagination path instead of constructing a single-page response.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
IT contacts were the whole of the Organizations shortfall — the feature sat at⚠️ 5/6 read and ⚠️ 6/10 write, and all five missing endpoints were these. Organizations is now ✅ 6/6 read, ✅ 10/10 write. Repo coverage 184 → 189 of 261 (72.4%).
GET/organizations/{organization_id}/it_contactsPOST/organizations/{organization_id}/it_contactsDELETE/organizations/{organization_id}/it_contacts/{contact_id}POST/organizations/{organization_id}/it_contacts/{contact_id}/invitePOST/organizations/{organization_id}/it_contacts/{contact_id}/revokeThe behaviour comes from the spec's prose, not just its schemas
The
ItContactschema is nearly trivial, but the operation descriptions carry the real rules, and both are enforced:409 it_contact_already_exists, scoped to the organization and matched case-insensitively, so the same address may serve several organizations.409 it_contact_invitation_already_active. Re-inviting the contact who already holds it refreshes their link instead, because the count stays at one and that is the resend a caller reaches for when the first email goes astray.Both 409
codevalues are the ones the spec pins withconst, not invented names.Revoke clears the organization's invitation, through whichever contact you address
The spec's description is "Revoke the organization's active Admin Portal invitation" — the organization's, not the contact's. That distinction matters here because no endpoint reports which contact holds the invitation. A per-contact revoke would answer
204to a caller who addressed the wrong one, change nothing, and leave them wedged behind a409on their next invite, with no way to discover whom to ask about. So revoking through any contact clears it.Invitation state is stored but never serialized
ItContacthas exactly five properties, all required, and invite and revoke both answer204— so there is nowhere in the spec for an invitation to surface. It is still stored (invited_at,invite_intents, and the setup link production would have emailed), because the one-invitation rule is unenforceable without it. Same shape aslogin_urlon connect applications.Stamping it uses
updateSilent, like the emulator's other non-serialized stamps (last_used_at,last_sign_in_at): none of it reaches the wire, soupdated_atmust not move and claim the resource changed.Nothing is emailed — the emulator has no mail outbox to wire into.
Not implemented, deliberately
403and503are documented on all five operations and neither is implemented. The emulator's store is not environment-scoped, so the forbidden case cannot arise — no WorkOS route in the repo callsforbidden()— and503is a production infrastructure state. Both remain injectable per-route through the existing error hooks. This is recorded in the README section and the Organizations note inSUPPORTED.md.One pre-existing, emulator-wide divergence is untouched: a missing or unparseable body answers
400from the sharedparseJsonBody, where these operations document422.Conformance and tests
it_contactjoins the generated resource catalog andItContactListthe envelope catalog, so the wire shape is checked against the spec rather than by hand — the field-set assertion is what keeps the invitation state off the wire.The tests were mutation-tested, and three of them turned out unfalsifiable before this was raised:
updated_atno-op check compared two live timestamps taken inside the same millisecond — it passed with the guard deleted entirelyinvited_at, leavinginvite_intentsand the setup link stale, passed the whole suite — and that clearing is exactly what the README promises.trim()was unpinned, because the blank-address case422s on shape whether or not the trim runsEach now fails when the behaviour it names is broken; I re-ran the mutations to confirm. 404 coverage reached
DELETEonly, so invite, revoke and create-against-an-unknown-organization are covered too.bun test: 1364 pass, 0 fail.typecheck,lint,fmt:checkclean.gen:shapesandgen:supportedre-run byte-identical, so there is no codegen drift.🤖 Generated with Claude Code