fix(organization): judge the exit/logout restriction location from what is stored

Merged
EV
14d ago
+924-115

Summary

Follow-up to #11578 for #9873. When an organization is updated, the agent exit/logout restriction check now judges the location from what is stored, as the screen capture check does.

Before, the "after this update" location took its country from a top-level country in the request. The organization does not store that field (its country lives on its contact), so a value that is never saved could change the decision.

Now one helper, organizationLocations(), builds the locations both checks use:

  • Stored: the region code, time zone and contact country as they are stored now.
  • After the write: the region code and time zone the write sets (otherwise the stored ones), plus the country of the contact the organization is linked to after the save. If the save clears the link, there is no contact country. The exit/logout rule is judged against this location only.
  • Requested: the location values the request names: the region code, the time zone, the country of a contact it carries, and the country of each contact it names by id. Only the screen capture check uses these. It asks for the acknowledgement when the organization is in the EEA/UK before or after the write, or when any of these values is in the EEA/UK.

Two more steps depend on the location:

  • Unreadable contact: if the save would link a contact that cannot be read, the location after the write is unknown. The update is refused with "not found" before anything is judged, recorded or saved.
  • Lifting employee restrictions: the step that runs when an organization moves into the EEA/UK reads the saved organization and its contact after the save. It lifts restrictions only when that row is in the EEA/UK.

What the save stores for the contact

organization-location.persistence.spec.ts pins this down. It runs the CRUD upsert on in-memory SQLite under both ORMs, mapped like the organization (the contact relation does not cascade, and contactId is a TypeORM relation id). For each request, it checks that savedContactLink() reads the same link as the stored row.

Request (organization linked to contact A)TypeORMMikroORM
contact: { id: B }B, with B's own countryB, with B's own country
contact: 'B' (an id alone)link clearedB
contact: nulllink clearedlink cleared
contactId: BBnot stored (the column is not mapped)
contactId: nulllink clearednot stored
contactId: B with contact: nullBlink cleared
contact: { country } without an idA kept, country not storedlink cleared, country not stored
top-level countrynot storednot stored

The "after the write" location follows this table, with one exception: for a contact without an id, MikroORM clears the link, but the check counts A as kept. That keeps A's country in the check, the stricter of the two readings.

Decisions

  1. The exit/logout restriction is judged against the location after the write only. That location is built from the stored values plus what the save stores. A top-level country is ignored. A contact sent with the request counts only where the save stores it (see the table).
    • Moving an organization out of the EEA/UK and restricting exit/logout in the same save is allowed when the stored result is outside the EEA/UK. This includes unlinking a UK contact.
    • It is still refused when the stored result stays inside, for example when the time zone moves but the UK contact stays.
  2. The screen capture check also ignores a top-level country, because the organization does not store it. Otherwise it asks as before: when the organization is in the EEA/UK before or after the write, or when any location value the request names is in the EEA/UK.

Behaviour

SituationBeforeAfter
UK/EEA organization (by its stored contact), request restricts exit with an acknowledgement and a top-level country outside the EEA/UKallowedrefused, as without the country
Organization outside the EEA/UK with a restriction in force, request carries only a top-level country inside the EEA/UKrefused as "moving into the EEA/UK"saved
Organization outside the EEA/UK, request carries only a top-level country inside the EEA/UKtreated as a move into the EEA/UK: employee restrictions liftedno move, nothing lifted
Organization outside the EEA/UK, request carries contact: { country: 'DE' }treated as a move into the EEA/UKno move (the save does not store that country)
Request links a UK/EEA contact by contact.id while restricting exitjudged by the old contactrefused
Request moves the organization out of the EEA/UK (time zone, or unlinking a UK contact) and restricts exit in the same saveallowed with the acknowledgement when the time zone moved; refused when the UK contact was unlinkedallowed when the stored result is outside the EEA/UK
Request links a contact that cannot be readjudged by the old contactrefused: contact not found
Screen capture acknowledgement, request with a top-level country inside the EEA/UKaskedjudged without it

Changes

  • packages/core/src/lib/organization/organization-location.ts (new):
    • currentContactId and linkedContactCountry, moved from screenshot-capture-acknowledgement.ts, with the same behaviour
    • savedContactLink(): the contact linked after the save, per ORM (the table above)
    • storedOrganizationLocation()
    • organizationLocations(): takes the ORM type, reads each contact the request names by id once, and reports a linked contact that cannot be read
  • organization.update.handler.ts:
    • refuses an update that links a contact that cannot be read
    • the exit/logout check uses the location after the write
    • the screen capture check uses the stored, after and requested locations
    • the lift step confirms the move on the saved row, with one extra read only when the write may have moved the organization
  • organization.create.handler.ts: import path only.
  • Specs:
    • organization-location.spec.ts (new; includes the moved helper tests)
    • organization-location.persistence.spec.ts (new; real database, both ORMs)
    • organization.update.handler.spec.ts

Tests

All run locally in the worktree (Node 26.10, Windows).

  • NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js -c packages/core/jest.config.ts --rootDir packages/core "<organization, employee, invite and GraphQL suites>": 21 suites, 447 tests passed.
  • Update handler: each case runs with no country, an unstored US and an unstored DE. The decision is the same in all three:
    • a UK organization cannot restrict exit;
    • an organization elsewhere can, with the acknowledgement recorded;
    • a restricted organization elsewhere is saved and nothing is lifted.
  • Update handler, other cases:
    • moving out of the EEA/UK and restricting in one save: allowed when the stored result is outside it (time zone moved, or UK contact unlinked), refused when the UK contact stays;
    • a UK contact linked by contact.id refuses a restriction, and lifts employee restrictions once the saved row links it;
    • a carried DE contact moves nothing;
    • linking a contact that cannot be read is refused, and nothing is recorded or saved.
  • Location helper:
    • after, stored and requested values;
    • contactId counted only under TypeORM;
    • an id alone counted per ORM;
    • a cleared link gives no country;
    • unread contacts are reported;
    • cleared time zones;
    • how many contacts are read.
  • Persistence spec: the table above, under TypeORM and MikroORM.
  • Against the previous handler: the first commit's specs had 7 failures, as expected; the last commit's two new handler cases fail too.
  • tsc --noEmit on packages/core/tsconfig.lib.json and packages/core/tsconfig.spec.json: no errors.
  • Prettier and ESLint on the touched files: no new findings (the remaining ones are on lines this PR does not change).

Follow-up (not in this PR)

  • The organization location page in the web app sends the address as a nested contact in the organization update. The organization save does not store a nested contact's fields under either ORM: TypeORM keeps the current link, and MikroORM clears it. It is worth checking whether the page's address changes are saved, and through which path.

Refs #9873

šŸ¤– Generated with Claude Code