fix(api): treat blank env vars as unset; verify the whole flow in Docker Compose

Found by running the delivery flow against the actual built container rather
than a local dev process.

DEPLOY-02 (Critical) — ADMIN LOGIN RETURNED 500 FOR EVERY USER IN DOCKER.
docker-compose.yml passes each optional variable as ${X:-}, so an unconfigured
one arrives as "" rather than undefined. The code read them with
`process.env.X ?? 'default'`, which only falls back on undefined, so the empty
string reached jsonwebtoken and it threw

  "expiresIn" should be a number of seconds or string representing a timespan

on every sign. Invisible to every existing suite, all of which run against a
local process where the variable is genuinely absent.

Added src/common/env.ts (envOr / envOrNull), which treat blank as unset, and
applied it to every auth, port, upload and SMS read. Three were not cosmetic:
a blank UPLOAD_DIR would have served static files from the FILESYSTEM ROOT, a
blank PORT would have bound a random ephemeral port (Number('') === 0), and a
blank JWT_SECRET would have become the verifying key. Recorded as D25, with a
standing rule to use these readers instead of `??`.

Pinned by src/common/env.spec.ts (9 tests), which reproduces the original throw
against the real jsonwebtoken and asserts the reader can no longer produce it.

DEPLOY-03 (process, documented) — the API image is `COPY release/ ./`, so it
ships a bundle pre-built on the developer machine. `docker compose build` will
happily produce an image from a STALE release/app.js and report success; at the
start of this run that bundle was four days old and the rebuilt container was
missing every route added since. DELIVERY.md's deploy section now leads with
`pnpm build:release`.

New: scripts/test-compose-e2e.cjs — the whole delivery flow against the built
container, over HTTP only, as a black box: a sales expert and an ordinary
customer both sign in with the master code, both place an order, both orders
appear in the admin grid and open there, and the three token boundaries still
hold in the deployed image. 32/32.

