Skip to main content

Phase 2: calculate the intended result

The planner reads JodGig and JodApp, decides what should be true now, and writes nothing. Its output powers both the dry run and the live writer.

Outcome

TL;DR

Given the same source snapshot and destination state, the planner always produces the same explainable plan.

At the end of this phase, a reviewer can run one command and see:

  • who would migrate;
  • who would update;
  • whose access would be revoked;
  • who would be skipped;
  • which records need human review;
  • the exact intended memberships and outlet assignments; and
  • the reason for every decision.

Read the complete JodGig source and relevant JodApp state

DataWhy it is needed
jodgig.users where user_type is HQ, AREA, LOCATION or SUPER_HQ_EXTERNAL, including rows where status = 0 or is_deleted = 1Identity fields, role, status, ordinary company and outlet; also detect employer access that must be revoked
jodgig.companiesCompany eligibility
jodgig.user_companyDurable external super-HQ company grants
jodgig.locationsOutlet-manager and area-manager scope
jodapp.org_companies and jodapp.org_outletsConfirm that company and outlet prerequisites have already migrated
jodapp.identities_usersMatch by JodGig user ID or normalized email and find identity conflicts
jodapp.org_memberships rows where provisioning_source = jodgig, together with their jodapp.org_outlet_assignments rowsFind JodGig-provisioned access that disappeared from the source
jodapp.org_memberships rows where provisioning_source = jodappPreserve memberships created directly in JodApp and exclude them from revocation

Do not filter the JodGig source by jodgig.users.updated_at. A change to jodgig.companies, jodgig.user_company or jodgig.locations can alter access without updating the related jodgig.users row.

For example, suppose an HQ user was migrated yesterday and JodGig changes their jodgig.users.status to 0 today. If the planner reads only enabled users, that person disappears from its input and their old JodApp membership remains active. Reading the disabled row allows the planner to produce a revoke decision for that membership.

The merged code violates this rule — replace it, do not copy it

