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
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
| Data | Why it is needed |
|---|---|
jodgig.users where user_type is HQ, AREA, LOCATION or SUPER_HQ_EXTERNAL, including rows where status = 0 or is_deleted = 1 | Identity fields, role, status, ordinary company and outlet; also detect employer access that must be revoked |
jodgig.companies | Company eligibility |
jodgig.user_company | Durable external super-HQ company grants |
jodgig.locations | Outlet-manager and area-manager scope |
jodapp.org_companies and jodapp.org_outlets | Confirm that company and outlet prerequisites have already migrated |
jodapp.identities_users | Match 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 rows | Find JodGig-provisioned access that disappeared from the source |
jodapp.org_memberships rows where provisioning_source = jodapp | Preserve 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 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
| Value | Normalization |
|---|---|
| Strip surrounding spaces and lowercase | |
| Mobile | Normalize to the existing destination format before duplicate checks |
| Boolean-like settings | Convert explicitly to true or false |
| Company grants | Keep distinct active user-and-company pairs |
| Missing dates | Treat 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
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,AREAorLOCATION; - the user is enabled and not deleted;
jodgig.users.company_idresolves 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:
skipwhen no JodGig-provisioned destination access exists; orrevokewhen old JodGig-provisioned access exists.
Decide external super-HQ eligibility
External access comes from valid jodgig.user_company rows. The JodGig user row chooses only the initial default.
For SUPER_HQ_EXTERNAL:
- require an enabled, non-deleted user;
- read live
jodgig.user_companyrows; - remove duplicate user-and-company pairs;
- require each linked company to be live, non-obsolete and present as an active JodApp company; and
- create one desired
hq_managermembership 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
skipwhen the identity has never received JodGig-provisioned access; - emit
revokewhen it has; and - include a clear
no_valid_company_grantreason in the report.
SUPER_HQ_INTERNAL never enters this planner. It is JOD staff impersonation, not customer membership data.
Match the identity safely
| Order | Rule |
|---|---|
| 1 | Find Identities::User by the stable JodGig user ID. |
| 2 | On first migration only, try the normalized email. |
| 3 | Link the email match only when it has no different JodGig user ID. |
| 4 | If 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 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
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_verifiedandis_phone_verifiedboth set totrue, 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 JodApp | Source or decision | Exact rule |
|---|---|---|
jodapp.org_memberships.user_id | jodapp.identities_users.id | Use 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 LOCATION | jodgig.users.company_id → jodapp.org_companies.remote_id | Find 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_EXTERNAL | jodgig.user_company.company_id → jodapp.org_companies.remote_id | For 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_source | Fixed value jodgig | This 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.role | jodgig.users.user_type | HQ and SUPER_HQ_EXTERNAL → hq_manager; AREA → area_manager; LOCATION → outlet_manager. Phase 1 adds the destination role name outlet_manager. |
jodapp.org_memberships.status | Planner's access decision | Use 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.title | jodgig.users.job_title | Copy the current JodGig job title. |
jodapp.org_memberships.is_owner | Fixed value false | JodGig access does not prove company ownership. |
jodapp.org_memberships.is_default | Planner's default-membership decision | Set 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_enabled | jodgig.users.user_setting_email_notification_status | Convert 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_enabled | jodgig.users.user_setting_whatsapp_notification_status | Convert 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_enabled | jodgig.users.user_setting_sms_notification_status | Convert 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_type | Desired JodApp access |
|---|---|
HQ | No jodapp.org_outlet_assignments rows; the membership has company-wide access |
SUPER_HQ_EXTERNAL | No jodapp.org_outlet_assignments rows; each membership has company-wide access |
LOCATION | One 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 |
AREA | One 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
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
| Column | Purpose |
|---|---|
| JodGig user ID and type | Identifies the source record without personal data |
| User and company state | Explains eligibility |
| Decision and reason codes | Makes behaviour reviewable |
| Matched JodApp identity ID | Shows identity linking |
| Intended company IDs, roles and default | Shows exact membership access |
| Intended outlet IDs | Shows exact scoped access |
| Memberships and assignments to revoke | Shows removals before they happen |
| Missing prerequisites or conflict | Makes manual work explicit |
Do not include passwords, password hashes, government IDs, email addresses, mobile numbers or names in the report.
Planner tests
| Scenario | Expected plan |
|---|---|
| Active HQ at live company | migrate or update; one active HQ membership |
| New disabled HQ | skip; no identity or membership write |
| Previously migrated HQ becomes disabled | revoke; keep identity history |
| External super-HQ with three valid pivots | Three HQ memberships |
External jodgig.users.company_id matches a valid grant | That membership is default |
| External company is missing or invalid | Highest valid pivot ID supplies the fallback default |
| External pivot is removed | Missing JodGig membership appears in revoke set |
| Internal super-HQ | Excluded |
| Email belongs to another linked identity | needs_review; no write plan |
| LOCATION outlet is missing | needs_review; no partial access |
| Native membership exists beside a JodGig membership | Native row absent from update and revoke sets |
| Same snapshot planned twice | Structurally identical plans |
Phase completion gate
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.