Merge release/delivery (IAM single sign-on) into the triage work
Brings the four release/delivery commits (IAM SSO for the admin panel,
both apps and the API; IAM env in docker-compose) onto fix/issues-triage.
Conflicts:
- customer-auth.service.ts imports: both kept.
- both apps' api/client.ts: both kept (AccountingBalance/WageQuote, and
iamAppUrl).
- all build output (admin-panel, user-app, user-app-v2 dist, and the api
release bundle): rebuilt from the merged source instead of picking a
side.
Fixed while verifying:
- The IAM change calls iam.browserBootstrapUrl after every login, but
the identity-service stand-ins in three test files lacked it. 15 tests
failed on release/delivery itself.
- SMS relay: SMS_RELAY_PLATFORM was read but never sent, so the gateway
never got `platform` (documented body: phone, host, text, platform).
relayConfig() now returns it ("default" → null) and the request carries
it. sms.service.spec now passes.
All 249 API tests pass; the three front-ends type-check and build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
-- Accounting integration (D27): Tahesab / Zarrin, chosen in the admin panel.
|
||||
-- Plan: docs/ACCOUNTING-INTEGRATION-PLAN.md.
|
||||
--
|
||||
-- Adds the link table (app row <-> accounting record), the outbox the push
|
||||
-- jobs run from, the issue list pulls report into, and the cached balance and
|
||||
-- stock columns read back from the accounting software. Nothing existing
|
||||
-- changes meaning: goldDebtGrams/fiatDebtRial stay the internal ledger caches.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AccountingEntityType" AS ENUM ('CUSTOMER', 'PRODUCT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AccountingOutboxKind" AS ENUM ('CUSTOMER_PUSH', 'PRODUCT_PUSH', 'ORDER_DOCUMENT', 'ORDER_DOCUMENT_DELETE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AccountingOutboxStatus" AS ENUM ('PENDING', 'SENDING', 'SENT', 'FAILED', 'NEEDS_REVIEW', 'RESOLVED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Customer" ADD COLUMN "accountingBalanceAt" TIMESTAMP(3),
|
||||
ADD COLUMN "accountingGoldBalance" DECIMAL(14,4),
|
||||
ADD COLUMN "accountingRialBalance" DECIMAL(20,2);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "accountingStockAt" TIMESTAMP(3),
|
||||
ADD COLUMN "accountingStockCount" DECIMAL(14,4),
|
||||
ADD COLUMN "accountingStockWeight" DECIMAL(14,4);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AccountingLink" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"entityType" "AccountingEntityType" NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"externalCode" TEXT NOT NULL,
|
||||
"externalName" TEXT,
|
||||
"origin" TEXT NOT NULL,
|
||||
"snapshot" JSONB,
|
||||
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AccountingLink_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AccountingOutbox" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"kind" "AccountingOutboxKind" NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"status" "AccountingOutboxStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastError" TEXT,
|
||||
"externalRef" TEXT,
|
||||
"progress" JSONB,
|
||||
"lastRequest" JSONB,
|
||||
"lastResponse" JSONB,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"resolvedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AccountingOutbox_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AccountingIssue" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"externalCode" TEXT NOT NULL,
|
||||
"entityId" TEXT,
|
||||
"message" TEXT NOT NULL,
|
||||
"data" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "AccountingIssue_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingLink_entityType_entityId_idx" ON "AccountingLink"("entityType", "entityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccountingLink_provider_entityType_entityId_key" ON "AccountingLink"("provider", "entityType", "entityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccountingLink_provider_entityType_externalCode_key" ON "AccountingLink"("provider", "entityType", "externalCode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingOutbox_status_nextAttemptAt_idx" ON "AccountingOutbox"("status", "nextAttemptAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingOutbox_kind_entityId_idx" ON "AccountingOutbox"("kind", "entityId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AccountingIssue_resolvedAt_idx" ON "AccountingIssue"("resolvedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AccountingIssue_provider_kind_externalCode_key" ON "AccountingIssue"("provider", "kind", "externalCode");
|
||||
|
||||
|
||||
-- The new admin screen's permission. The seeded super-admin user type (85,
|
||||
-- prisma/seed.ts) gets every rid in ALL_RIDS on a fresh seed; an existing
|
||||
-- database has to be granted the new one here.
|
||||
INSERT INTO "permission" ("rid", "userTypeId")
|
||||
SELECT 'config.accounting', 85
|
||||
WHERE EXISTS (SELECT 1 FROM "user_type" WHERE "id" = 85)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,210 @@
|
||||
-- ============================================================================
|
||||
-- Preparation for the legacy data migration — PURELY ADDITIVE.
|
||||
--
|
||||
-- Owner decisions of 2026-10-06 and 2026-10-07, recorded in
|
||||
-- docs/DATA-MIGRATION-MAP.md §5.2, §5.5, §10, §10.4, §10.5.5.
|
||||
--
|
||||
-- Nothing here drops, renames or retypes an existing column. Every statement
|
||||
-- either removes a constraint or adds a new column/table, so code that works
|
||||
-- today keeps working. Verified NOT touched:
|
||||
-- * Customer.accounting{Gold,Rial}Balance / accountingBalanceAt (D27)
|
||||
-- * Product.accountingStock{Weight,Count} / accountingStockAt (D27)
|
||||
-- * ProductVariant.imageId (PROTECTED.md §B)
|
||||
-- * AccountingLink / AccountingOutbox / AccountingIssue
|
||||
-- * SmsMessage and the SMS module
|
||||
-- These are target-only capabilities under PROTECTED.md §G ("a keep-out
|
||||
-- list"), and the owner confirmed on 2026-10-06 that their data is real.
|
||||
--
|
||||
-- D15: idempotent (IF NOT EXISTS / duplicate_object guards) because this runs
|
||||
-- under BOTH ledgers — the Prisma CLI in development and the app's own runner
|
||||
-- (src/prisma/migrate.ts) in the deploy container, which wraps each file in a
|
||||
-- transaction. The runner's splitSqlStatements() handles $$-quoted blocks
|
||||
-- (migrate.ts:83-91), so the DO blocks below are split correctly.
|
||||
-- ============================================================================
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 1. Customer.nationalCode becomes OPTIONAL
|
||||
--
|
||||
-- Source reality (docs/migration-precheck.sql, run 2026-10-06 on the live
|
||||
-- `oapi` MySQL schema): empty_ncode = 1595 of n_customers = 1595. NOT ONE of
|
||||
-- the 1,616 legacy customers has a national code. dup_ncode_groups = 0.
|
||||
--
|
||||
-- Owner ruling 2026-10-07: "ساختن کد ملی فیک تخلف دیتایی است و مانع از
|
||||
-- احراز هویتهای قانونی بعدی میشود" — fabricating one is a data violation, so
|
||||
-- the column must be nullable. Without this the customer migration cannot
|
||||
-- insert a single row.
|
||||
--
|
||||
-- This also RETIRES three existing workarounds that only exist because the
|
||||
-- column is required. They write the MOBILE NUMBER into the national-code
|
||||
-- field, i.e. they store wrong data today:
|
||||
-- * admin-customers.service.ts:625 nationalCode: mobile
|
||||
-- comment: "schema requires it and it is unique; singleCustomer.vue
|
||||
-- collects no ncode"
|
||||
-- * admin-customers.service.ts:685 nationalCode: nationalCode || mobile
|
||||
-- * customer-auth.service.ts:258 ... || admin.mobile
|
||||
-- Removing those fallbacks is a SERVICE-LAYER change, deliberately NOT done
|
||||
-- here: this migration only makes it possible. Until they are removed the
|
||||
-- behaviour is exactly as it is today.
|
||||
--
|
||||
-- The UNIQUE index "Customer_nationalCode_key" (first/migration.sql:304) is
|
||||
-- left in place. In PostgreSQL NULLs are not equal to each other, so any
|
||||
-- number of rows may hold NULL under a unique index — which is precisely why
|
||||
-- the migration tool must write NULL and never the empty string: '' would
|
||||
-- collide on the second row (see §10.4).
|
||||
--
|
||||
-- DROP NOT NULL takes only a brief ACCESS EXCLUSIVE lock on the catalog; it
|
||||
-- does not rewrite the table and does not scan the data.
|
||||
ALTER TABLE "Customer" ALTER COLUMN "nationalCode" DROP NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN "Customer"."nationalCode" IS
|
||||
'Legacy customers.ncode. OPTIONAL: no legacy customer has one (1595/1595 empty as of 2026-10-06), and fabricating a value is a data violation (owner, 2026-10-07). Still UNIQUE — write NULL, never '''', because NULLs do not collide under a unique index but empty strings do.';
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 2. Customer.birthDate — new, nullable, unread until backfilled
|
||||
--
|
||||
-- Legacy `customers.birth_date timestamp NULL` (oapi dump line 484), populated
|
||||
-- on 917 of 1,595 live rows (~57%). Owner ruling 2026-10-07: migrate it —
|
||||
-- "نرخ پرشدگی بالای ۵۵٪ است و داده برای کمپینهای وفاداری و پیامکی کاربرد
|
||||
-- مستقیم دارد".
|
||||
--
|
||||
-- Two sibling columns were deliberately NOT ported, same ruling:
|
||||
-- * marriage_date (5 rows) — "۵ ردیف توجیه معماری ندارد"
|
||||
-- * token_mobile (99 rows) — "توکنها اغلب منقضی شدهاند · زباله دیتایی"
|
||||
--
|
||||
-- ⚠ THE COLUMN IS CREATED EMPTY ON PURPOSE. Do not backfill it until the
|
||||
-- calendar question is settled (§5.9): legacy stores some dates as JALALI
|
||||
-- STRINGS elsewhere (cashes.recive_date is varchar(255), 88 of 89 values
|
||||
-- starting "14…"), so birth_date may hold Jalali years mis-entered into a
|
||||
-- timestamp. Copying those unchanged would silently produce 917 WRONG
|
||||
-- birthdays, which for an SMS campaign is worse than having none.
|
||||
-- docs/migration-precheck-3.sql answers this.
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "birthDate" TIMESTAMP(3);
|
||||
|
||||
COMMENT ON COLUMN "Customer"."birthDate" IS
|
||||
'Legacy customers.birth_date (917 of 1595 rows populated). Created empty: verify Jalali-vs-Gregorian with docs/migration-precheck-3.sql BEFORE backfilling — legacy stores dates as Jalali strings in other tables.';
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 3. Rejection trail for document verification
|
||||
--
|
||||
-- Legacy has THREE states per verification step, not two
|
||||
-- (MajorCustomerController.php:106-135): 1 = awaiting review, 2 = approved,
|
||||
-- 3 = REJECTED. The target's VerificationStep enum has no rejected state, so
|
||||
-- 14 already-rejected customers had nowhere to go.
|
||||
--
|
||||
-- Owner ruling 2026-10-07: mapping them to "awaiting" is "کاملاً مردود" —
|
||||
-- it would put closed cases back in the review queue and create duplicate
|
||||
-- work. REJECTED is added to the enum (statement 4 below) and these two
|
||||
-- columns carry the reason.
|
||||
--
|
||||
-- Legacy records NO reason: all 49 `customers` columns were checked and none
|
||||
-- is a reason/note/description field, and on rejection the server sends only
|
||||
-- a FIXED SMS ("اطلاعات شما تایید نشده است. لطفا با پیشتیبانی تماس حاصل
|
||||
-- فرمایید") with no cause stored anywhere. So for those 14 rows the reason is
|
||||
-- unrecoverable; the owner asked the tool to write an explicit sentinel rather
|
||||
-- than NULL, so support knows WHY it is blank:
|
||||
-- رد شده در سیستم قدیم — فاقد شرح علت ثبتشده
|
||||
-- That text is written by the migration tool, not by this migration, and only
|
||||
-- WHERE verificationRejectionReason IS NULL so a re-run can never overwrite a
|
||||
-- real reason entered in the new system (§5.5).
|
||||
--
|
||||
-- verificationRejectedAt stays NULL for the 14 legacy rows: legacy did not
|
||||
-- record a rejection timestamp either.
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "verificationRejectionReason" TEXT;
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "verificationRejectedAt" TIMESTAMP(3);
|
||||
|
||||
COMMENT ON COLUMN "Customer"."verificationRejectionReason" IS
|
||||
'Why document verification was rejected. Legacy stored no reason (only a fixed SMS), so rows migrated from henza carry the sentinel «رد شده در سیستم قدیم — فاقد شرح علت ثبتشده». Write only WHERE this IS NULL so a re-run never overwrites a real reason.';
|
||||
|
||||
COMMENT ON COLUMN "Customer"."verificationRejectedAt" IS
|
||||
'When verification was rejected. NULL for rows migrated from henza — legacy did not record a rejection timestamp.';
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 4. VerificationStep gains REJECTED
|
||||
--
|
||||
-- ADD VALUE is additive: existing rows and existing queries are unaffected.
|
||||
-- IF NOT EXISTS makes a re-run a no-op.
|
||||
--
|
||||
-- ⚠ CONSUMER AUDIT STILL OWED. Every read of VerificationStep must be checked
|
||||
-- for a Persian label / switch that does not yet know this value, or the admin
|
||||
-- panel will render a blank cell. Flagged in §10.5.5 as a separate task; it is
|
||||
-- a display gap, not a data risk, and nothing writes REJECTED until the
|
||||
-- migration tool runs.
|
||||
--
|
||||
-- Postgres note: before v12 ADD VALUE could not run inside a transaction, and
|
||||
-- the app's own runner wraps each file in one (migrate.ts:198). This project
|
||||
-- targets a modern Postgres where that restriction is lifted. If the deploy
|
||||
-- container ever errors with "ALTER TYPE ... ADD VALUE cannot be executed
|
||||
-- inside a transaction block", this statement must move to its own file.
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TYPE "VerificationStep" ADD VALUE IF NOT EXISTS 'REJECTED';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 5. CustomerFavorite — the legacy `fav` table finally gets a home
|
||||
--
|
||||
-- Legacy `fav` holds 251 rows (customer_id, product_id) and the target had NO
|
||||
-- model for it, so every customer's saved products would have been lost.
|
||||
-- Owner ruling 2026-10-06: they matter. docs/PWA-PORT-PLAN.md reached the same
|
||||
-- conclusion independently.
|
||||
--
|
||||
-- Legacy `fav` has only two columns and NO primary key, so duplicate pairs are
|
||||
-- possible in principle. The composite primary key here closes that, and the
|
||||
-- precheck confirmed fav_duplicate_pairs = 0, so no de-duplication is needed —
|
||||
-- but the tool should still SELECT DISTINCT, since the source cannot enforce it.
|
||||
--
|
||||
-- 14 of the 251 rows point at a customer that no longer exists
|
||||
-- (fav.customer_id orphans). Those must be SKIPPED by the tool; the foreign
|
||||
-- keys below would reject them anyway.
|
||||
--
|
||||
-- ON DELETE CASCADE on both sides: a favourite is a pure join row with no
|
||||
-- meaning once either end is gone. This differs from ProductVariant.imageId,
|
||||
-- which uses SetNull precisely so a deleted image never cascades into order
|
||||
-- history — the distinction is deliberate.
|
||||
--
|
||||
-- Legacy also keeps a denormalised `product.count_fav` counter. It is NOT
|
||||
-- recreated as a column; derive it from this table (§5.2 group B) rather than
|
||||
-- showing a stale or zero value.
|
||||
CREATE TABLE IF NOT EXISTS "CustomerFavorite" (
|
||||
"customerId" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "CustomerFavorite_pkey" PRIMARY KEY ("customerId", "productId")
|
||||
);
|
||||
|
||||
-- The PK already indexes (customerId, productId) left-to-right, so "my
|
||||
-- favourites" needs no extra index. This one serves the reverse lookup —
|
||||
-- "how many customers favourited this product" — which is how count_fav is
|
||||
-- derived.
|
||||
CREATE INDEX IF NOT EXISTS "CustomerFavorite_productId_idx"
|
||||
ON "CustomerFavorite"("productId");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE "CustomerFavorite"
|
||||
ADD CONSTRAINT "CustomerFavorite_customerId_fkey"
|
||||
FOREIGN KEY ("customerId") REFERENCES "Customer"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE "CustomerFavorite"
|
||||
ADD CONSTRAINT "CustomerFavorite_productId_fkey"
|
||||
FOREIGN KEY ("productId") REFERENCES "Product"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
COMMENT ON TABLE "CustomerFavorite" IS
|
||||
'Legacy `fav` (251 rows). Composite PK replaces the source table''s missing primary key. Derive product.count_fav from here rather than storing a counter.';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- ============================================================================
|
||||
-- Customer.cityNameRaw — PURELY ADDITIVE.
|
||||
--
|
||||
-- Owner ruling 2026-10-07 (docs/DATA-MIGRATION-MAP.md §5.9): keep the city a
|
||||
-- customer typed at sign-up verbatim, and set cityId only on an exact match.
|
||||
--
|
||||
-- Legacy `customers.new_city` is free text (all 172 live values are names, 0
|
||||
-- are ids). The legacy city table has only 15 rows, so 43 of those names match
|
||||
-- no known city at all, and only ~72 match one of the 15 after ي/ك
|
||||
-- normalisation. Dropping the text would lose where those customers are.
|
||||
--
|
||||
-- Legacy sign-up also forced city_id = 5 (تهران) on every new customer
|
||||
-- (Major/CustomerController.php:21); 91 of the 92 customers with both values
|
||||
-- carry that forced Tehran. For them the typed name is the truth.
|
||||
--
|
||||
-- D15: idempotent, runs under both migration ledgers.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "cityNameRaw" TEXT;
|
||||
|
||||
COMMENT ON COLUMN "Customer"."cityNameRaw" IS
|
||||
'Legacy customers.new_city — city name as typed at sign-up, kept verbatim. cityId is set too only on an exact (ي/ك-normalised) match; legacy forced city_id=5 (Tehran) on sign-up, so prefer this text over that cityId.';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- ============================================================================
|
||||
-- Customer identity cleanup — data only, no schema change. Idempotent.
|
||||
--
|
||||
-- Owner ruling 2026-10-07: «هر دیتایی باید سر جای خودش بشینه».
|
||||
--
|
||||
-- 1. nationalCode held the MOBILE (or "<mobile>-<provider>-<code>") on every
|
||||
-- customer created without a national code, because the column used to be
|
||||
-- NOT NULL. It is optional since 20261007120000, and the code paths that
|
||||
-- wrote those stand-ins are fixed in the same change as this file. The
|
||||
-- stand-ins are cleared to NULL; a real national code is never touched.
|
||||
-- Accounting is unaffected: it only ever received validNationalCode(),
|
||||
-- which already mapped a mobile to null.
|
||||
--
|
||||
-- 2. passwordHash '' becomes the explicit "no password login" marker
|
||||
-- (auth/password-hash.ts). Both are refused before bcrypt by
|
||||
-- compareLocalHash, so nobody's ability to sign in changes.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE "Customer"
|
||||
SET "nationalCode" = NULL
|
||||
WHERE "nationalCode" = ''
|
||||
OR "nationalCode" = "mobile"
|
||||
OR "nationalCode" LIKE "mobile" || '-%';
|
||||
|
||||
UPDATE "Customer" SET "passwordHash" = '!OTP_ONLY_NO_PASSWORD_LOGIN' WHERE "passwordHash" = '';
|
||||
UPDATE "AdminUser" SET "passwordHash" = '!NO_PASSWORD_SET_YET' WHERE "passwordHash" = '';
|
||||
|
||||
-- The earlier comment named the wrong source table: `fav` belongs to the retail
|
||||
-- shop; wholesale favourites come from `major_favorites` (DATA-MIGRATION-MAP §10.7).
|
||||
COMMENT ON TABLE "CustomerFavorite" IS
|
||||
'Legacy `major_favorites` (wholesale; deduplicated on import). Composite PK replaces the source table''s missing primary key. Derive a favourite count from here rather than storing a counter.';
|
||||
@@ -0,0 +1,47 @@
|
||||
-- ============================================================================
|
||||
-- Order.salesAgentId / Order.referrerCustomerId — additive, idempotent.
|
||||
--
|
||||
-- Owner ruling 2026-10-08: keep legacy's rule exactly. An order is credited to
|
||||
-- the expert who was the customer's referrer WHEN IT WAS PLACED (legacy
|
||||
-- `orders.reagent_id`, set once at creation), so a customer moving to another
|
||||
-- expert later never changes past sales or commission. The app used to credit
|
||||
-- an order to the customer's CURRENT referrer, which disagreed with legacy for
|
||||
-- 8 of 49 experts on the imported data.
|
||||
--
|
||||
-- No row is deleted and no existing column changes.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE "Order" ADD COLUMN IF NOT EXISTS "salesAgentId" TEXT;
|
||||
ALTER TABLE "Order" ADD COLUMN IF NOT EXISTS "referrerCustomerId" TEXT;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Order" ADD CONSTRAINT "Order_salesAgentId_fkey"
|
||||
FOREIGN KEY ("salesAgentId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Order" ADD CONSTRAINT "Order_referrerCustomerId_fkey"
|
||||
FOREIGN KEY ("referrerCustomerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Order_salesAgentId_idx" ON "Order"("salesAgentId");
|
||||
CREATE INDEX IF NOT EXISTS "Order_referrerCustomerId_idx" ON "Order"("referrerCustomerId");
|
||||
|
||||
-- Orders placed in the new app before this column existed: the customer's
|
||||
-- referrer today is the best record there is (these are recent). Orders
|
||||
-- brought over from legacy (orderNumber 'H-<id>') are NOT guessed here — the
|
||||
-- import writes their real `orders.reagent_id`.
|
||||
UPDATE "Order" o
|
||||
SET "salesAgentId" = c."referredByAgentId"
|
||||
FROM "Customer" c
|
||||
WHERE c."id" = o."customerId"
|
||||
AND o."salesAgentId" IS NULL
|
||||
AND c."referredByAgentId" IS NOT NULL
|
||||
AND o."orderNumber" NOT LIKE 'H-%';
|
||||
|
||||
COMMENT ON COLUMN "Order"."salesAgentId" IS
|
||||
'Sales expert credited with this order, fixed when it is placed (legacy orders.reagent_id). Never follows a later change of the customer''s referrer.';
|
||||
COMMENT ON COLUMN "Order"."referrerCustomerId" IS
|
||||
'History only: legacy orders.reagent_id when it named a customer who is not a sales expert. Never set for new orders.';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- «اجرت» admin screen (owner ruling 2026-10-08): its permission for the
|
||||
-- seeded super-admin user type. A fresh seed grants every rid in ALL_RIDS;
|
||||
-- an existing database gets the new one here. Data only, idempotent.
|
||||
-- The formula itself needs no row: until an admin approves one, the server
|
||||
-- uses legacy's formula (pricing/wage-formula.ts LEGACY_WAGE_FORMULA).
|
||||
INSERT INTO "permission" ("rid", "userTypeId")
|
||||
SELECT 'config.wage', 85
|
||||
WHERE EXISTS (SELECT 1 FROM "user_type" WHERE "id" = 85)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- PERM-01 (owner approval 2026-10-08): legacy's two dashboard permissions
|
||||
-- «آمار عمده فروشی» (dashboard.omde) and «آمار تک فروشی» (dashboard.tak), and
|
||||
-- their tree parent "dashboard", became the single "dashboard.view" when the two
|
||||
-- dashboards became one. A database seeded before that change still holds the
|
||||
-- old rows: nothing reads them and the permission screen cannot show or revoke
|
||||
-- them. Whoever held one keeps the dashboard through "dashboard.view", then the
|
||||
-- old rows go. Data only, idempotent.
|
||||
INSERT INTO "permission" ("rid", "userTypeId")
|
||||
SELECT DISTINCT 'dashboard.view', "userTypeId"
|
||||
FROM "permission"
|
||||
WHERE "rid" IN ('dashboard', 'dashboard.omde', 'dashboard.tak')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
DELETE FROM "permission" WHERE "rid" IN ('dashboard', 'dashboard.omde', 'dashboard.tak');
|
||||
@@ -0,0 +1,33 @@
|
||||
-- PERS-02 (owner approval 2026-10-08): cities become a tree again, as in legacy
|
||||
-- (country > province > city > district, henza `cities.parent`). The flat
|
||||
-- `province` label is turned into parent rows, nothing is lost, then dropped.
|
||||
|
||||
ALTER TABLE "City" ADD COLUMN "parentId" TEXT;
|
||||
|
||||
-- One province row per distinct label, named as legacy names them
|
||||
-- («استان تهران»), unless a city row already carries that name.
|
||||
INSERT INTO "City" ("id", "name")
|
||||
SELECT 'cprov' || substr(md5(p.pname), 1, 20), p.pname
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
CASE WHEN btrim("province") LIKE 'استان%' THEN btrim("province") ELSE 'استان ' || btrim("province") END AS pname
|
||||
FROM "City"
|
||||
WHERE "province" IS NOT NULL AND btrim("province") <> ''
|
||||
) p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "City" c WHERE c."name" = p.pname);
|
||||
|
||||
UPDATE "City" c
|
||||
SET "parentId" = (
|
||||
SELECT p."id" FROM "City" p
|
||||
WHERE p."id" <> c."id"
|
||||
AND p."name" = CASE WHEN btrim(c."province") LIKE 'استان%' THEN btrim(c."province") ELSE 'استان ' || btrim(c."province") END
|
||||
ORDER BY p."id"
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE c."province" IS NOT NULL AND btrim(c."province") <> '';
|
||||
|
||||
ALTER TABLE "City" DROP COLUMN "province";
|
||||
|
||||
CREATE INDEX "City_parentId_idx" ON "City"("parentId");
|
||||
|
||||
ALTER TABLE "City" ADD CONSTRAINT "City_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "City"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
Reference in New Issue
Block a user