From f611c337223ad36a2f491017a85933f90e65bc66 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 11 Aug 2026 11:53:36 +0000 Subject: [PATCH] feat(companies): migrate existing companies off the general manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data rescue, not just schema tidying — the order matters: 1. Backfill companies.email from generalManagerEmail before the key is stripped, or companies whose only address lived there stop receiving mail. 2. Backfill ownerName/Email/Phone (now required) from owner, GM, contact person, then the columns; seed etradeManagerName so existing rows read as matching rather than as a false mismatch. 3. Convert poaSameAsOwner: non-forwarders become poaDeclared='no' with their poa* details cleared; forwarders become 'yes' and keep them, so they now owe a DARS letter they were previously waived. 4. Derive poaDeclared for everyone else. 5. Move drafts off the deleted 'personnel' wizard step. 6. Drop the general_manager_* columns and strip gm*/poaSameAsOwner. down() restores the column shape only; the stripped values are gone by design. --- .../3390000000000-RemoveGeneralManager.ts | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts diff --git a/apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts b/apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts new file mode 100644 index 000000000..055c3771b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts @@ -0,0 +1,210 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Onboarding revamp: one company, one verified identity. + * + * The general manager is removed outright — it named who to talk to and gated + * nothing — and the company's people become its **owner** (whoever the eTrade + * licence names as the business's manager) and its **Power of Attorney**. + * Exactly one of them is identity-verified, chosen by the company's own answer + * to "does anyone hold power of attorney for you?", stored as + * `attributes.poaDeclared`. + * + * The order below matters — each step depends on data a later step destroys: + * + * 1. Rescue notification addresses. `companyNotifyEmailExpr` used to fall + * through to `attributes->>'generalManagerEmail'`, and `companies.email` was + * only ever written for a Fayda-verified owner — so every foreign company + * had none and was reached solely through that fallback. Promote it to the + * column before the key is stripped, or those companies stop receiving mail + * in silence. + * 2. Backfill the owner. `ownerName`/`ownerEmail`/`ownerPhone` are now required + * onboarding fields; without this every already-onboarded company would + * report three missing fields the moment it opened its settings page. + * 3. Resolve `poaSameAsOwner`. That flag waived the DARS delegation paper. It + * is gone, so the companies holding it must be re-expressed: + * - NOT a freight forwarder → "no PoA" (the owner represents themselves, + * nothing to delegate). Their PoA details are cleared. + * - A freight forwarder → "yes" and details KEPT. A forwarder signs on + * other companies' behalf, so a representative is non-negotiable and the + * waiver no longer exists. These companies will be asked for a + * delegation paper they were previously excused — an intentional, + * visible consequence, not an oversight. Count them before deploying. + * 4. Derive the declaration for everyone else, from whether PoA details exist. + * 5. Move drafts off the deleted "personnel" wizard step. + * 6. Only now drop the columns and strip the retired attribute keys. + * + * Irreversible by design: `down()` restores the columns' shape but cannot + * recover the values, and re-deriving `poaSameAsOwner` from `poaDeclared` would + * be a guess. + */ +export class RemoveGeneralManager3390000000000 implements MigrationInterface { + name = "RemoveGeneralManager3390000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // 1. Rescue the notification address before the key it lives under is gone. + await queryRunner.query(` + UPDATE freight.companies + SET email = COALESCE( + NULLIF(email, ''), + NULLIF(attributes->>'ownerEmail', ''), + NULLIF(attributes->>'generalManagerEmail', ''), + NULLIF(attributes->>'contactPersonEmail', '') + ) + WHERE COALESCE(email, '') = '' + `); + + // 2. Backfill the owner from the best source each company actually has: + // its Fayda-verified owner claims (already under owner*), then the general + // manager it named, then its contact person. A company with none of these + // never finished onboarding, and will be asked on its next visit. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = attributes + || jsonb_strip_nulls(jsonb_build_object( + 'ownerName', COALESCE( + NULLIF(attributes->>'ownerName', ''), + NULLIF(attributes->>'generalManagerName', ''), + NULLIF(attributes->>'contactPersonName', '') + ), + 'ownerEmail', COALESCE( + NULLIF(attributes->>'ownerEmail', ''), + NULLIF(attributes->>'generalManagerEmail', ''), + NULLIF(attributes->>'contactPersonEmail', ''), + NULLIF(email, '') + ), + 'ownerPhone', COALESCE( + NULLIF(attributes->>'ownerPhone', ''), + NULLIF(attributes->>'generalManagerPhone', ''), + NULLIF(attributes->>'contactPersonPhone', ''), + NULLIF(phone, '') + ) + )) + WHERE attributes IS NOT NULL + `); + + // 2b. Capture eTrade's manager for the owner-vs-licence check the + // backoffice now makes. Nothing stored it before, so the best we have is + // the owner name itself — which makes existing companies read as "matches" + // rather than as a false mismatch on data nobody ever compared. The value + // is refreshed for real on the company's next eTrade lookup. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = jsonb_set( + attributes, '{etradeManagerName}', to_jsonb(attributes->>'ownerName') + ) + WHERE COALESCE(attributes->>'ownerName', '') <> '' + AND attributes->>'etradeManagerName' IS NULL + AND COALESCE(licence_number, '') <> '' + `); + + // 3a. Owner-represents-themselves, and NOT a forwarder → "no PoA". + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = (c.attributes - 'poaName' - 'poaPhone' - 'poaEmail' + - 'poaLocation' - 'poaAddress' - 'poaFaydaSub' + - 'poaFaydaVerifiedAt' - 'poaBirthdate' - 'poaGender') + || jsonb_build_object('poaDeclared', 'no') + WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE + AND NOT EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + `); + + // 3b. Forwarders keep their representative and lose the waiver. + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = c.attributes || jsonb_build_object('poaDeclared', 'yes') + WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE + AND EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + `); + + // 4. Everyone else: "yes" if a representative was named or the company is a + // forwarder, "no" if it finished onboarding without one. A company still + // mid-onboarding is left unanswered — it will be asked, which is the point. + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = COALESCE(c.attributes, '{}'::jsonb) + || jsonb_build_object('poaDeclared', 'yes') + WHERE c.attributes->>'poaDeclared' IS NULL + AND ( + COALESCE(c.attributes->>'poaName', '') <> '' + OR COALESCE(c.attributes->>'poaPhone', '') <> '' + OR COALESCE(c.attributes->>'poaEmail', '') <> '' + OR COALESCE(c.attributes->>'poaLocation', '') <> '' + OR COALESCE(c.attributes->>'poaAddress', '') <> '' + OR EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + ) + `); + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = COALESCE(c.attributes, '{}'::jsonb) + || jsonb_build_object('poaDeclared', 'no') + WHERE c.attributes->>'poaDeclared' IS NULL + AND EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = c.id + AND ep.onboarding_completed = true + AND ep.deleted_at IS NULL + ) + `); + + // 5. The "personnel" (general manager) wizard step no longer exists; a + // draft resting on it would fall back to the very first step and make the + // customer walk the whole wizard again. + await queryRunner.query(` + UPDATE freight.external_profiles + SET onboarding_step = 'owner' + WHERE onboarding_step = 'personnel' + `); + + // 6. Retire the general manager and the flag it shared the model with. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = attributes - 'generalManagerName' - 'generalManagerEmail' + - 'generalManagerPhone' - 'gmSameAsOwner' - 'gmFaydaSub' + - 'gmFaydaVerifiedAt' - 'gmName' - 'gmEmail' - 'gmPhone' + - 'gmAddress' - 'gmBirthdate' - 'gmGender' + - 'poaSameAsOwner' + WHERE attributes IS NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS general_manager_name, + DROP COLUMN IF EXISTS general_manager_email, + DROP COLUMN IF EXISTS general_manager_phone + `); + } + + /** + * Restores the columns' shape only. The values, the `gm*` attributes and the + * `poaSameAsOwner` flag are not recoverable — this migration folded them into + * `ownerEmail` / `poaDeclared`, and there is no way back that isn't a guess. + */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS general_manager_name varchar(100), + ADD COLUMN IF NOT EXISTS general_manager_email varchar(150), + ADD COLUMN IF NOT EXISTS general_manager_phone varchar(20) + `); + await queryRunner.query(` + UPDATE freight.external_profiles + SET onboarding_step = 'personnel' + WHERE onboarding_step = 'owner' + `); + } +}