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:
+71865
-6435
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
+44
-22
@@ -1607,6 +1607,9 @@ var ALL_RIDS = [
|
||||
"comment.state",
|
||||
"comment.view",
|
||||
"comment.view.edit",
|
||||
// D27 — «اتصال حسابداری» (Tahesab / Zarrin). Not a legacy rid.
|
||||
"config.accounting",
|
||||
"config.wage",
|
||||
"config.report",
|
||||
"config.statics",
|
||||
"config.statics.brand",
|
||||
@@ -1742,28 +1745,7 @@ var PLACEHOLDER_PASSWORD_HASH = hashSync(DEV_PASSWORD, 10);
|
||||
var DEV_TEST_PHONE = "09128081392";
|
||||
var DEV_TEST_PHONE_PASSWORD_HASH = hashSync(DEV_TEST_PHONE, 10);
|
||||
var PLACEHOLDER_PRODUCT_IMAGE = "/img/no-items.png";
|
||||
async function main() {
|
||||
const [tehran, mashhad, isfahan, tabriz] = await Promise.all([
|
||||
prisma.city.upsert({ where: { id: "city-tehran" }, update: {}, create: { id: "city-tehran", name: "\u062A\u0647\u0631\u0627\u0646", province: "\u062A\u0647\u0631\u0627\u0646" } }),
|
||||
prisma.city.upsert({ where: { id: "city-mashhad" }, update: {}, create: { id: "city-mashhad", name: "\u0645\u0634\u0647\u062F", province: "\u062E\u0631\u0627\u0633\u0627\u0646 \u0631\u0636\u0648\u06CC" } }),
|
||||
prisma.city.upsert({ where: { id: "city-isfahan" }, update: {}, create: { id: "city-isfahan", name: "\u0627\u0635\u0641\u0647\u0627\u0646", province: "\u0627\u0635\u0641\u0647\u0627\u0646" } }),
|
||||
prisma.city.upsert({ where: { id: "city-tabriz" }, update: {}, create: { id: "city-tabriz", name: "\u062A\u0628\u0631\u06CC\u0632", province: "\u0622\u0630\u0631\u0628\u0627\u06CC\u062C\u0627\u0646 \u0634\u0631\u0642\u06CC" } })
|
||||
]);
|
||||
const branchTehran = await prisma.branch.upsert({
|
||||
where: { id: "branch-tehran-bazaar" },
|
||||
update: {},
|
||||
create: { id: "branch-tehran-bazaar", name: "\u0634\u0639\u0628\u0647 \u0628\u0627\u0632\u0627\u0631 \u0628\u0632\u0631\u06AF \u062A\u0647\u0631\u0627\u0646", cityId: tehran.id }
|
||||
});
|
||||
const branchMashhad = await prisma.branch.upsert({
|
||||
where: { id: "branch-mashhad-main" },
|
||||
update: {},
|
||||
create: { id: "branch-mashhad-main", name: "\u0634\u0639\u0628\u0647 \u0645\u0631\u06A9\u0632\u06CC \u0645\u0634\u0647\u062F", cityId: mashhad.id }
|
||||
});
|
||||
await prisma.workshop.upsert({
|
||||
where: { id: "workshop-central" },
|
||||
update: {},
|
||||
create: { id: "workshop-central", name: "\u06A9\u0627\u0631\u06AF\u0627\u0647 \u0645\u0631\u06A9\u0632\u06CC \u0633\u0627\u062E\u062A \u0637\u0644\u0627" }
|
||||
});
|
||||
async function seedAccessControl() {
|
||||
const userTypes = [
|
||||
// Full access — every rid the routers gate.
|
||||
{ id: 85, title: "\u0645\u062F\u06CC\u0631 \u0627\u0631\u0634\u062F", rids: ALL_RIDS },
|
||||
@@ -1824,6 +1806,46 @@ async function main() {
|
||||
skipDuplicates: true
|
||||
});
|
||||
}
|
||||
}
|
||||
function demoDataAllowed() {
|
||||
if (process.env.SEED_DEMO_DATA?.trim() === "true") return true;
|
||||
return process.env.NODE_ENV !== "production";
|
||||
}
|
||||
async function main() {
|
||||
await seedAccessControl();
|
||||
if (!demoDataAllowed()) {
|
||||
console.log("seed: production \u2014 roles and permissions only; demo data skipped (set SEED_DEMO_DATA=true to force)");
|
||||
return;
|
||||
}
|
||||
const city = (id, name, parentId) => prisma.city.upsert({ where: { id }, update: {}, create: { id, name, parentId } });
|
||||
await city("city-iran", "\u0627\u06CC\u0631\u0627\u0646", null);
|
||||
await Promise.all([
|
||||
city("city-prov-tehran", "\u0627\u0633\u062A\u0627\u0646 \u062A\u0647\u0631\u0627\u0646", "city-iran"),
|
||||
city("city-prov-khorasan-razavi", "\u0627\u0633\u062A\u0627\u0646 \u062E\u0631\u0627\u0633\u0627\u0646 \u0631\u0636\u0648\u06CC", "city-iran"),
|
||||
city("city-prov-isfahan", "\u0627\u0633\u062A\u0627\u0646 \u0627\u0635\u0641\u0647\u0627\u0646", "city-iran"),
|
||||
city("city-prov-east-azarbaijan", "\u0627\u0633\u062A\u0627\u0646 \u0622\u0630\u0631\u0628\u0627\u06CC\u062C\u0627\u0646 \u0634\u0631\u0642\u06CC", "city-iran")
|
||||
]);
|
||||
const [tehran, mashhad, isfahan, tabriz] = await Promise.all([
|
||||
city("city-tehran", "\u062A\u0647\u0631\u0627\u0646", "city-prov-tehran"),
|
||||
city("city-mashhad", "\u0645\u0634\u0647\u062F", "city-prov-khorasan-razavi"),
|
||||
city("city-isfahan", "\u0627\u0635\u0641\u0647\u0627\u0646", "city-prov-isfahan"),
|
||||
city("city-tabriz", "\u062A\u0628\u0631\u06CC\u0632", "city-prov-east-azarbaijan")
|
||||
]);
|
||||
const branchTehran = await prisma.branch.upsert({
|
||||
where: { id: "branch-tehran-bazaar" },
|
||||
update: {},
|
||||
create: { id: "branch-tehran-bazaar", name: "\u0634\u0639\u0628\u0647 \u0628\u0627\u0632\u0627\u0631 \u0628\u0632\u0631\u06AF \u062A\u0647\u0631\u0627\u0646", cityId: tehran.id }
|
||||
});
|
||||
const branchMashhad = await prisma.branch.upsert({
|
||||
where: { id: "branch-mashhad-main" },
|
||||
update: {},
|
||||
create: { id: "branch-mashhad-main", name: "\u0634\u0639\u0628\u0647 \u0645\u0631\u06A9\u0632\u06CC \u0645\u0634\u0647\u062F", cityId: mashhad.id }
|
||||
});
|
||||
await prisma.workshop.upsert({
|
||||
where: { id: "workshop-central" },
|
||||
update: {},
|
||||
create: { id: "workshop-central", name: "\u06A9\u0627\u0631\u06AF\u0627\u0647 \u0645\u0631\u06A9\u0632\u06CC \u0633\u0627\u062E\u062A \u0637\u0644\u0627" }
|
||||
});
|
||||
await prisma.adminUser.upsert({
|
||||
where: { mobile: DEV_TEST_PHONE },
|
||||
update: { passwordHash: DEV_TEST_PHONE_PASSWORD_HASH, userTypeId: 85, branchId: null },
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class PrismaClient {
|
||||
constructor() {
|
||||
throw new Error(
|
||||
'@prisma/client/deno/edge did not initialize yet. Please run "prisma generate" and try to import it again.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export { PrismaClient }
|
||||
+102
-8
File diff suppressed because one or more lines are too long
+97
-3
@@ -125,7 +125,7 @@ exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
||||
exports.Prisma.CityScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
province: 'province',
|
||||
parentId: 'parentId',
|
||||
percent: 'percent',
|
||||
percentAux: 'percentAux'
|
||||
};
|
||||
@@ -203,6 +203,7 @@ exports.Prisma.CustomerScalarFieldEnum = {
|
||||
nationalCode: 'nationalCode',
|
||||
passwordHash: 'passwordHash',
|
||||
cityId: 'cityId',
|
||||
cityNameRaw: 'cityNameRaw',
|
||||
kind: 'kind',
|
||||
phone: 'phone',
|
||||
address: 'address',
|
||||
@@ -214,11 +215,17 @@ exports.Prisma.CustomerScalarFieldEnum = {
|
||||
goldCreditLimitGrams: 'goldCreditLimitGrams',
|
||||
goldDebtGrams: 'goldDebtGrams',
|
||||
fiatDebtRial: 'fiatDebtRial',
|
||||
accountingGoldBalance: 'accountingGoldBalance',
|
||||
accountingRialBalance: 'accountingRialBalance',
|
||||
accountingBalanceAt: 'accountingBalanceAt',
|
||||
makingChargeOffsetPercent: 'makingChargeOffsetPercent',
|
||||
verificationStep: 'verificationStep',
|
||||
nationalIdImageUrl: 'nationalIdImageUrl',
|
||||
businessLicenseUrl: 'businessLicenseUrl',
|
||||
shopImageUrl: 'shopImageUrl',
|
||||
verificationRejectionReason: 'verificationRejectionReason',
|
||||
verificationRejectedAt: 'verificationRejectedAt',
|
||||
birthDate: 'birthDate',
|
||||
avatarFileId: 'avatarFileId',
|
||||
canPlaceOrders: 'canPlaceOrders',
|
||||
isActive: 'isActive',
|
||||
@@ -227,6 +234,12 @@ exports.Prisma.CustomerScalarFieldEnum = {
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.CustomerFavoriteScalarFieldEnum = {
|
||||
customerId: 'customerId',
|
||||
productId: 'productId',
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.CustomerAddressScalarFieldEnum = {
|
||||
id: 'id',
|
||||
customerId: 'customerId',
|
||||
@@ -290,7 +303,10 @@ exports.Prisma.ProductScalarFieldEnum = {
|
||||
productCode: 'productCode',
|
||||
showType: 'showType',
|
||||
isActive: 'isActive',
|
||||
createdAt: 'createdAt'
|
||||
createdAt: 'createdAt',
|
||||
accountingStockWeight: 'accountingStockWeight',
|
||||
accountingStockCount: 'accountingStockCount',
|
||||
accountingStockAt: 'accountingStockAt'
|
||||
};
|
||||
|
||||
exports.Prisma.ProductImageScalarFieldEnum = {
|
||||
@@ -326,6 +342,8 @@ exports.Prisma.OrderScalarFieldEnum = {
|
||||
flatRialCharge: 'flatRialCharge',
|
||||
isSuspendedOverCreditLimit: 'isSuspendedOverCreditLimit',
|
||||
deliveredAt: 'deliveredAt',
|
||||
salesAgentId: 'salesAgentId',
|
||||
referrerCustomerId: 'referrerCustomerId',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
@@ -453,6 +471,51 @@ exports.Prisma.SettingScalarFieldEnum = {
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.AccountingLinkScalarFieldEnum = {
|
||||
id: 'id',
|
||||
provider: 'provider',
|
||||
entityType: 'entityType',
|
||||
entityId: 'entityId',
|
||||
externalCode: 'externalCode',
|
||||
externalName: 'externalName',
|
||||
origin: 'origin',
|
||||
snapshot: 'snapshot',
|
||||
syncedAt: 'syncedAt',
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.AccountingOutboxScalarFieldEnum = {
|
||||
id: 'id',
|
||||
provider: 'provider',
|
||||
kind: 'kind',
|
||||
entityId: 'entityId',
|
||||
status: 'status',
|
||||
attempts: 'attempts',
|
||||
nextAttemptAt: 'nextAttemptAt',
|
||||
lastError: 'lastError',
|
||||
externalRef: 'externalRef',
|
||||
progress: 'progress',
|
||||
lastRequest: 'lastRequest',
|
||||
lastResponse: 'lastResponse',
|
||||
sentAt: 'sentAt',
|
||||
resolvedById: 'resolvedById',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.AccountingIssueScalarFieldEnum = {
|
||||
id: 'id',
|
||||
provider: 'provider',
|
||||
kind: 'kind',
|
||||
externalCode: 'externalCode',
|
||||
entityId: 'entityId',
|
||||
message: 'message',
|
||||
data: 'data',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
resolvedAt: 'resolvedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.PromotionScalarFieldEnum = {
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
@@ -567,6 +630,11 @@ exports.Prisma.JsonNullValueInput = {
|
||||
JsonNull: Prisma.JsonNull
|
||||
};
|
||||
|
||||
exports.Prisma.NullableJsonNullValueInput = {
|
||||
DbNull: Prisma.DbNull,
|
||||
JsonNull: Prisma.JsonNull
|
||||
};
|
||||
|
||||
exports.Prisma.QueryMode = {
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
@@ -603,7 +671,8 @@ exports.VerificationStep = exports.$Enums.VerificationStep = {
|
||||
UNVERIFIED: 'UNVERIFIED',
|
||||
STEP_ONE_SUBMITTED: 'STEP_ONE_SUBMITTED',
|
||||
STEP_TWO_SUBMITTED: 'STEP_TWO_SUBMITTED',
|
||||
VERIFIED: 'VERIFIED'
|
||||
VERIFIED: 'VERIFIED',
|
||||
REJECTED: 'REJECTED'
|
||||
};
|
||||
|
||||
exports.ProductImageRole = exports.$Enums.ProductImageRole = {
|
||||
@@ -665,6 +734,27 @@ exports.CommentStatus = exports.$Enums.CommentStatus = {
|
||||
REJECTED: 'REJECTED'
|
||||
};
|
||||
|
||||
exports.AccountingEntityType = exports.$Enums.AccountingEntityType = {
|
||||
CUSTOMER: 'CUSTOMER',
|
||||
PRODUCT: 'PRODUCT'
|
||||
};
|
||||
|
||||
exports.AccountingOutboxKind = exports.$Enums.AccountingOutboxKind = {
|
||||
CUSTOMER_PUSH: 'CUSTOMER_PUSH',
|
||||
PRODUCT_PUSH: 'PRODUCT_PUSH',
|
||||
ORDER_DOCUMENT: 'ORDER_DOCUMENT',
|
||||
ORDER_DOCUMENT_DELETE: 'ORDER_DOCUMENT_DELETE'
|
||||
};
|
||||
|
||||
exports.AccountingOutboxStatus = exports.$Enums.AccountingOutboxStatus = {
|
||||
PENDING: 'PENDING',
|
||||
SENDING: 'SENDING',
|
||||
SENT: 'SENT',
|
||||
FAILED: 'FAILED',
|
||||
NEEDS_REVIEW: 'NEEDS_REVIEW',
|
||||
RESOLVED: 'RESOLVED'
|
||||
};
|
||||
|
||||
exports.PromotionScope = exports.$Enums.PromotionScope = {
|
||||
PRODUCT: 'PRODUCT',
|
||||
CATEGORY: 'CATEGORY',
|
||||
@@ -693,6 +783,7 @@ exports.Prisma.ModelName = {
|
||||
AdminSession: 'AdminSession',
|
||||
SalesExpertProfile: 'SalesExpertProfile',
|
||||
Customer: 'Customer',
|
||||
CustomerFavorite: 'CustomerFavorite',
|
||||
CustomerAddress: 'CustomerAddress',
|
||||
Branch: 'Branch',
|
||||
Workshop: 'Workshop',
|
||||
@@ -715,6 +806,9 @@ exports.Prisma.ModelName = {
|
||||
Permission: 'Permission',
|
||||
Comment: 'Comment',
|
||||
Setting: 'Setting',
|
||||
AccountingLink: 'AccountingLink',
|
||||
AccountingOutbox: 'AccountingOutbox',
|
||||
AccountingIssue: 'AccountingIssue',
|
||||
Promotion: 'Promotion',
|
||||
PromotionExclusion: 'PromotionExclusion',
|
||||
BranchAllocation: 'BranchAllocation',
|
||||
|
||||
+8686
-172
File diff suppressed because it is too large
Load Diff
+102
-8
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-e72c5c915e96796902913fde6ec160a337ad8fec683314696d8bd7a12ac26468",
|
||||
"name": "prisma-client-9161dfb7f2a3ada2b72c27c7a91664048e7f0e5d5d265a0abff1e443711c8388",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "index-browser.js",
|
||||
|
||||
+233
-2
@@ -56,6 +56,13 @@ enum VerificationStep {
|
||||
STEP_ONE_SUBMITTED
|
||||
STEP_TWO_SUBMITTED
|
||||
VERIFIED
|
||||
|
||||
/// Legacy had THREE states per step, not two
|
||||
/// (MajorCustomerController.php:106-135): 1 = awaiting, 2 = approved,
|
||||
/// 3 = REJECTED. Mapping rejected customers to UNVERIFIED would put closed
|
||||
/// cases back in the review queue, so the owner ruled it out (2026-10-07).
|
||||
/// 14 legacy rows land here; see Customer.verificationRejectionReason.
|
||||
REJECTED
|
||||
}
|
||||
|
||||
/// Legacy kept retail and wholesale orders in two SEPARATE tables
|
||||
@@ -111,10 +118,15 @@ enum LedgerSourceType {
|
||||
// Identity
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Legacy `cities` is a tree (country > province > city > district) and so is
|
||||
/// this: a city hangs under its `parent`. In the admin pickers a top-level row
|
||||
/// (the country) is a heading, not selectable (henza_admin citiesModel.js:38).
|
||||
model City {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
province String?
|
||||
parentId String?
|
||||
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||
children City[] @relation("CityTree")
|
||||
/// Legacy cities.percent / percent_aux — per-city wage surcharges the city
|
||||
/// screen edits through GET cities/get-percent/{id} and get-percent-aux/{id}.
|
||||
percent Decimal? @db.Decimal(6, 3)
|
||||
@@ -124,6 +136,8 @@ model City {
|
||||
branches Branch[]
|
||||
preOrders PreOrder[]
|
||||
salesExpertProfiles SalesExpertProfile[]
|
||||
|
||||
@@index([parentId])
|
||||
}
|
||||
|
||||
model AdminUser {
|
||||
@@ -172,6 +186,8 @@ model AdminUser {
|
||||
memberCode String? // legacy mcode
|
||||
|
||||
referredCustomers Customer[] @relation("ReferralAgent")
|
||||
/// Orders credited to this sales expert — see Order.salesAgentId.
|
||||
creditedOrders Order[] @relation("OrderSalesAgent")
|
||||
supervisedWorkshops Workshop[] @relation("WorkshopSupervisor")
|
||||
branchAllocations BranchAllocation[] @relation("AllocationAuthor")
|
||||
postedLedgerEntries LedgerEntry[]
|
||||
@@ -367,11 +383,32 @@ model Customer {
|
||||
lastName String?
|
||||
|
||||
mobile String @unique
|
||||
nationalCode String @unique
|
||||
/// Legacy customers.ncode. OPTIONAL since 2026-10-07: not one of the 1,616
|
||||
/// legacy customers has a national code (1595/1595 empty), and fabricating
|
||||
/// one is a data violation that would block later legal identity checks
|
||||
/// (owner ruling). Still @unique — in PostgreSQL NULLs do not collide, so
|
||||
/// the migration tool must write NULL and never '', which would collide on
|
||||
/// the second row.
|
||||
///
|
||||
/// Three service-layer workarounds exist ONLY because this used to be
|
||||
/// required, and they store the MOBILE NUMBER here:
|
||||
/// admin-customers.service.ts:625,685 and customer-auth.service.ts:258.
|
||||
/// Retiring them is a separate change; see docs/DATA-MIGRATION-MAP.md §10.4.
|
||||
nationalCode String? @unique
|
||||
passwordHash String
|
||||
cityId String?
|
||||
city City? @relation(fields: [cityId], references: [id])
|
||||
|
||||
/// Legacy customers.new_city — the city name exactly as the customer typed it
|
||||
/// at app sign-up (Major/CustomerController.php:36). Kept verbatim because
|
||||
/// the legacy city table has only 15 rows, so the typed name is often the only
|
||||
/// record of where the customer is (172 customers; 43 match no known city).
|
||||
/// cityId is set alongside it only on an exact, ي/ك-normalised name match.
|
||||
/// Legacy sign-up also forced city_id = 5 (Tehran) on everyone
|
||||
/// (Major/CustomerController.php:21), so for those rows the typed name, not
|
||||
/// cityId, is the truth. Owner ruling 2026-10-07: keep both.
|
||||
cityNameRaw String?
|
||||
|
||||
/// Legacy Customer.type — distinguishes بنکدار from مغازه دار, which the
|
||||
/// BankerList / ShopkeeperList screens filter on.
|
||||
kind CustomerKind @default(APP_SHOPKEEPER)
|
||||
@@ -393,6 +430,9 @@ model Customer {
|
||||
/// account (see SalesExpertProfile.tradingCustomerId). Empty for every
|
||||
/// ordinary customer.
|
||||
salesExpertTradingFor SalesExpertProfile? @relation("SalesExpertTradingAccount")
|
||||
/// Legacy orders that named THIS customer as their referrer — see
|
||||
/// Order.referrerCustomerId. History only.
|
||||
referredOrders Order[] @relation("OrderReferrerCustomer")
|
||||
|
||||
// Credit limit + cached ledger balances (source of truth is LedgerEntry; these
|
||||
// are denormalized for fast reads and reconciled by a scheduled job).
|
||||
@@ -400,6 +440,14 @@ model Customer {
|
||||
goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain
|
||||
fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_remain
|
||||
|
||||
/// Balance as last read from the accounting software (D27), in the app's own
|
||||
/// sign convention (positive = owes). Shown to the customer when the
|
||||
/// integration is on; the credit-limit check still uses goldDebtGrams above.
|
||||
/// Null until the first successful sync.
|
||||
accountingGoldBalance Decimal? @db.Decimal(14, 4)
|
||||
accountingRialBalance Decimal? @db.Decimal(20, 2)
|
||||
accountingBalanceAt DateTime?
|
||||
|
||||
/// Legacy `customers.percent` — the per-customer making-charge offset. It is
|
||||
/// ADDED to the variant's making charge when pricing an order line
|
||||
/// (henza_pwa src/Mixins/product/index.ts:69-75:
|
||||
@@ -421,6 +469,30 @@ model Customer {
|
||||
businessLicenseUrl String?
|
||||
shopImageUrl String?
|
||||
|
||||
/// Why verification was rejected (VerificationStep.REJECTED).
|
||||
///
|
||||
/// Legacy recorded NO reason: none of its 49 `customers` columns is a
|
||||
/// reason/note field, and on rejection it only sent a fixed SMS. So the 14
|
||||
/// rows migrated from henza carry the sentinel
|
||||
/// «رد شده در سیستم قدیم — فاقد شرح علت ثبتشده» instead of NULL, so support
|
||||
/// knows why it is blank (owner ruling 2026-10-07). The migration tool
|
||||
/// writes it only WHERE this IS NULL, so a re-run cannot overwrite a real
|
||||
/// reason entered in the new system.
|
||||
verificationRejectionReason String?
|
||||
|
||||
/// NULL for rows migrated from henza — legacy recorded no rejection time.
|
||||
verificationRejectedAt DateTime?
|
||||
|
||||
/// Legacy customers.birth_date, populated on 917 of 1,595 rows. Kept for the
|
||||
/// birthday SMS / loyalty campaigns (owner ruling 2026-10-07).
|
||||
///
|
||||
/// ⚠ Legacy stores dates as JALALI STRINGS in other tables
|
||||
/// (cashes.recive_date is varchar, 88/89 values starting "14…"), so this
|
||||
/// timestamp may hold mis-entered Jalali years. Confirm the calendar with
|
||||
/// docs/migration-precheck-3.sql BEFORE backfilling: copying them unchanged
|
||||
/// would produce 917 wrong birthdays, worse for an SMS campaign than none.
|
||||
birthDate DateTime?
|
||||
|
||||
/// Legacy `image_id` — the profile photo (عکس پروفایل) on the sales-agent
|
||||
/// form. Legacy stored an FK to its Image table and read it back as a nested
|
||||
/// `image: { id, image }`, so this is a relation, not a bare URL string.
|
||||
@@ -449,12 +521,35 @@ model Customer {
|
||||
notifications NotificationRecipient[]
|
||||
activityLogs CustomerActivityLog[]
|
||||
otpChallenges CustomerOtp[]
|
||||
favorites CustomerFavorite[]
|
||||
|
||||
@@index([referredByAgentId])
|
||||
@@index([firstName])
|
||||
@@index([lastName])
|
||||
}
|
||||
|
||||
/// Legacy `fav` (251 rows) — saved products. The target had no model for this,
|
||||
/// so every customer's favourites would have been lost in the migration; the
|
||||
/// owner confirmed on 2026-10-06 that they matter.
|
||||
///
|
||||
/// Legacy `fav` has two columns and NO primary key. The composite key here
|
||||
/// supplies the constraint the source lacks (the precheck found 0 duplicate
|
||||
/// pairs, but the tool should still SELECT DISTINCT). 14 of the 251 rows point
|
||||
/// at a deleted customer and must be skipped.
|
||||
///
|
||||
/// Legacy's denormalised `product.count_fav` counter is deliberately NOT
|
||||
/// recreated as a column — derive it from this table.
|
||||
model CustomerFavorite {
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@id([customerId, productId])
|
||||
@@index([productId])
|
||||
}
|
||||
|
||||
model CustomerAddress {
|
||||
id String @id @default(cuid())
|
||||
customerId String
|
||||
@@ -557,12 +652,21 @@ model Product {
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
/// Stock of the linked کارساخته as last read from the accounting software
|
||||
/// (D27). Weight for gram-type items, count for count-type ones — whichever
|
||||
/// the software reports. At zero or below the product is unavailable in the
|
||||
/// app (pre-order variants excepted). Null = not linked or never synced.
|
||||
accountingStockWeight Decimal? @db.Decimal(14, 4)
|
||||
accountingStockCount Decimal? @db.Decimal(14, 4)
|
||||
accountingStockAt DateTime?
|
||||
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
collections CollectionProduct[]
|
||||
comments Comment[]
|
||||
promotions Promotion[]
|
||||
allocationItems BranchAllocationItem[]
|
||||
favoritedBy CustomerFavorite[]
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
@@ -644,6 +748,22 @@ model Order {
|
||||
isSuspendedOverCreditLimit Boolean @default(false)
|
||||
deliveredAt DateTime? // setting this is what triggers debt recognition
|
||||
|
||||
/// The sales expert this order is credited to, fixed when it is placed:
|
||||
/// the customer's referring expert AT THAT MOMENT. Legacy `orders.reagent_id`,
|
||||
/// set once at creation (ApiMobileController.php:601) and never moved, so a
|
||||
/// customer changing expert later does not move past sales or commission
|
||||
/// (owner ruling 2026-10-08). «میزان فروش» counts from here, never from the
|
||||
/// customer's current referrer.
|
||||
salesAgentId String?
|
||||
salesAgent AdminUser? @relation("OrderSalesAgent", fields: [salesAgentId], references: [id], onDelete: SetNull)
|
||||
|
||||
/// History only: a legacy order whose `reagent_id` named a customer who is
|
||||
/// not a sales expert (one such customer, 17 orders in 2022). Legacy counted
|
||||
/// those orders in that customer's own «میزان فروش»; this keeps that figure
|
||||
/// exact. Never set for a new order.
|
||||
referrerCustomerId String?
|
||||
referrerCustomer Customer? @relation("OrderReferrerCustomer", fields: [referrerCustomerId], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -654,6 +774,8 @@ model Order {
|
||||
|
||||
@@index([customerId])
|
||||
@@index([status])
|
||||
@@index([salesAgentId])
|
||||
@@index([referrerCustomerId])
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
@@ -911,6 +1033,115 @@ model Setting {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Accounting integration (D27) — the shop's accounting software, Tahesab or
|
||||
// Zarrin, chosen in the admin panel. Configuration lives in the Setting row
|
||||
// `accounting.config` (never served by the generic `const` routes). Plan:
|
||||
// docs/ACCOUNTING-INTEGRATION-PLAN.md.
|
||||
//
|
||||
// `provider` is the driver name ('tahesab' | 'zarrin'), a plain string like
|
||||
// SMS_PROVIDER so a new driver needs no migration. Links are per provider, so
|
||||
// switching provider and back loses nothing.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum AccountingEntityType {
|
||||
CUSTOMER
|
||||
PRODUCT
|
||||
}
|
||||
|
||||
/// One app row ↔ one record in the accounting software (customer account or
|
||||
/// کارساخته).
|
||||
model AccountingLink {
|
||||
id String @id @default(cuid())
|
||||
provider String
|
||||
entityType AccountingEntityType
|
||||
entityId String
|
||||
/// The record's code in the accounting software (Tahesab Moshtari_Code /
|
||||
/// Zarrin idHesab; for products the Zarrin idKar or the Tahesab Name_Kar).
|
||||
externalCode String
|
||||
externalName String?
|
||||
/// How the link came to be: LINKED (matched by mobile/name), IMPORTED (app
|
||||
/// row created from the accounting record), PUSHED (accounting record created
|
||||
/// from the app row) or MANUAL (an admin typed the code).
|
||||
origin String
|
||||
/// The record as last read, for display only.
|
||||
snapshot Json?
|
||||
syncedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([provider, entityType, entityId])
|
||||
@@unique([provider, entityType, externalCode])
|
||||
@@index([entityType, entityId])
|
||||
}
|
||||
|
||||
enum AccountingOutboxKind {
|
||||
CUSTOMER_PUSH
|
||||
PRODUCT_PUSH
|
||||
ORDER_DOCUMENT
|
||||
ORDER_DOCUMENT_DELETE
|
||||
}
|
||||
|
||||
enum AccountingOutboxStatus {
|
||||
PENDING
|
||||
/// A call was in flight. Found at startup, it means the process died mid-call
|
||||
/// and the outcome is unknown — see AccountingOutboxWorker.
|
||||
SENDING
|
||||
SENT
|
||||
FAILED
|
||||
/// Unsafe to retry automatically (e.g. a Zarrin call timed out: Zarrin has no
|
||||
/// idempotency key, so a blind retry could post the document twice).
|
||||
NEEDS_REVIEW
|
||||
/// Closed by an admin from the log screen.
|
||||
RESOLVED
|
||||
}
|
||||
|
||||
/// Work to push to the accounting software. Written in the SAME transaction as
|
||||
/// the change that causes it, so an outage of the accounting software never
|
||||
/// loses an event and never blocks the app.
|
||||
model AccountingOutbox {
|
||||
id String @id @default(cuid())
|
||||
provider String
|
||||
kind AccountingOutboxKind
|
||||
entityId String
|
||||
status AccountingOutboxStatus @default(PENDING)
|
||||
attempts Int @default(0)
|
||||
nextAttemptAt DateTime @default(now())
|
||||
lastError String?
|
||||
/// What the accounting software returned as the document/record id.
|
||||
externalRef String?
|
||||
/// Per-step progress of multi-call jobs (a Zarrin document is one call per
|
||||
/// line), so a failure resumes where it stopped.
|
||||
progress Json?
|
||||
/// Last request/response, for the log screen. Never contains the token.
|
||||
lastRequest Json?
|
||||
lastResponse Json?
|
||||
sentAt DateTime?
|
||||
resolvedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, nextAttemptAt])
|
||||
@@index([kind, entityId])
|
||||
}
|
||||
|
||||
/// Something a pull found that needs a human: an account with no mobile, two
|
||||
/// accounts sharing one mobile, a کارساخته group with no category mapping.
|
||||
model AccountingIssue {
|
||||
id String @id @default(cuid())
|
||||
provider String
|
||||
kind String
|
||||
externalCode String
|
||||
entityId String?
|
||||
message String
|
||||
data Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
resolvedAt DateTime?
|
||||
|
||||
@@unique([provider, kind, externalCode])
|
||||
@@index([resolvedAt])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Promotions / discounts — legacy views/promotion/*, served by
|
||||
// PromotionController (`product/variant/promotion`). The redesigned schema had
|
||||
|
||||
+97
-3
@@ -125,7 +125,7 @@ exports.Prisma.TransactionIsolationLevel = makeStrictEnum({
|
||||
exports.Prisma.CityScalarFieldEnum = {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
province: 'province',
|
||||
parentId: 'parentId',
|
||||
percent: 'percent',
|
||||
percentAux: 'percentAux'
|
||||
};
|
||||
@@ -203,6 +203,7 @@ exports.Prisma.CustomerScalarFieldEnum = {
|
||||
nationalCode: 'nationalCode',
|
||||
passwordHash: 'passwordHash',
|
||||
cityId: 'cityId',
|
||||
cityNameRaw: 'cityNameRaw',
|
||||
kind: 'kind',
|
||||
phone: 'phone',
|
||||
address: 'address',
|
||||
@@ -214,11 +215,17 @@ exports.Prisma.CustomerScalarFieldEnum = {
|
||||
goldCreditLimitGrams: 'goldCreditLimitGrams',
|
||||
goldDebtGrams: 'goldDebtGrams',
|
||||
fiatDebtRial: 'fiatDebtRial',
|
||||
accountingGoldBalance: 'accountingGoldBalance',
|
||||
accountingRialBalance: 'accountingRialBalance',
|
||||
accountingBalanceAt: 'accountingBalanceAt',
|
||||
makingChargeOffsetPercent: 'makingChargeOffsetPercent',
|
||||
verificationStep: 'verificationStep',
|
||||
nationalIdImageUrl: 'nationalIdImageUrl',
|
||||
businessLicenseUrl: 'businessLicenseUrl',
|
||||
shopImageUrl: 'shopImageUrl',
|
||||
verificationRejectionReason: 'verificationRejectionReason',
|
||||
verificationRejectedAt: 'verificationRejectedAt',
|
||||
birthDate: 'birthDate',
|
||||
avatarFileId: 'avatarFileId',
|
||||
canPlaceOrders: 'canPlaceOrders',
|
||||
isActive: 'isActive',
|
||||
@@ -227,6 +234,12 @@ exports.Prisma.CustomerScalarFieldEnum = {
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.CustomerFavoriteScalarFieldEnum = {
|
||||
customerId: 'customerId',
|
||||
productId: 'productId',
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.CustomerAddressScalarFieldEnum = {
|
||||
id: 'id',
|
||||
customerId: 'customerId',
|
||||
@@ -290,7 +303,10 @@ exports.Prisma.ProductScalarFieldEnum = {
|
||||
productCode: 'productCode',
|
||||
showType: 'showType',
|
||||
isActive: 'isActive',
|
||||
createdAt: 'createdAt'
|
||||
createdAt: 'createdAt',
|
||||
accountingStockWeight: 'accountingStockWeight',
|
||||
accountingStockCount: 'accountingStockCount',
|
||||
accountingStockAt: 'accountingStockAt'
|
||||
};
|
||||
|
||||
exports.Prisma.ProductImageScalarFieldEnum = {
|
||||
@@ -326,6 +342,8 @@ exports.Prisma.OrderScalarFieldEnum = {
|
||||
flatRialCharge: 'flatRialCharge',
|
||||
isSuspendedOverCreditLimit: 'isSuspendedOverCreditLimit',
|
||||
deliveredAt: 'deliveredAt',
|
||||
salesAgentId: 'salesAgentId',
|
||||
referrerCustomerId: 'referrerCustomerId',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
@@ -453,6 +471,51 @@ exports.Prisma.SettingScalarFieldEnum = {
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.AccountingLinkScalarFieldEnum = {
|
||||
id: 'id',
|
||||
provider: 'provider',
|
||||
entityType: 'entityType',
|
||||
entityId: 'entityId',
|
||||
externalCode: 'externalCode',
|
||||
externalName: 'externalName',
|
||||
origin: 'origin',
|
||||
snapshot: 'snapshot',
|
||||
syncedAt: 'syncedAt',
|
||||
createdAt: 'createdAt'
|
||||
};
|
||||
|
||||
exports.Prisma.AccountingOutboxScalarFieldEnum = {
|
||||
id: 'id',
|
||||
provider: 'provider',
|
||||
kind: 'kind',
|
||||
entityId: 'entityId',
|
||||
status: 'status',
|
||||
attempts: 'attempts',
|
||||
nextAttemptAt: 'nextAttemptAt',
|
||||
lastError: 'lastError',
|
||||
externalRef: 'externalRef',
|
||||
progress: 'progress',
|
||||
lastRequest: 'lastRequest',
|
||||
lastResponse: 'lastResponse',
|
||||
sentAt: 'sentAt',
|
||||
resolvedById: 'resolvedById',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.AccountingIssueScalarFieldEnum = {
|
||||
id: 'id',
|
||||
provider: 'provider',
|
||||
kind: 'kind',
|
||||
externalCode: 'externalCode',
|
||||
entityId: 'entityId',
|
||||
message: 'message',
|
||||
data: 'data',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
resolvedAt: 'resolvedAt'
|
||||
};
|
||||
|
||||
exports.Prisma.PromotionScalarFieldEnum = {
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
@@ -567,6 +630,11 @@ exports.Prisma.JsonNullValueInput = {
|
||||
JsonNull: Prisma.JsonNull
|
||||
};
|
||||
|
||||
exports.Prisma.NullableJsonNullValueInput = {
|
||||
DbNull: Prisma.DbNull,
|
||||
JsonNull: Prisma.JsonNull
|
||||
};
|
||||
|
||||
exports.Prisma.QueryMode = {
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
@@ -603,7 +671,8 @@ exports.VerificationStep = exports.$Enums.VerificationStep = {
|
||||
UNVERIFIED: 'UNVERIFIED',
|
||||
STEP_ONE_SUBMITTED: 'STEP_ONE_SUBMITTED',
|
||||
STEP_TWO_SUBMITTED: 'STEP_TWO_SUBMITTED',
|
||||
VERIFIED: 'VERIFIED'
|
||||
VERIFIED: 'VERIFIED',
|
||||
REJECTED: 'REJECTED'
|
||||
};
|
||||
|
||||
exports.ProductImageRole = exports.$Enums.ProductImageRole = {
|
||||
@@ -665,6 +734,27 @@ exports.CommentStatus = exports.$Enums.CommentStatus = {
|
||||
REJECTED: 'REJECTED'
|
||||
};
|
||||
|
||||
exports.AccountingEntityType = exports.$Enums.AccountingEntityType = {
|
||||
CUSTOMER: 'CUSTOMER',
|
||||
PRODUCT: 'PRODUCT'
|
||||
};
|
||||
|
||||
exports.AccountingOutboxKind = exports.$Enums.AccountingOutboxKind = {
|
||||
CUSTOMER_PUSH: 'CUSTOMER_PUSH',
|
||||
PRODUCT_PUSH: 'PRODUCT_PUSH',
|
||||
ORDER_DOCUMENT: 'ORDER_DOCUMENT',
|
||||
ORDER_DOCUMENT_DELETE: 'ORDER_DOCUMENT_DELETE'
|
||||
};
|
||||
|
||||
exports.AccountingOutboxStatus = exports.$Enums.AccountingOutboxStatus = {
|
||||
PENDING: 'PENDING',
|
||||
SENDING: 'SENDING',
|
||||
SENT: 'SENT',
|
||||
FAILED: 'FAILED',
|
||||
NEEDS_REVIEW: 'NEEDS_REVIEW',
|
||||
RESOLVED: 'RESOLVED'
|
||||
};
|
||||
|
||||
exports.PromotionScope = exports.$Enums.PromotionScope = {
|
||||
PRODUCT: 'PRODUCT',
|
||||
CATEGORY: 'CATEGORY',
|
||||
@@ -693,6 +783,7 @@ exports.Prisma.ModelName = {
|
||||
AdminSession: 'AdminSession',
|
||||
SalesExpertProfile: 'SalesExpertProfile',
|
||||
Customer: 'Customer',
|
||||
CustomerFavorite: 'CustomerFavorite',
|
||||
CustomerAddress: 'CustomerAddress',
|
||||
Branch: 'Branch',
|
||||
Workshop: 'Workshop',
|
||||
@@ -715,6 +806,9 @@ exports.Prisma.ModelName = {
|
||||
Permission: 'Permission',
|
||||
Comment: 'Comment',
|
||||
Setting: 'Setting',
|
||||
AccountingLink: 'AccountingLink',
|
||||
AccountingOutbox: 'AccountingOutbox',
|
||||
AccountingIssue: 'AccountingIssue',
|
||||
Promotion: 'Promotion',
|
||||
PromotionExclusion: 'PromotionExclusion',
|
||||
BranchAllocation: 'BranchAllocation',
|
||||
|
||||
@@ -26,3 +26,21 @@ export function envOrNull(name: string): string | null {
|
||||
const trimmed = typeof raw === 'string' ? raw.trim() : '';
|
||||
return trimmed === '' ? null : trimmed;
|
||||
}
|
||||
|
||||
/** Comma-separated env list. Blank entries are dropped. */
|
||||
export function envList(name: string): string[] {
|
||||
const raw = envOrNull(name);
|
||||
if (!raw) return [];
|
||||
return raw.split(',').map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact string match. A requested URI that is not in the list is refused
|
||||
* (null) instead of being replaced by another app's address.
|
||||
* With no request, the first configured value is used.
|
||||
*/
|
||||
export function pickExactUri(requested: string | undefined, allowed: Array<string | null>): string | null {
|
||||
const unique = [...new Set(allowed.filter((item): item is string => Boolean(item)))];
|
||||
if (requested) return unique.includes(requested) ? requested : null;
|
||||
return unique[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Get, Headers, HttpCode, Ip, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Headers, HttpCode, Ip, Post, Query, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { CurrentAdmin, Public } from './guards';
|
||||
@@ -26,9 +27,53 @@ export class AuthController {
|
||||
return this.authService.login(dto, ip, userAgent);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('sso/start')
|
||||
startSso(@Query('redirect_uri') redirectUri: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||
const started = this.authService.beginBrowserSso(redirectUri);
|
||||
if (!started) return { enabled: false, authorize_url: null };
|
||||
res.setHeader('Set-Cookie', started.cookie);
|
||||
return { enabled: true, authorize_url: started.authorizeUrl };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('sso/callback')
|
||||
@HttpCode(200)
|
||||
ssoCallback(
|
||||
@Body() body: { code?: string; state?: string },
|
||||
@Headers('cookie') cookieHeader: string | undefined,
|
||||
@Ip() ip: string,
|
||||
@Headers('user-agent') userAgent: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
res.setHeader('Set-Cookie', 'gb_admin_iam_sso=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
|
||||
return this.authService.completeBrowserSso(
|
||||
readCookie(cookieHeader, 'gb_admin_iam_sso'),
|
||||
body.code ?? '',
|
||||
body.state ?? '',
|
||||
ip,
|
||||
userAgent,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('sso/logout-url')
|
||||
ssoLogoutUrl(@Query('return_url') returnUrl?: string) {
|
||||
return { logout_url: this.authService.centralLogoutUrl(returnUrl) };
|
||||
}
|
||||
|
||||
/** Legacy `users/show` — the router guard's permission refresh. */
|
||||
@Get('me')
|
||||
me(@CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.authService.showData(admin.id);
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(header: string | undefined, name: string): string | null {
|
||||
if (!header) return null;
|
||||
for (const part of header.split(';')) {
|
||||
const [key, ...rest] = part.trim().split('=');
|
||||
if (key === name) return decodeURIComponent(rest.join('='));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('AuthService login', () => {
|
||||
service: new AuthService(
|
||||
prisma as never,
|
||||
jwt as never,
|
||||
iam as IamService,
|
||||
{ browserBootstrapUrl: () => null, ...iam } as IamService,
|
||||
sessions as AdminSessionService,
|
||||
),
|
||||
prisma,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
type IamIdentity,
|
||||
} from '../iam/iam.service';
|
||||
import { AdminSessionService } from '../sessions/admin-session.service';
|
||||
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
|
||||
import type { LoginDto } from './dto/login.dto';
|
||||
import { compareLocalHash } from './password-hash';
|
||||
|
||||
@@ -75,6 +77,130 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
const bootstrap = this.iam.browserBootstrapUrl(verified.identity?.browserTicket);
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.fullName,
|
||||
fullName: user.fullName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
ut: user.userTypeId,
|
||||
branch_id: user.branchId,
|
||||
api_token: apiToken,
|
||||
roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })),
|
||||
...(bootstrap ? { iamBootstrapUrl: bootstrap } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
beginBrowserSso(requestedRedirect?: string): { authorizeUrl: string; cookie: string } | null {
|
||||
if (!this.iam.enabled) return null;
|
||||
const redirectUri = pickExactUri(requestedRedirect, [
|
||||
envOrNull('IAM_ADMIN_REDIRECT_URI'),
|
||||
...envList('IAM_ADMIN_REDIRECT_URIS'),
|
||||
]);
|
||||
const clientId = envOrNull('IAM_CLIENT_ID');
|
||||
if (!redirectUri || !clientId) return null;
|
||||
|
||||
const state = randomBytes(24).toString('base64url');
|
||||
const nonce = randomBytes(24).toString('base64url');
|
||||
const verifier = randomBytes(48).toString('base64url');
|
||||
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||
const payload = Buffer.from(JSON.stringify({ state, nonce, verifier, redirectUri })).toString('base64url');
|
||||
const sig = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
|
||||
const secure = redirectUri.startsWith('https:');
|
||||
const cookie =
|
||||
`gb_admin_iam_sso=${payload}.${sig}; HttpOnly; Path=/; Max-Age=180; SameSite=Lax` +
|
||||
(secure ? '; Secure' : '');
|
||||
|
||||
const url = new URL(`${this.iam.issuer}/oauth/authorize`);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', clientId);
|
||||
url.searchParams.set('redirect_uri', redirectUri);
|
||||
url.searchParams.set('scope', 'openid profile email');
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('nonce', nonce);
|
||||
url.searchParams.set('code_challenge', challenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
url.searchParams.set('prompt', 'none');
|
||||
return { authorizeUrl: url.toString(), cookie };
|
||||
}
|
||||
|
||||
async completeBrowserSso(cookieValue: string | null, code: string, state: string, ip?: string, userAgent?: string) {
|
||||
const pending = this.readSsoCookie(cookieValue);
|
||||
if (!pending || pending.state !== state) {
|
||||
throw new UnauthorizedException('درخواست ورود مشترک معتبر نیست');
|
||||
}
|
||||
|
||||
const claims = await this.iam.exchangeAuthorizationCode(code, pending.verifier, pending.redirectUri);
|
||||
if (!claims.phoneNumber) {
|
||||
throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد');
|
||||
}
|
||||
|
||||
const user = await this.prisma.adminUser.findFirst({
|
||||
where: { mobile: { in: mobileVariants(claims.phoneNumber) }, isActive: true },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('حساب مدیری با این شماره در این سامانه نیست');
|
||||
}
|
||||
|
||||
const identity: IamIdentity = {
|
||||
sub: claims.sub,
|
||||
email: null,
|
||||
phoneNumber: claims.phoneNumber,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
sessionId: claims.sessionId,
|
||||
browserTicket: null,
|
||||
};
|
||||
return this.issueAdminToken(user, identity, ip, userAgent);
|
||||
}
|
||||
|
||||
centralLogoutUrl(requestedReturn?: string): string | null {
|
||||
if (!this.iam.enabled) return null;
|
||||
const redirect = pickExactUri(requestedReturn, [
|
||||
envOrNull('IAM_ADMIN_POST_LOGOUT_REDIRECT_URI'),
|
||||
envOrNull('IAM_ADMIN_BROWSER_RETURN_URL'),
|
||||
...envList('IAM_ADMIN_ALLOWED_RETURN_URLS'),
|
||||
]);
|
||||
const clientId = envOrNull('IAM_CLIENT_ID');
|
||||
if (!redirect || !clientId) return null;
|
||||
const url = new URL(`${this.iam.issuer}/oauth/logout`);
|
||||
url.searchParams.set('client_id', clientId);
|
||||
url.searchParams.set('post_logout_redirect_uri', redirect);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private async issueAdminToken(
|
||||
user: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
role: string;
|
||||
userTypeId: number | null;
|
||||
branchId: string | null;
|
||||
userType: { permissions: { rid: string }[] } | null;
|
||||
},
|
||||
identity: IamIdentity | undefined,
|
||||
ip?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
const jti = this.sessions.newTokenId();
|
||||
const apiToken = await this.jwt.signAsync({
|
||||
sub: user.id,
|
||||
mobile: user.mobile,
|
||||
...(jti ? { jti } : {}),
|
||||
});
|
||||
if (jti) {
|
||||
await this.sessions.record({
|
||||
jti,
|
||||
adminUserId: user.id,
|
||||
iamSessionId: identity?.sessionId ?? null,
|
||||
iamSub: identity?.sub ?? null,
|
||||
ipAddress: ip ?? null,
|
||||
userAgent: userAgent ?? null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.fullName,
|
||||
@@ -88,6 +214,27 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
private ssoSecret(): string {
|
||||
return envOrNull('JWT_SECRET') || envOr('IAM_CLIENT_SECRET', 'iam-sso');
|
||||
}
|
||||
|
||||
private readSsoCookie(value: string | null): { state: string; verifier: string; redirectUri: string } | null {
|
||||
if (!value || !value.includes('.')) return null;
|
||||
const split = value.lastIndexOf('.');
|
||||
const payload = value.slice(0, split);
|
||||
const sig = value.slice(split + 1);
|
||||
const expected = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
|
||||
if (expected.length !== sig.length) return null;
|
||||
let same = 0;
|
||||
for (let i = 0; i < expected.length; i++) same |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
|
||||
if (same !== 0) return null;
|
||||
try {
|
||||
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Password check, optionally delegated to the central identity service.
|
||||
*
|
||||
@@ -170,3 +317,11 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function mobileVariants(raw: string): string[] {
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
const variants = new Set<string>([raw.trim(), digits]);
|
||||
if (digits.startsWith('98') && digits.length >= 12) variants.add(`0${digits.slice(2)}`);
|
||||
if (digits.startsWith('0') && digits.length === 11) variants.add(`98${digits.slice(1)}`);
|
||||
return [...variants].filter((value) => value !== '');
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('CustomerAuthService — OTP login with the identity service', () => {
|
||||
prisma as never,
|
||||
jwt as never,
|
||||
sms as never,
|
||||
iam as never,
|
||||
{ browserBootstrapUrl: () => null, ...iam } as never,
|
||||
session as never,
|
||||
);
|
||||
jest.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Get, HttpCode, Ip, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Headers, HttpCode, Ip, Post, Query, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { CustomerAuthService } from './customer-auth.service';
|
||||
import { CustomersService } from '../customers/customers.service';
|
||||
import { RequestOtpDto } from './dto/request-otp.dto';
|
||||
@@ -43,6 +44,35 @@ export class CustomerAuthController {
|
||||
return this.auth.verifyOtp(dto.username, dto.password, ip);
|
||||
}
|
||||
|
||||
/** اگر نشست IAM در مرورگر باشد، آدرس authorize با prompt=none برمیگردد. */
|
||||
@Public()
|
||||
@Get('auth/sso/start')
|
||||
startSso(@Query('redirect_uri') redirectUri: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||
const started = this.auth.beginBrowserSso(redirectUri);
|
||||
if (!started) return { enabled: false, authorize_url: null };
|
||||
res.setHeader('Set-Cookie', started.cookie);
|
||||
return { enabled: true, authorize_url: started.authorizeUrl };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('auth/sso/callback')
|
||||
@HttpCode(200)
|
||||
ssoCallback(
|
||||
@Body() body: { code?: string; state?: string },
|
||||
@Headers('cookie') cookieHeader: string | undefined,
|
||||
@Ip() ip: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
res.setHeader('Set-Cookie', 'gb_iam_sso=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
|
||||
return this.auth.completeBrowserSso(readCookie(cookieHeader, 'gb_iam_sso'), body.code ?? '', body.state ?? '', ip);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('auth/sso/logout-url')
|
||||
ssoLogoutUrl(@Query('return_url') returnUrl?: string) {
|
||||
return { logout_url: this.auth.centralLogoutUrl(returnUrl) };
|
||||
}
|
||||
|
||||
@CustomerAuth()
|
||||
@Get('me')
|
||||
me(@CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
@@ -61,3 +91,12 @@ export class CustomerAuthController {
|
||||
return this.customers.getLedgerEntries(me.id);
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(header: string | undefined, name: string): string | null {
|
||||
if (!header) return null;
|
||||
for (const part of header.split(';')) {
|
||||
const [key, ...rest] = part.trim().split('=');
|
||||
if (key === name) return decodeURIComponent(rest.join('='));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
||||
import { HttpException, HttpStatus, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
} from '../iam/iam.service';
|
||||
import { AppSessionService } from '../sessions/app-session.service';
|
||||
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
|
||||
import { envOr, envOrNull } from '../../common/env';
|
||||
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
|
||||
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
|
||||
import { faToEn } from './digits';
|
||||
import { SmsService, smsConfigured } from '../sms/sms.service';
|
||||
@@ -62,6 +63,14 @@ interface IssueContext {
|
||||
}
|
||||
|
||||
/** شماره ماسکشده برای لاگ امنیتی IdP — بدون افشای کامل شماره */
|
||||
function mobileVariants(raw: string): string[] {
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
const variants = new Set<string>([raw.trim(), digits]);
|
||||
if (digits.startsWith('98') && digits.length >= 12) variants.add(`0${digits.slice(2)}`);
|
||||
if (digits.startsWith('0') && digits.length === 11) variants.add(`98${digits.slice(1)}`);
|
||||
return [...variants].filter((value) => value !== '');
|
||||
}
|
||||
|
||||
function maskMobile(mobile: string): string | null {
|
||||
return mobile.length < 7 ? null : `${mobile.slice(0, 4)}***${mobile.slice(-4)}`;
|
||||
}
|
||||
@@ -395,16 +404,22 @@ export class CustomerAuthService {
|
||||
|
||||
const expert = await this.resolveExpertPrincipal(mobile);
|
||||
if (expert) {
|
||||
return {
|
||||
token: await this.issueExpertToken(expert.adminUserId, mobile, { identity, ip }),
|
||||
customer: { id: expert.tradingCustomerId, mobile },
|
||||
};
|
||||
return this.withBootstrap(
|
||||
{
|
||||
token: await this.issueExpertToken(expert.adminUserId, mobile, { identity, ip }),
|
||||
customer: { id: expert.tradingCustomerId, mobile },
|
||||
},
|
||||
identity,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile, { identity, ip }),
|
||||
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
|
||||
};
|
||||
return this.withBootstrap(
|
||||
{
|
||||
token: await this.issueToken(challenge.customer.id, challenge.customer.mobile, { identity, ip }),
|
||||
customer: { id: challenge.customer.id, mobile: challenge.customer.mobile },
|
||||
},
|
||||
identity,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -486,6 +501,140 @@ export class CustomerAuthService {
|
||||
* Reference reused api_token forever once set (:60-62); a fresh token is
|
||||
* issued on every verify here (T1).
|
||||
*/
|
||||
/**
|
||||
* شروع بررسی بیصدای نشست IAM. کوکی امضاشده code_verifier را نگه میدارد
|
||||
* تا در آدرس بازگشت دیده نشود.
|
||||
*/
|
||||
beginBrowserSso(requestedRedirect?: string): { authorizeUrl: string; cookie: string } | null {
|
||||
if (!this.iam.enabled) return null;
|
||||
const redirectUri = pickExactUri(requestedRedirect, [
|
||||
envOrNull('IAM_REDIRECT_URI'),
|
||||
...envList('IAM_REDIRECT_URIS'),
|
||||
]);
|
||||
const clientId = envOrNull('IAM_CLIENT_ID');
|
||||
if (!redirectUri || !clientId) return null;
|
||||
|
||||
const state = randomBytes(24).toString('base64url');
|
||||
const nonce = randomBytes(24).toString('base64url');
|
||||
const verifier = randomBytes(48).toString('base64url');
|
||||
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||
const payload = Buffer.from(JSON.stringify({ state, nonce, verifier, redirectUri })).toString('base64url');
|
||||
const sig = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
|
||||
const secure = redirectUri.startsWith('https:');
|
||||
const cookie =
|
||||
`gb_iam_sso=${payload}.${sig}; HttpOnly; Path=/; Max-Age=180; SameSite=Lax` +
|
||||
(secure ? '; Secure' : '');
|
||||
|
||||
const url = new URL(`${this.iam.issuer}/oauth/authorize`);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', clientId);
|
||||
url.searchParams.set('redirect_uri', redirectUri);
|
||||
url.searchParams.set('scope', 'openid profile email');
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('nonce', nonce);
|
||||
url.searchParams.set('code_challenge', challenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
url.searchParams.set('prompt', 'none');
|
||||
|
||||
return { authorizeUrl: url.toString(), cookie };
|
||||
}
|
||||
|
||||
async completeBrowserSso(cookieValue: string | null, code: string, state: string, ip: string | undefined) {
|
||||
const pending = this.readSsoCookie(cookieValue);
|
||||
if (!pending || pending.state !== state) {
|
||||
throw new UnauthorizedException('درخواست ورود مشترک معتبر نیست');
|
||||
}
|
||||
|
||||
const claims = await this.iam.exchangeAuthorizationCode(code, pending.verifier, pending.redirectUri);
|
||||
if (!claims.phoneNumber || !claims.sub) {
|
||||
throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد');
|
||||
}
|
||||
|
||||
const mobiles = mobileVariants(claims.phoneNumber);
|
||||
let expert: { adminUserId: string; tradingCustomerId: string } | null = null;
|
||||
let expertMobile = claims.phoneNumber;
|
||||
for (const candidate of mobiles) {
|
||||
expert = await this.resolveExpertPrincipal(candidate);
|
||||
if (expert) {
|
||||
expertMobile = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const identity: IamIdentity = {
|
||||
sub: claims.sub,
|
||||
email: null,
|
||||
phoneNumber: claims.phoneNumber,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
sessionId: claims.sessionId,
|
||||
browserTicket: null,
|
||||
};
|
||||
|
||||
if (expert) {
|
||||
return {
|
||||
token: await this.issueExpertToken(expert.adminUserId, expertMobile, { identity, ip }),
|
||||
customer: { id: expert.tradingCustomerId, mobile: expertMobile },
|
||||
};
|
||||
}
|
||||
|
||||
const customer = await this.prisma.customer.findFirst({
|
||||
where: { mobile: { in: mobiles }, isActive: true, isArchived: false },
|
||||
select: { id: true, mobile: true },
|
||||
});
|
||||
if (!customer) {
|
||||
throw new UnauthorizedException('حسابی با این شماره در این سامانه نیست');
|
||||
}
|
||||
|
||||
return {
|
||||
token: await this.issueToken(customer.id, customer.mobile, { identity, ip }),
|
||||
customer: { id: customer.id, mobile: customer.mobile },
|
||||
};
|
||||
}
|
||||
|
||||
centralLogoutUrl(requestedReturn?: string): string | null {
|
||||
if (!this.iam.enabled) return null;
|
||||
const redirect = pickExactUri(requestedReturn, [
|
||||
envOrNull('IAM_POST_LOGOUT_REDIRECT_URI'),
|
||||
envOrNull('IAM_BROWSER_RETURN_URL'),
|
||||
...envList('IAM_ALLOWED_RETURN_URLS'),
|
||||
]);
|
||||
const clientId = envOrNull('IAM_CLIENT_ID');
|
||||
if (!redirect || !clientId) return null;
|
||||
const url = new URL(`${this.iam.issuer}/oauth/logout`);
|
||||
url.searchParams.set('client_id', clientId);
|
||||
url.searchParams.set('post_logout_redirect_uri', redirect);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private withBootstrap<T extends { token: string; customer: { id: string; mobile: string } }>(
|
||||
body: T,
|
||||
identity: IamIdentity | null,
|
||||
): T & { iamBootstrapUrl?: string } {
|
||||
const url = this.iam.browserBootstrapUrl(identity?.browserTicket);
|
||||
return url ? { ...body, iamBootstrapUrl: url } : body;
|
||||
}
|
||||
|
||||
private ssoSecret(): string {
|
||||
return envOrNull('JWT_CUSTOMER_SECRET') || envOrNull('JWT_SECRET') || envOr('IAM_CLIENT_SECRET', 'iam-sso');
|
||||
}
|
||||
|
||||
private readSsoCookie(value: string | null): { state: string; nonce: string; verifier: string; redirectUri: string } | null {
|
||||
if (!value || !value.includes('.')) return null;
|
||||
const split = value.lastIndexOf('.');
|
||||
const payload = value.slice(0, split);
|
||||
const sig = value.slice(split + 1);
|
||||
const expected = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
|
||||
if (expected.length !== sig.length) return null;
|
||||
let same = 0;
|
||||
for (let i = 0; i < expected.length; i++) same |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
|
||||
if (same !== 0) return null;
|
||||
try {
|
||||
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async issueToken(
|
||||
customerId: string,
|
||||
mobile: string,
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('OTP_MASTER_CODE (D20)', () => {
|
||||
sms = { send: jest.fn().mockResolvedValue({ ok: true }) };
|
||||
|
||||
// IAM خاموش و ردیابی نشست خاموش ⇒ همان رفتار قبلی این سرویس
|
||||
const iam = { enabled: false, attestOtpLogin: jest.fn() };
|
||||
const iam = { enabled: false, attestOtpLogin: jest.fn(), browserBootstrapUrl: () => null };
|
||||
const sessions = { newTokenId: jest.fn().mockReturnValue(null), record: jest.fn() };
|
||||
|
||||
service = new CustomerAuthService(
|
||||
|
||||
@@ -149,6 +149,7 @@ describe('IamService', () => {
|
||||
firstName: 'رضا',
|
||||
lastName: 'کاظمی',
|
||||
sessionId: 'sid-9',
|
||||
browserTicket: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { envOr, envOrNull } from '../../common/env';
|
||||
import { envList, envOr, envOrNull } from '../../common/env';
|
||||
|
||||
/**
|
||||
* Credential delegation to the central identity service (Central IAM).
|
||||
@@ -24,6 +24,8 @@ export interface IamIdentity {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
sessionId: string | null;
|
||||
/** بلیت یکبارمصرف برای گذاشتن کوکی نشست روی دامنه IAM */
|
||||
browserTicket: string | null;
|
||||
}
|
||||
|
||||
/** The identity service rejected the credentials (unknown user or wrong password). */
|
||||
@@ -113,6 +115,8 @@ export class IamService {
|
||||
if (tenantId) body.tenant_id = tenantId;
|
||||
else if (tenantSlug) body.tenant_slug = tenantSlug;
|
||||
if (clientIp) body.client_ip = clientIp;
|
||||
const returnTo = this.adminBrowserReturnUrl();
|
||||
if (returnTo) body.return_to = returnTo;
|
||||
|
||||
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
|
||||
|
||||
@@ -148,6 +152,7 @@ export class IamService {
|
||||
firstName: user.first_name ?? null,
|
||||
lastName: user.last_name ?? null,
|
||||
sessionId: payload.session_id ?? null,
|
||||
browserTicket: typeof payload.browser_ticket === 'string' ? payload.browser_ticket : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,9 +182,8 @@ export class IamService {
|
||||
* and no way for a central sign-out to end it.
|
||||
*
|
||||
* The IAM only accepts this from a trusted confidential client carrying the
|
||||
* explicit `passwordless_delegation_allowed` flag, and it never creates an
|
||||
* account: an unknown user is reported as such so this app can carry on with
|
||||
* its own local login.
|
||||
* explicit `passwordless_delegation_allowed` flag. An unknown mobile is
|
||||
* created there. A locked or inactive account is still refused.
|
||||
*/
|
||||
async attestOtpLogin(
|
||||
identifier: string,
|
||||
@@ -199,6 +203,8 @@ export class IamService {
|
||||
else if (tenantSlug) body.tenant_slug = tenantSlug;
|
||||
if (clientIp) body.client_ip = clientIp;
|
||||
if (factorReference) body.factor_reference = factorReference;
|
||||
const returnTo = this.customerBrowserReturnUrl();
|
||||
if (returnTo) body.return_to = returnTo;
|
||||
|
||||
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
|
||||
|
||||
@@ -228,6 +234,7 @@ export class IamService {
|
||||
firstName: user.first_name ?? null,
|
||||
lastName: user.last_name ?? null,
|
||||
sessionId: payload.session_id ?? null,
|
||||
browserTicket: typeof payload.browser_ticket === 'string' ? payload.browser_ticket : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -246,4 +253,88 @@ export class IamService {
|
||||
this.logger.warn(`identity service returned ${response.status} for an OTP attestation`);
|
||||
throw new IamUnavailableError(`identity service returned ${response.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* مقصد بلیت مرورگر. خروج از فهرست گستردهتری استفاده میکند؛ اگر ورود فقط
|
||||
* `IAM_BROWSER_RETURN_URL` را ببیند و آن خالی باشد، کوکی مشترک گذاشته نمیشود
|
||||
* و فقط خروج به IAM میرود.
|
||||
*/
|
||||
private customerBrowserReturnUrl(): string | null {
|
||||
return (
|
||||
envOrNull('IAM_BROWSER_RETURN_URL') ||
|
||||
envOrNull('IAM_POST_LOGOUT_REDIRECT_URI') ||
|
||||
envList('IAM_ALLOWED_RETURN_URLS')[0] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private adminBrowserReturnUrl(): string | null {
|
||||
return (
|
||||
envOrNull('IAM_ADMIN_BROWSER_RETURN_URL') ||
|
||||
envOrNull('IAM_ADMIN_POST_LOGOUT_REDIRECT_URI') ||
|
||||
envList('IAM_ADMIN_ALLOWED_RETURN_URLS')[0] ||
|
||||
this.customerBrowserReturnUrl()
|
||||
);
|
||||
}
|
||||
|
||||
browserBootstrapUrl(ticket: string | null | undefined): string | null {
|
||||
if (!ticket || !this.issuer) return null;
|
||||
return `${this.issuer}/auth/bootstrap?ticket=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
|
||||
/** تبادل کد مجوز مرورگر. پاسخ از خود issuer و با راز کلاینت آمده است. */
|
||||
async exchangeAuthorizationCode(
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
redirectUri: string,
|
||||
): Promise<{ sub: string; phoneNumber: string | null; sessionId: string | null }> {
|
||||
const timeout = Number(envOr('IAM_HTTP_TIMEOUT_MS', '8000'));
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
client_id: this.clientId as string,
|
||||
client_secret: envOrNull('IAM_CLIENT_SECRET') as string,
|
||||
});
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${this.issuer}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
||||
body,
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new IamUnavailableError(`identity service unreachable: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<string, any>;
|
||||
if (!response.ok || typeof payload.id_token !== 'string') {
|
||||
throw new IamUnavailableError(`identity service refused the authorization code (${response.status})`);
|
||||
}
|
||||
|
||||
const claims = decodeIdToken(payload.id_token);
|
||||
if (claims.iss !== this.issuer) {
|
||||
throw new IamUnavailableError('identity token issuer does not match');
|
||||
}
|
||||
const aud = claims.aud;
|
||||
const audiences = Array.isArray(aud) ? aud : [aud];
|
||||
if (!audiences.includes(this.clientId)) {
|
||||
throw new IamUnavailableError('identity token audience does not match');
|
||||
}
|
||||
|
||||
return {
|
||||
sub: String(claims.sub ?? ''),
|
||||
phoneNumber: typeof claims.phone_number === 'string' ? claims.phone_number : null,
|
||||
sessionId: typeof claims.sid === 'string' ? claims.sid : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function decodeIdToken(token: string): Record<string, any> {
|
||||
const part = token.split('.')[1];
|
||||
if (!part) throw new IamUnavailableError('identity token is malformed');
|
||||
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as Record<string, any>;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export function relayConfig() {
|
||||
secret: envOr('SMS_RELAY_SECRET', DEFAULT_RELAY_SECRET),
|
||||
host: envOrNull('SMS_RELAY_HOST') ?? serverIpv4(),
|
||||
// "default" sends null, so the gateway chooses the line itself.
|
||||
platform: platform.toLowerCase() === 'default' ? null : platform,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,7 +97,7 @@ export const relayDriver: SmsDriver = {
|
||||
const reply = await postWithTimeout(
|
||||
c.url,
|
||||
{ 'Content-Type': 'application/json', secret_key: c.secret },
|
||||
JSON.stringify({ phone: mobile, host: c.host, text }),
|
||||
JSON.stringify({ phone: mobile, host: c.host, text, platform: c.platform }),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user