Skip to main content

[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:

  • A6Org::OutletAssignment.upsert_all is 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_all is read with .rows even when the call was skipped because the array was empty (the result is nil, 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_id resolves to an Org::Outlet (remote_id = location_id), produce one row { membership_id, outlet_id }. The lookup safely returns 0..1 rows because 2.1 makes index_org_outlets_on_remote_id UNIQUE. If the outlet has not been migrated yet, skip (and log — issue 5.2 will pick it up later).
  • AREAOrg::Outlet.where(area_user_id: jodgig_user.id). The location sync stores the jodgig user id in org_outlets.area_user_id (verified in MapToOrgOutletService), 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 clears revoked_at on any that were previously revoked (re-assignment un-revokes), so revoked_at is in update_only.
  • Then set revoked_at = Time.current on 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_at set (row retained, not deleted).
  • Tests: an outlet revoked on one sync, then re-added in jodgig → its revoked_at is 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