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' `); } }