[Employer Sync 4.4] Map → Org::OutletAssignment
TL;DR: LOCATION → one outlet (via location_id); AREA → many (via org_outlets.area_user_id); HQ / super-HQ → none. Guard the empty array (fixes A6 / A7 crashes) and revoke stale assignments on re-sync via revoked_at — never hard-delete (fixes D3, follows the soft-delete convention).
Context
Org::OutletAssignment links an Org::Membership to one or more Org::Outlets. LOCATION managers map to exactly one outlet (their users.location_id). AREA managers map to one or more outlets (the outlets whose area_user_id equals the jodgig AREA user's id). HQ and SUPER_HQ users do not get outlet assignments — they are company-level.
Problem
PR #1634 has two real bugs here:
- A6 —
Org::OutletAssignment.upsert_allis called without first checking that the input array is non-empty. A batch that contains only HQ users produces an empty array, which raises. - A7 — In the membership service, the result of
upsert_allis read with.rowseven when the call was skipped because the array was empty (the result isnil, the read crashes). - D3 — Stale outlet assignments are never cleared. An AREA manager moved off an outlet in jodgig keeps the old assignment active in jodapp. (The fix revokes via
revoked_at; it does not delete — see the soft-delete convention.)
Direction
Build the mapping per migrated membership:
- HQ / SUPER_HQ_EXTERNAL — no rows.
- LOCATION — if
jodgig_user.location_idresolves to anOrg::Outlet(remote_id = location_id), produce one row{ membership_id, outlet_id }. The lookup safely returns0..1rows because 2.1 makesindex_org_outlets_on_remote_idUNIQUE. If the outlet has not been migrated yet, skip (and log — issue 5.2 will pick it up later). - AREA —
Org::Outlet.where(area_user_id: jodgig_user.id). The location sync stores the jodgig user id inorg_outlets.area_user_id(verified inMapToOrgOutletService), so the comparison is jodgig-id to jodgig-id. Produce one row per outlet.
Guard empty. Before calling upsert_all, check the current-row array is non-empty. If empty, skip the upsert — but still run the revoke step below, because an employer who lost all their outlets must have every assignment revoked.
Revoke stale assignments — never hard-delete. org_outlet_assignments carries a revoked_at timestamp for soft-deletion, and the project soft-delete convention (docs/90-99-engineering-meta/91-rails/schema/schema-soft-delete-conventions.md) forbids removing rows. The sync converges to the jodgig truth by toggling revoked_at, not by deleting:
- Build the current rows with
revoked_at: nil. The upsert inserts new assignments and clearsrevoked_aton any that were previously revoked (re-assignment un-revokes), sorevoked_atis inupdate_only. - Then set
revoked_at = Time.currenton the assignments that exist in jodapp but are no longer in the current set. - Mind the empty-array SQL gotcha: when the current set is empty, exclude nothing and revoke every assignment for the membership; when non-empty, revoke the complement of the current ids. Guard the two cases explicitly rather than relying on
where.not(col: [])behaviour.
Upsert and revoke.
# rows: [{ membership_id:, outlet_id:, revoked_at: nil }, ...]
unless rows.empty?
Org::OutletAssignment.upsert_all(
rows,
unique_by: [:membership_id, :outlet_id],
update_only: [:revoked_at], # re-assignment clears a prior revocation
record_timestamps: true
)
end
# Revoke assignments no longer current — soft-delete via revoked_at, never delete.
current_outlet_ids = rows.map { |r| r[:outlet_id] }
scope = Org::OutletAssignment.where(membership_id: membership_id, revoked_at: nil)
scope = scope.where.not(outlet_id: current_outlet_ids) if current_outlet_ids.any?
scope.update_all(revoked_at: Time.current)
Acceptance
- Tests: empty current set → upsert skipped; revoke step still runs; no crash.
- Tests: LOCATION user with a migrated outlet → exactly one active assignment.
- Tests: AREA user with three outlets → three active assignments.
- Tests: AREA user previously assigned to outlets A, B; jodgig now points to B, C → B and C active, A has
revoked_atset (row retained, not deleted). - Tests: an outlet revoked on one sync, then re-added in jodgig → its
revoked_atis cleared back to NULL on the next sync. - Tests: an employer who loses all outlets → every assignment for that membership is revoked, none deleted.
- Tests: HQ-only batch → no assignment rows inserted; no crash.
Depends on
- 3.1, 2.1