[Employer Sync 4.3] Map → Org::Membership (with one-owner-per-company rule)
TL;DR: Map per row into Org::Membership with role / status / title propagation and the one-owner-per-company rule (HQ wins; else companies.created_by super-HQ; else earliest super-HQ). Super-HQ memberships come from the user_company pivot via raw SQL. JodGig notification settings are NOT copied — JodApp owns notifications separately. Writes is_owner and is_default as real booleans (requires 2.2 to have flipped the column type).
Context
After the Identities::User row exists, the sync creates an Org::Membership linking that user to their company in JodApp. HQ / AREA / LOCATION users get one membership; a SUPER_HQ_EXTERNAL user gets one membership per company in their user_company pivot.
Problem
PR #1634 has two concrete defects this mapping must fix:
- F2 —
is_owneris set torole == hq_manager, so every HQ user is an owner. The audit shows every company has at most one HQ user, but super-HQ users can produce many owners on the same company. - D2 —
role,status, andtitleare only set on insert and never updated on re-sync. A user promoted fromLOCATIONtoAREAin JodGig stays alocation_managerin JodApp forever.
There is also no propagation of "user was disabled in JodGig after they were synced" — that needs to flow as a state transition to status: revoked (the skip-vs-revoke distinction agreed in the epic).
PR #1634's F1 WhatsApp/SMS swap bug is out of scope. We are not copying JodGig's notification settings at all (see "Out of scope" below), so there are no flags to swap.
Direction
Implement the mapping per row.
Role mapping. JodGig::User.user_type → Org::Membership.role:
HQ,SUPER_HQ_EXTERNAL→hq_managerAREA→area_managerLOCATION→location_manager
Status mapping. Per the state-transition rule. The revoke signal lands on status. This mapper does NOT write is_deleted or deleted_at. This follows the project soft-delete convention (docs/90-99-engineering-meta/91-rails/schema/schema-soft-delete-conventions.md): org_memberships is a lifecycle-rich table whose own status states already express "not active," and is_deleted / deleted_at are the rejected legacy pattern. The sync expresses "no longer an employer in jodgig" as a status change, full stop:
is_deleted = 1→Org::Membership.statuses[:revoked]status = 0→Org::Membership.statuses[:revoked](the propagation path; new disabled users are excluded earlier by 4.1)suspended_atpresent →Org::Membership.statuses[:suspended]- otherwise →
Org::Membership.statuses[:active]
One owner per company. resolve_owner_per_company(company_id, candidate_memberships):
- If any candidate maps from an
HQjodgig user — that one is the owner. - Else (super-HQ-only company): the candidate whose jodgig user id equals
companies.created_by— that is the owner. - Else: the earliest-created super-HQ membership (lowest
users.created_at). - All other candidates for that company:
is_owner = false.
is_default. For non-super-HQ: true (single membership). For super-HQ: true for one membership per user — pick the one matching the company on users.company_id if present, else the earliest by companies.created_at. Same precedence used by 4.2 for the user's address_geo_area_id source.
is_owner and is_default storage. Both columns become real boolean once 2.2 lands. This mapper writes true / false, not 't' / 'f'. The depends_on: 2.2 is there so this issue does not start until the type flip is done.
No remote_gig_user_id on memberships. The source-of-truth join key for "jodgig user X" lives on identities_users.remote_gig_user_id (UNIQUE in production). Queries that want all memberships for a jodgig user id join through identities_users — no duplicated column on memberships.
Super-HQ pivot read. Continue to read the user_company pivot via the raw SQL pattern PR #1634 introduced (filter deleted_at IS NULL). Skip pivot rows pointing to obsolete / archived Org::Company.
De-duplicate the upsert input by (user_id, company_id) before calling upsert_all. The unique composite on org_memberships(user_id, company_id) (added in 2.1) is the upsert key. PostgreSQL refuses to apply two pending rows with the same ON CONFLICT key in one statement and raises ON CONFLICT DO UPDATE command cannot affect row a second time. A super-HQ user whose user_company pivot lists the same company twice, or who has users.company_id pointing to a company that also appears in their pivot, would produce duplicate (user_id, company_id) pairs in the mapper's row array. Resolve in-Ruby with rows.uniq { |r| [r[:user_id], r[:company_id]] } using the precedence: HQ-direct membership wins over super-HQ-pivot membership; among ties, earliest by source created_at.
Out of scope — notification flags. Do NOT read or write user_setting_email_notification_status / user_setting_sms_notification_status / user_setting_whatsapp_notification_status. JodApp will own notification preferences as its own concern; JodGig's notification settings are unreliable and intentionally not migrated. The corresponding columns are no longer added by 2.1 either, so there is nothing to write into.
Upsert.
Org::Membership.upsert_all(
rows,
unique_by: [:user_id, :company_id],
update_only: [
:role,
:status,
:title,
:is_owner,
:is_default
],
record_timestamps: true,
returning: [:id]
)
Acceptance
- Tests: a company's HQ user is the sole owner; a super-HQ-only company gets exactly one owner per the precedence rule.
- Tests: a super-HQ user across 5 companies produces 5 memberships joinable back to the same
identities_users.remote_gig_user_id, and no double-ownership. - Tests: a super-HQ user with the same company appearing in both
users.company_idand theuser_companypivot produces exactly one membership for that company, picked by the documented precedence — noON CONFLICT DO UPDATE command cannot affect row a second timeerror. - Tests: a promoted user (
LOCATION → AREAin JodGig) flips role on re-sync. - Tests: an enabled-then-disabled JodGig user (already in jodapp) flips to
status: 'revoked'on re-sync.is_deletedanddeleted_atare not touched. - Tests:
is_ownerandis_defaultare real booleans on every written row. - No notification-related columns are read from JodGig or written to
org_membershipsby this service. - No
remote_gig_user_idcolumn is written onorg_memberships.
Depends on
- 3.1, 2.1, 2.2