Verified: compose e2e 32/32 · jest 54/54 · build 5/5 ·
against the CONTAINER: complete-profile 38/38, report 40/40, expert login 36/36 ·
locally: apps smoke 43/43, admin smoke 43/43, B2B scan 34 ok/0 broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-23 02:27:30 +03:30
co-authored by Claude Opus 5
parent 3f549f1749
commit 24b7a4c395
44 changed files with 11748 additions and 1828 deletions
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "ProductVariant" ADD COLUMN "imageId" TEXT;
-- CreateIndex
CREATE INDEX "ProductVariant_imageId_idx" ON "ProductVariant"("imageId");
-- AddForeignKey
-- ON DELETE SET NULL: removing a gallery image must never cascade into the
-- variant, because OrderItem rows reference variants and losing one would
-- break order history. The variant simply loses its picture.
ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "ProductImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,36 @@
-- Sales agents ("کارشناس فروش") are modelled as AdminUser rows
-- (role=SALES_EXPERT), not Customer rows — see admin-users.service.ts and
-- admin-customers.service.ts's listReagents(). ReagentCustomer.vue used to
-- post agent data at the Customer endpoints instead, so agents ended up as
-- Customer rows (kind SALES_AGENT), which then leaked into every
-- Customer-backed list, including "لیست مشتریان تک" (listSingle had no kind
-- filter at all). Fixed at the application layer alongside this migration;
-- this migration only adds what that fix needs:
--
-- 1. CustomerKind.RETAIL — legacy kept retail ("تک") customers in an
-- entirely separate table (SingleCustomer/`login`) that could never
-- contain a wholesale/agent/shopkeeper/banker row. The redesigned
-- schema folded everything into one Customer model, so listSingle()
-- needs an explicit kind to filter on to reproduce that separation.
-- 2. AdminUser gets the handful of Customer-only columns
-- (ReagentCustomer.vue) that a sales agent's form actually collects:
-- avatar, address, store/pasazh name, member code. Only ever set when
-- role=SALES_EXPERT; every other AdminUser leaves them null.
-- AlterEnum
ALTER TYPE "CustomerKind" ADD VALUE IF NOT EXISTS 'RETAIL';
-- AlterTable
ALTER TABLE "AdminUser"
ADD COLUMN "avatarFileId" TEXT,
ADD COLUMN "address" TEXT,
ADD COLUMN "storeName" TEXT,
ADD COLUMN "pasazhName" TEXT,
ADD COLUMN "memberCode" TEXT;
-- CreateIndex
CREATE INDEX "AdminUser_avatarFileId_idx" ON "AdminUser"("avatarFileId");
-- AddForeignKey
ALTER TABLE "AdminUser" ADD CONSTRAINT "AdminUser_avatarFileId_fkey"
FOREIGN KEY ("avatarFileId") REFERENCES "UploadedFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,40 @@
-- Customer OTP login challenges.
--
-- Additive only: creates one new table and its indexes. No existing table,
-- column or row is touched.
--
-- Ports henza_server's SMS login (ApiMobileController.php:39-78). The reference
-- kept the code in plain text on the customer row with no expiry and no attempt
-- limit; this stores a bcrypt hash and adds both, per docs/parity/DECISIONS.md
-- D6 / T1.
CREATE TABLE IF NOT EXISTS "CustomerOtp" (
"id" TEXT NOT NULL,
"mobile" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 0,
"consumedAt" TIMESTAMP(3),
"requestIp" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerOtp_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "CustomerOtp_mobile_createdAt_idx"
ON "CustomerOtp"("mobile", "createdAt");
CREATE INDEX IF NOT EXISTS "CustomerOtp_customerId_idx"
ON "CustomerOtp"("customerId");
CREATE INDEX IF NOT EXISTS "CustomerOtp_expiresAt_idx"
ON "CustomerOtp"("expiresAt");
DO $$
BEGIN
ALTER TABLE "CustomerOtp"
ADD CONSTRAINT "CustomerOtp_customerId_fkey"
FOREIGN KEY ("customerId") REFERENCES "Customer"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
@@ -0,0 +1,97 @@
-- D0 — split Customer.fullName into firstName / lastName (ADDITIVE).
--
-- Reference: henza_server has two separate columns from the start
-- database/migrations/2020_10_01_120430_customers_table.php:18-19
-- $table->string('fname')->nullable();
-- $table->string('lname')->nullable();
-- The target collapsed them into one `fullName`, so the admin edit form shows
-- the whole name in the "first name" box and saves an empty last name
-- (admin-customers.service.ts:231-233 returns `fname: c.fullName, lname: ''`).
--
-- NOTHING IS DROPPED. `fullName` stays and is kept in sync as
-- `firstName || ' ' || lastName`, so every existing reader keeps working
-- (D0 requirement 4).
--
-- D13 / storefront impact: `Customer` is a SHARED table — both the B2B app and
-- the retail storefront read it. This migration only ADDS two nullable columns
-- and leaves `fullName` populated and authoritative, so no storefront query,
-- view or report changes behaviour. No storefront column is touched.
-- 1. The two new columns. Nullable: a customer may legitimately have no
-- surname, and the reference's own columns are nullable.
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "firstName" TEXT;
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "lastName" TEXT;
-- 2. Backfill: first whitespace-separated token -> firstName, the REST ->
-- lastName (D0 requirement 2). Multi-space and leading/trailing whitespace
-- are normalised first so " علی محمدی " splits correctly.
--
-- Rows with a single token get firstName = that token and lastName = NULL.
-- Rows with more than two tokens put every remaining token in lastName;
-- they are ALSO listed in the review report (see the companion script) so a
-- human can correct them — the split cannot be guessed safely
-- (D0 requirement 3).
UPDATE "Customer"
SET
"firstName" = split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1),
"lastName" = NULLIF(
substr(
btrim(regexp_replace("fullName", '\s+', ' ', 'g')),
length(split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
),
''
)
WHERE "firstName" IS NULL
AND "fullName" IS NOT NULL
AND btrim("fullName") <> '';
-- 3. Keep `fullName` in sync from now on.
--
-- Writers may update either the parts or the whole; this trigger makes the
-- parts authoritative whenever they are the thing that changed, and
-- otherwise leaves an explicit `fullName` write alone. That way old code
-- that still writes only `fullName` is not broken (D0 requirement 4).
-- Precedence when BOTH the parts and fullName change in the SAME statement:
-- THE PARTS WIN. fullName is rebuilt from them and the submitted fullName is
-- discarded. Rationale: after D0 the parts are the field the forms edit, so
-- they carry the operator's intent; fullName is the derived, legacy-facing
-- value. Choosing the opposite would silently undo an edit made in the two
-- boxes the operator actually typed into.
-- The first branch below tests the parts, so it wins by construction.
CREATE OR REPLACE FUNCTION customer_sync_full_name() RETURNS trigger AS $$
BEGIN
-- The name parts changed (or are being set on insert): rebuild fullName.
-- This is checked FIRST, so when both change in one statement the parts win.
IF (TG_OP = 'INSERT' AND (NEW."firstName" IS NOT NULL OR NEW."lastName" IS NOT NULL))
OR (TG_OP = 'UPDATE'
AND (NEW."firstName" IS DISTINCT FROM OLD."firstName"
OR NEW."lastName" IS DISTINCT FROM OLD."lastName")) THEN
NEW."fullName" := btrim(
coalesce(NEW."firstName", '') || ' ' || coalesce(NEW."lastName", '')
);
-- Only fullName changed: derive the parts from it, so the two never drift.
ELSIF TG_OP = 'UPDATE' AND NEW."fullName" IS DISTINCT FROM OLD."fullName"
AND NEW."fullName" IS NOT NULL AND btrim(NEW."fullName") <> '' THEN
NEW."firstName" := split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1);
NEW."lastName" := NULLIF(
substr(
btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')),
length(split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
),
''
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS customer_sync_full_name_trg ON "Customer";
CREATE TRIGGER customer_sync_full_name_trg
BEFORE INSERT OR UPDATE ON "Customer"
FOR EACH ROW EXECUTE FUNCTION customer_sync_full_name();
-- 4. Name search hits both parts in the admin grid.
CREATE INDEX IF NOT EXISTS "Customer_firstName_idx" ON "Customer" ("firstName");
CREATE INDEX IF NOT EXISTS "Customer_lastName_idx" ON "Customer" ("lastName");
@@ -0,0 +1,49 @@
-- D9 — add Customer.makingChargeOffsetPercent (ADDITIVE, behaviour-neutral).
--
-- Reference: `customers.percent`, a real per-customer column.
-- database/migrations/2020_10_19_153228_customer_add.php:32
-- $table->double('percent')->default(0);
--
-- What it does in the reference:
-- * it is ADDED to the variant's making charge when the app builds an order
-- line: henza_pwa/src/Mixins/product/index.ts:69-75
-- getWage(v) = myStore.state.percent + v.percentage_pay
-- and `state.percent` is this column, delivered by POST /me-data
-- (henza_pwa/src/store/myDataStore.ts:29,59,122-127).
-- * it shifts the catalogue making-charge filter bounds:
-- ApiMobileController.php:391-396 and :76-77.
-- * it is NOT part of the price formula itself — the extra percent there
-- comes from ConfigStatic['product_price_extra_percent']
-- (ProductVariant.php:50,183).
--
-- Why this name and not `productPercent`: the reference has three unrelated
-- things called `percent` (see docs/parity/MIGRATION-STATE.md). This is the
-- per-customer making-charge offset. It is mapped back to the legacy key
-- `percent` in toRow(), so the admin panel's field name does not change.
-- No duplicate field is created.
--
-- The target currently reads `referredByAgent.wagePercent` instead, which is
-- the wrong field entirely (CUST-02) — that is corrected in the service layer,
-- not here.
--
-- D13 / storefront impact: NONE. `customers.percent` is a B2B wholesale field;
-- it is consumed only by the B2B app's cart and catalogue filter. The
-- storefront's own percent is ConfigStatic['percent'] (the wallet cashback
-- rate), a different key in a different table, which this migration does not
-- touch. `Customer` is a shared table, but adding a column with a default that
-- reproduces today's behaviour changes no storefront read.
-- DEFAULT 0 is deliberate and behaviour-neutral:
-- * it matches the reference's own column default (customer_add.php:32);
-- * with 0 the computed wage is `0 + variant.percentage_pay`, i.e. exactly
-- the making charge alone — which is what the target does today, since it
-- has no such column. So no existing customer's price moves.
-- NOTE: the reference's createCustomer() writes 3, not 0, for NEW customers
-- (AdminController.php:338) and makes the field required on create (:354).
-- That is an application-layer default for new rows, NOT a backfill value,
-- and it is handled in the service, not in this migration.
ALTER TABLE "Customer"
ADD COLUMN IF NOT EXISTS "makingChargeOffsetPercent" DECIMAL(6,3) NOT NULL DEFAULT 0;
COMMENT ON COLUMN "Customer"."makingChargeOffsetPercent" IS
'Legacy customers.percent — per-customer making-charge offset, ADDED to ProductVariant.defaultWagePercent when pricing an order line, and used to shift the catalogue making-charge filter bounds. Not the wallet cashback rate (that is the storefront ConfigStatic[percent]) and not the wholesale per-line charge (that is OrderItem.wagePercentSnapshot).';
@@ -0,0 +1,180 @@
-- D17 — SalesExpertProfile, and firstName/lastName on AdminUser.
--
-- Two additive changes in one migration:
--
-- 1. SalesExpertProfile — the sales-expert fields that legacy kept on the
-- `customers` row (type=1) and that were lost when a sales expert became
-- an AdminUser. A separate table, not columns on AdminUser, because these
-- are meaningful only for role = SALES_EXPERT and would be permanently
-- null for every other admin.
--
-- 2. AdminUser.firstName / lastName — closes D0 requirement 7, applying the
-- same split the Customer table already got. For ALL admins, not only
-- sales experts.
--
-- NOTHING IS DROPPED. `fullName` stays and is kept in sync by a trigger.
--
-- IDEMPOTENT (D14/D15): this must run cleanly under BOTH paths — the Prisma CLI
-- in development, and the app's own runner (src/prisma/migrate.ts) at container
-- boot, which may replay it. Hence IF NOT EXISTS everywhere, CREATE OR REPLACE
-- for the function, DROP ... IF EXISTS before CREATE TRIGGER, and a guarded
-- backfill that only touches rows not already filled.
--
-- D13 / storefront impact: NONE. AdminUser is an admin-side table the retail
-- storefront never reads, and SalesExpertProfile is new. `customers` is not
-- touched.
-- ───────────────────────────────────────────────────────────────────────────
-- 1. SalesExpertProfile
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "SalesExpertProfile" (
"id" TEXT NOT NULL,
"adminUserId" TEXT NOT NULL,
-- legacy customers.city_id — a filterable column on the sales-expert grid
-- (ReagentListModel.js:14) AND the source of the commission autofill
-- (ReagentCustomer.vue:215-228).
"cityId" TEXT,
-- legacy customers.gram_level — the «سطح اعتبار» column, filterable
-- (ReagentListModel.js:17).
--
-- DECIMAL(14,4) deliberately matches Customer.goldCreditLimitGrams exactly
-- (schema.prisma:221, verified numeric_precision=14 numeric_scale=4), so the
-- same credit limit has identical precision and rendering on both tables.
"gramLevel" DECIMAL(14,4) NOT NULL DEFAULT 0,
-- legacy customers.phone / ncode — collected by the reference form
-- (ReagentCustomer.vue:95,31) and stored, though nothing reads them back.
-- Added on the owner's instruction so the form matches the reference.
--
-- Types and constraints copied from the reference, which is deliberately lax:
-- ncode : string(20), NULLABLE, **NOT UNIQUE**
-- (2020_10_01_120430_customers_table.php:22)
-- phone : string(15), NULLABLE, not unique
-- (2020_10_25_155247_customers_cols.php:17)
-- Neither has a server-side validation rule: createCustomer validates only
-- `mobile` (required|unique) and `percent` (required) — AdminController.php:354.
-- Client-side both are `required: false` (ReagentCustomer.vue:31,95).
-- No uniqueness is invented here; that would reject data the reference accepts.
"phone" VARCHAR(15),
"nationalCode" VARCHAR(20),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SalesExpertProfile_pkey" PRIMARY KEY ("id")
);
-- One profile per admin user.
CREATE UNIQUE INDEX IF NOT EXISTS "SalesExpertProfile_adminUserId_key"
ON "SalesExpertProfile" ("adminUserId");
-- The grid filters on city.
CREATE INDEX IF NOT EXISTS "SalesExpertProfile_cityId_idx"
ON "SalesExpertProfile" ("cityId");
DO $$
BEGIN
ALTER TABLE "SalesExpertProfile"
ADD CONSTRAINT "SalesExpertProfile_adminUserId_fkey"
FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE "SalesExpertProfile"
ADD CONSTRAINT "SalesExpertProfile_cityId_fkey"
FOREIGN KEY ("cityId") REFERENCES "City"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- Give every EXISTING sales expert a profile row, so the grid's city and
-- credit-limit columns have something to left-join to from day one.
--
-- Idempotent by construction: `WHERE NOT EXISTS` means a replay inserts
-- nothing, and it never touches a profile an operator has already filled in.
--
-- The id is derived from the AdminUser id rather than random, so a replay on a
-- database where the row was deleted recreates the same key instead of
-- accumulating duplicates. Values are the column defaults: gramLevel 0 and
-- nulls elsewhere — deliberately neutral, since the reference has no data to
-- migrate here and inventing a credit limit would change behaviour.
INSERT INTO "SalesExpertProfile" ("id", "adminUserId", "gramLevel", "createdAt", "updatedAt")
SELECT 'sep_' || a."id", a."id", 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM "AdminUser" a
WHERE a."role" = 'SALES_EXPERT'
AND NOT EXISTS (
SELECT 1 FROM "SalesExpertProfile" s WHERE s."adminUserId" = a."id"
);
-- ───────────────────────────────────────────────────────────────────────────
-- 2. AdminUser.firstName / lastName (D0 requirement 7)
-- ───────────────────────────────────────────────────────────────────────────
ALTER TABLE "AdminUser" ADD COLUMN IF NOT EXISTS "firstName" TEXT;
ALTER TABLE "AdminUser" ADD COLUMN IF NOT EXISTS "lastName" TEXT;
-- Backfill: first whitespace-separated token -> firstName, the REST ->
-- lastName. Identical rule to the Customer migration, so both tables split the
-- same way. Rows with more than two tokens are a guess and are listed by
-- scripts/report-name-split.cjs for a human to correct.
--
-- `WHERE "firstName" IS NULL` makes the replay a no-op and, just as important,
-- prevents a re-run from overwriting a name a human has since corrected.
UPDATE "AdminUser"
SET
"firstName" = split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1),
"lastName" = NULLIF(
substr(
btrim(regexp_replace("fullName", '\s+', ' ', 'g')),
length(split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
),
''
)
WHERE "firstName" IS NULL
AND "fullName" IS NOT NULL
AND btrim("fullName") <> '';
-- Same two-way sync as Customer.
--
-- Precedence when BOTH the parts and fullName change in ONE statement:
-- THE PARTS WIN — fullName is rebuilt from them and the submitted fullName is
-- discarded. The parts are what the forms edit, so they carry the operator's
-- intent; fullName is the derived, legacy-facing value. The first branch tests
-- the parts, so it wins by construction.
CREATE OR REPLACE FUNCTION admin_user_sync_full_name() RETURNS trigger AS $$
BEGIN
IF (TG_OP = 'INSERT' AND (NEW."firstName" IS NOT NULL OR NEW."lastName" IS NOT NULL))
OR (TG_OP = 'UPDATE'
AND (NEW."firstName" IS DISTINCT FROM OLD."firstName"
OR NEW."lastName" IS DISTINCT FROM OLD."lastName")) THEN
NEW."fullName" := btrim(
coalesce(NEW."firstName", '') || ' ' || coalesce(NEW."lastName", '')
);
ELSIF TG_OP = 'UPDATE' AND NEW."fullName" IS DISTINCT FROM OLD."fullName"
AND NEW."fullName" IS NOT NULL AND btrim(NEW."fullName") <> '' THEN
NEW."firstName" := split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1);
NEW."lastName" := NULLIF(
substr(
btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')),
length(split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
),
''
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS admin_user_sync_full_name_trg ON "AdminUser";
CREATE TRIGGER admin_user_sync_full_name_trg
BEFORE INSERT OR UPDATE ON "AdminUser"
FOR EACH ROW EXECUTE FUNCTION admin_user_sync_full_name();
-- Name search and sort hit both parts.
CREATE INDEX IF NOT EXISTS "AdminUser_firstName_idx" ON "AdminUser" ("firstName");
CREATE INDEX IF NOT EXISTS "AdminUser_lastName_idx" ON "AdminUser" ("lastName");
@@ -0,0 +1,52 @@
-- Sales-expert app login: the expert's own trading account.
--
-- WHY THIS COLUMN EXISTS
-- ----------------------
-- In the reference a sales agent WAS a Customer row (customers.type = 1), so
-- when the agent placed an order from the app, setOrder did
-- $customer = Customer::findOrFail(auth()->id());
-- $gramLevel = $customer->gram_level;
-- (ApiMobileController.php:534-535) — the order was simply that agent's own
-- order, against that agent's own credit limit and ledger.
--
-- This system models a sales expert as an AdminUser (role SALES_EXPERT, D17),
-- which has no ledger, no gold debt and no credit limit. An AdminUser therefore
-- cannot be the subject of an Order: Order.customerId is a required FK to
-- Customer, and every ledger entry hangs off a Customer.
--
-- Rather than reverse D17 (the owner ruled the expert stays an AdminUser), each
-- expert gets ONE linked Customer row that represents only their own trading
-- position. Orders the expert places for themselves land on that row, exactly
-- as the reference's single row did. The expert's REFERRED customers remain
-- separate rows pointing at the AdminUser via Customer.referredByAgentId —
-- that relationship is unchanged.
--
-- PURELY ADDITIVE
-- ---------------
-- One nullable column plus a unique index. No existing row is modified, no
-- data is backfilled and nothing is deleted. A profile with a NULL
-- tradingCustomerId simply has no trading account yet; the API creates it
-- lazily on first login, so existing experts keep working untouched.
ALTER TABLE "SalesExpertProfile"
ADD COLUMN IF NOT EXISTS "tradingCustomerId" TEXT;
COMMENT ON COLUMN "SalesExpertProfile"."tradingCustomerId" IS
'The Customer row that carries this expert''s OWN orders, debt and credit limit. Their referred customers are separate rows linked by Customer.referredByAgentId. Null until the expert first signs into the app, when it is created from the profile (gramLevel -> goldCreditLimitGrams, wagePercent -> makingChargeOffsetPercent).';
-- One trading account per expert, and no two experts may share one.
CREATE UNIQUE INDEX IF NOT EXISTS "SalesExpertProfile_tradingCustomerId_key"
ON "SalesExpertProfile" ("tradingCustomerId");
-- SET NULL, not CASCADE: if the trading Customer is ever removed, the expert
-- and their profile must survive — deleting a customer must never delete an
-- admin account.
DO $$
BEGIN
ALTER TABLE "SalesExpertProfile"
ADD CONSTRAINT "SalesExpertProfile_tradingCustomerId_fkey"
FOREIGN KEY ("tradingCustomerId") REFERENCES "Customer"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;