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:
s.esmaeili
2026-10-10 09:03:53 +03:30
co-authored by Claude Opus 5.5
315 changed files with 82919 additions and 7020 deletions
@@ -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;