The scope currently on main (JodGig::Users::EmployersSyncService#select_employers_in_scope) reads only status: enabled users. With that filter, a synced employer who is later disabled in JodGig disappears from the input and keeps a working JodApp login — the revoke path can never run.

Load the small source set and build lookup maps before deciding individual users. This keeps decisions consistent and avoids repeated database reads.

Normalize before comparing

ValueNormalization
EmailStrip surrounding spaces and lowercase
MobileNormalize to the existing destination format before duplicate checks
Boolean-like settingsConvert explicitly to true or false
Company grantsKeep distinct active user-and-company pairs
Missing datesTreat as missing; never compare them directly with real dates

Mobile duplication must be counted after normalization. Two differently formatted strings that become the same number are one duplicate number.

Decide ordinary-employer eligibility

TL;DR

HQ, AREA and LOCATION require a live user and one resolvable live company.

An ordinary employer is eligible only when:

  • the account type is HQ, AREA or LOCATION;
  • the user is enabled and not deleted;
  • jodgig.users.company_id resolves to a source company;
  • the source company is enabled, not deleted and not in the shared obsolete-company list; and
  • the corresponding JodApp company exists and is active.

An eligible ordinary employer receives one desired membership.

An ineligible ordinary employer receives:

  • skip when no JodGig-provisioned destination access exists; or
  • revoke when old JodGig-provisioned access exists.

Decide external super-HQ eligibility

TL;DR

External access comes from valid jodgig.user_company rows. The JodGig user row chooses only the initial default.

For SUPER_HQ_EXTERNAL:

  1. require an enabled, non-deleted user;
  2. read live jodgig.user_company rows;
  3. remove duplicate user-and-company pairs;
  4. require each linked company to be live, non-obsolete and present as an active JodApp company; and
  5. create one desired hq_manager membership per remaining grant.

Ignore users.role_id when deriving access. JodGig temporarily changes it while a super-HQ user is inside a company.

Use jodgig.users.company_id as the default only when it matches one of the valid desired memberships. If it does not, choose the membership produced by the valid jodgig.user_company row with the highest pivot id. The auto-incrementing ID is a better insertion-order signal than created_at, which is absent on most legacy rows.

If no valid grant remains:

  • emit skip when the identity has never received JodGig-provisioned access;
  • emit revoke when it has; and
  • include a clear no_valid_company_grant reason in the report.

SUPER_HQ_INTERNAL never enters this planner. It is JOD staff impersonation, not customer membership data.

Match the identity safely

OrderRule
1Find Identities::User by the stable JodGig user ID.
2On first migration only, try the normalized email.
3Link the email match only when it has no different JodGig user ID.
4If the JodGig ID or email points at conflicting people, emit needs_review and plan no writes for that person.

A warning is not a guard. The planner must stop that person's plan when ownership is ambiguous.

A later JodGig email change that collides with another JodApp identity also becomes needs_review. Do not merge people and do not rely on a database uniqueness exception to make the decision.

The merged code violates this rule — replace it, do not copy it

The employer sync currently on main (JodGig::Users::EmployersSyncService#create_or_update_identities_user) sends a Sentry warning on an identity conflict and then updates the conflicting row anyway. That overwrites another person's email, mobile and name. This planner rule replaces that behaviour: a conflict becomes needs_review and nothing is written.

Calculate the desired identity

TL;DR

Copy the confirmed source-owned fields, but do not decide password-format upgrades in the planner.

The desired identity contains:

  • stable JodGig user ID;
  • normalized email and mobile;
  • country or phone code;
  • name and other agreed copied profile fields;
  • is_email_verified and is_phone_verified both set to true, so no verification step blocks an existing employer's first login;
  • address_geo_area_id — the database requires a value on every identity: use the area of the employer's company; for a multi-company super-HQ user, use the default membership's company; if the company has no area, use the Singapore root area; and
  • the source password representation and its detected format.

The planner records enough password information for the writer to apply the compatibility rule without including password hashes in reports or logs.

If mobile normalization finds an office number, invalid number or normalized duplicate, use the existing unique placeholder mechanism. An office number is any number whose normalized form starts with 6 — a Singapore landline. Real mobiles start with 8 or 9. Mobile collection improvements remain outside this migration.

Calculate memberships

The planner produces the values for rows in jodapp.org_memberships. It does not copy JodGig primary keys directly into jodapp.org_memberships.user_id or jodapp.org_memberships.company_id.

Destination column in JodAppSource or decisionExact rule
jodapp.org_memberships.user_idjodapp.identities_users.idUse the JodApp identity returned by the safe identity-matching rules above. This is not jodgig.users.id.
jodapp.org_memberships.company_id for HQ, AREA and LOCATIONjodgig.users.company_idjodapp.org_companies.remote_idFind the JodApp company whose remote_id equals jodgig.users.company_id, then store that row's jodapp.org_companies.id.
jodapp.org_memberships.company_id for SUPER_HQ_EXTERNALjodgig.user_company.company_idjodapp.org_companies.remote_idFor each valid jodgig.user_company row, find the JodApp company whose remote_id equals the pivot's company_id, then store that row's jodapp.org_companies.id.
jodapp.org_memberships.provisioning_sourceFixed value jodgigThis proposed column is added in Phase 1. The value means the employer reconciliation created and owns this membership, so later runs may update or revoke it. It is not a JodGig column or ID.
jodapp.org_memberships.rolejodgig.users.user_typeHQ and SUPER_HQ_EXTERNALhq_manager; AREAarea_manager; LOCATIONoutlet_manager. Phase 1 adds the destination role name outlet_manager.
jodapp.org_memberships.statusPlanner's access decisionUse active while the JodGig user, company and company grant remain valid. Use revoked for an existing JodGig-provisioned membership when that access is no longer valid. This is calculated, not copied from one JodGig column.
jodapp.org_memberships.titlejodgig.users.job_titleCopy the current JodGig job title.
jodapp.org_memberships.is_ownerFixed value falseJodGig access does not prove company ownership.
jodapp.org_memberships.is_defaultPlanner's default-membership decisionSet true on one valid membership for the identity and false on the others, using the default rules above.
Proposed jodapp.org_memberships.is_email_notification_enabledjodgig.users.user_setting_email_notification_statusConvert 1 to true and 0 to false. Phase 1 must add this destination column before the writer is enabled.
Proposed jodapp.org_memberships.is_whatsapp_notification_enabledjodgig.users.user_setting_whatsapp_notification_statusConvert 1 to true and 0 to false. Phase 1 must add this destination column before the writer is enabled.
Proposed jodapp.org_memberships.is_sms_notification_enabledjodgig.users.user_setting_sms_notification_statusConvert 1 to true and 0 to false. Phase 1 must add this destination column before the writer is enabled.

The existing unique index on jodapp.org_memberships(user_id, company_id) prevents duplicate memberships for the same JodApp identity and JodApp company. It is a destination database rule, not a pair copied from JodGig.

For example, assume jodgig.users.id = 42 matches jodapp.identities_users.id = 900, and jodgig.users.company_id = 17 matches jodapp.org_companies.id = 300 through jodapp.org_companies.remote_id = 17. The new row stores jodapp.org_memberships.user_id = 900 and jodapp.org_memberships.company_id = 300. It does not store the JodGig IDs 42 and 17 in those columns.

For a multi-company JodGig user, copy the same legacy notification choices to each JodGig-provisioned membership. Do not include native JodApp memberships in this update set.

The planner should list both:

  • desired active JodGig memberships; and
  • existing JodGig memberships absent from the desired set, which the writer must revoke.

Calculate outlet assignments

jodgig.users.user_typeDesired JodApp access
HQNo jodapp.org_outlet_assignments rows; the membership has company-wide access
SUPER_HQ_EXTERNALNo jodapp.org_outlet_assignments rows; each membership has company-wide access
LOCATIONOne jodapp.org_outlet_assignments row for the live jodapp.org_outlets row whose remote_id equals jodgig.users.location_id and whose company_id equals the membership's company_id
AREAOne jodapp.org_outlet_assignments row for every live jodapp.org_outlets row whose area_user_id equals jodgig.users.id and whose company_id equals the membership's company_id

A missing required company or outlet is not a successful partial migration. Emit needs_review with the missing prerequisite and plan no access write for that person.

For each membership, list:

  • assignments that should be active; and
  • existing assignments under JodGig-provisioned memberships that are no longer desired.

Plan record

TL;DR

One plain record contains the decision, intended state and reasons for one source person.

Conceptually, the planner returns:

EmployerMigrationPlan.new(
remote_user_id: 123,
decision: :update,
reasons: ["eligible_external_super_hq"],
identity_match: { type: :remote_id, id: 456 },
desired_identity: { ... },
desired_memberships: [ ... ],
memberships_to_revoke: [ ... ],
desired_outlet_assignments: [ ... ],
outlet_assignments_to_remove: [ ... ]
)

This is a data example, not a required class name. Keep the representation easy to serialize into a dry-run report and easy to pass to the writer.

Dry-run report

ColumnPurpose
JodGig user ID and typeIdentifies the source record without personal data
User and company stateExplains eligibility
Decision and reason codesMakes behaviour reviewable
Matched JodApp identity IDShows identity linking
Intended company IDs, roles and defaultShows exact membership access
Intended outlet IDsShows exact scoped access
Memberships and assignments to revokeShows removals before they happen
Missing prerequisites or conflictMakes manual work explicit

Do not include passwords, password hashes, government IDs, email addresses, mobile numbers or names in the report.

Planner tests

ScenarioExpected plan
Active HQ at live companymigrate or update; one active HQ membership
New disabled HQskip; no identity or membership write
Previously migrated HQ becomes disabledrevoke; keep identity history
External super-HQ with three valid pivotsThree HQ memberships
External jodgig.users.company_id matches a valid grantThat membership is default
External company is missing or invalidHighest valid pivot ID supplies the fallback default
External pivot is removedMissing JodGig membership appears in revoke set
Internal super-HQExcluded
Email belongs to another linked identityneeds_review; no write plan
LOCATION outlet is missingneeds_review; no partial access
Native membership exists beside a JodGig membershipNative row absent from update and revoke sets
Same snapshot planned twiceStructurally identical plans

Phase completion gate

TL;DR

Product and engineering must approve the dry-run shape and named examples before any writer is connected.

This phase is complete when the planner covers the full sanitized source population without writing and every non-PII row in its report has one explainable decision.