Skip to main content

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

  • F2is_owner is set to role == 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.
  • D2role, status, and title are only set on insert and never updated on re-sync. A user promoted from LOCATION to AREA in JodGig stays a location_manager in 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_typeOrg::Membership.role:

  • HQ, SUPER_HQ_EXTERNALhq_manager
  • AREAarea_manager
  • LOCATIONlocation_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 = 1Org::Membership.statuses[:revoked]
  • status = 0Org::Membership.statuses[:revoked] (the propagation path; new disabled users are excluded earlier by 4.1)
  • suspended_at present → Org::Membership.statuses[:suspended]
  • otherwise → Org::Membership.statuses[:active]

One owner per company. resolve_owner_per_company(company_id, candidate_memberships):

  1. If any candidate maps from an HQ jodgig user — that one is the owner.
  2. Else (super-HQ-only company): the candidate whose jodgig user id equals companies.created_by — that is the owner.
  3. Else: the earliest-created super-HQ membership (lowest users.created_at).
  4. 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_id and the user_company pivot produces exactly one membership for that company, picked by the documented precedence — no ON CONFLICT DO UPDATE command cannot affect row a second time error.
  • Tests: a promoted user (LOCATION → AREA in JodGig) flips role on re-sync.
  • Tests: an enabled-then-disabled JodGig user (already in jodapp) flips to status: 'revoked' on re-sync. is_deleted and deleted_at are not touched.
  • Tests: is_owner and is_default are real booleans on every written row.
  • No notification-related columns are read from JodGig or written to org_memberships by this service.
  • No remote_gig_user_id column is written on org_memberships.

Depends on

  • 3.1, 2.1, 2.2