Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab39f6abed | ||
|
|
ee43d664d5 | ||
|
|
600ce06d15 | ||
|
|
0c52adcd26 | ||
|
|
e2ec30f241 | ||
|
|
06c8a5f434 |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 17 KiB |
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png?v=2" /><link rel="shortcut icon" href="/favicon.ico?v=2" />
|
||||||
<title>تهران تاج — پنل مدیریت</title>
|
<title>تهران تاج — پنل مدیریت</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DWCzDlmr.js"></script>
|
<script type="module" crossorigin src="/assets/index-DWCzDlmr.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DL8RxBUI.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DL8RxBUI.css">
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png?v=2" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico?v=2" />
|
||||||
<title>%BRAND_TITLE%</title>
|
<title>%BRAND_TITLE%</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,68 @@
|
|||||||
|
-- MANUAL, OPTIONAL, PHASE 2 of the admin/expert table split.
|
||||||
|
-- NOT in prisma/migrations on purpose: the runtime runner applies everything in
|
||||||
|
-- that folder automatically, and this must be run by a person, after checking.
|
||||||
|
--
|
||||||
|
-- WHAT IT DOES
|
||||||
|
-- Migration 20261026120000_split_admin_table COPIED the panel staff out of
|
||||||
|
-- "AdminUser" into "Admin" and left the originals behind, dormant. Their
|
||||||
|
-- mobiles are still locked by "AdminUser"'s unique index, so a person who is
|
||||||
|
-- an admin cannot ALSO be registered as a sales expert with the same number.
|
||||||
|
-- This removes those dormant originals and nothing else.
|
||||||
|
--
|
||||||
|
-- WHAT IT NEVER TOUCHES
|
||||||
|
-- Any row with role = 'SALES_EXPERT'. The ~12,000 experts are not read for
|
||||||
|
-- modification, updated, or deleted by this script.
|
||||||
|
--
|
||||||
|
-- SAFE-GUARDS
|
||||||
|
-- * Only rows that (a) are NOT sales experts, (b) have an identical copy in
|
||||||
|
-- "Admin" (same id), and (c) are not referenced as a sales agent / referrer /
|
||||||
|
-- expert profile are deleted. Anything else is left and reported.
|
||||||
|
-- * One transaction. It prints what it will delete first, then deletes, then
|
||||||
|
-- checks the expert count is unchanged and ROLLS BACK if it is not.
|
||||||
|
--
|
||||||
|
-- RUN (psql, against production, after the split has been live and verified):
|
||||||
|
-- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f 20261026_remove_dormant_admin_rows.sql
|
||||||
|
--
|
||||||
|
-- To preview only: change the final COMMIT to ROLLBACK.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TEMP TABLE _expert_count_before AS
|
||||||
|
SELECT count(*) AS n FROM "AdminUser" WHERE "role" = 'SALES_EXPERT';
|
||||||
|
|
||||||
|
-- 1. What would be deleted.
|
||||||
|
CREATE TEMP TABLE _dormant AS
|
||||||
|
SELECT u."id", u."mobile", u."role"
|
||||||
|
FROM "AdminUser" u
|
||||||
|
JOIN "Admin" a ON a."id" = u."id" AND a."mobile" = u."mobile"
|
||||||
|
WHERE u."role" <> 'SALES_EXPERT'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM "Order" o WHERE o."salesAgentId" = u."id")
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM "Customer" c WHERE c."referredByAgentId" = u."id")
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM "SalesExpertProfile" p WHERE p."adminUserId" = u."id");
|
||||||
|
|
||||||
|
SELECT 'will delete' AS action, role, count(*) FROM _dormant GROUP BY role ORDER BY role;
|
||||||
|
|
||||||
|
-- 2. Non-expert rows that are NOT deleted, and why (should be empty).
|
||||||
|
SELECT 'left in place' AS action, u."id", u."mobile", u."role"
|
||||||
|
FROM "AdminUser" u
|
||||||
|
WHERE u."role" <> 'SALES_EXPERT'
|
||||||
|
AND u."id" NOT IN (SELECT "id" FROM _dormant);
|
||||||
|
|
||||||
|
-- 3. Delete exactly those rows.
|
||||||
|
DELETE FROM "AdminUser" WHERE "id" IN (SELECT "id" FROM _dormant) AND "role" <> 'SALES_EXPERT';
|
||||||
|
|
||||||
|
-- 4. The experts must be exactly as many as before — otherwise undo everything.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
before_n bigint;
|
||||||
|
after_n bigint;
|
||||||
|
BEGIN
|
||||||
|
SELECT n INTO before_n FROM _expert_count_before;
|
||||||
|
SELECT count(*) INTO after_n FROM "AdminUser" WHERE "role" = 'SALES_EXPERT';
|
||||||
|
IF before_n <> after_n THEN
|
||||||
|
RAISE EXCEPTION 'expert count changed (% -> %): rolling back', before_n, after_n;
|
||||||
|
END IF;
|
||||||
|
RAISE NOTICE 'sales experts unchanged: %', after_n;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
-- READ-ONLY diagnostic: "I sign in as a sales expert and the customers menu /
|
||||||
|
-- list does not show". Changes nothing.
|
||||||
|
--
|
||||||
|
-- psql "$DATABASE_URL" -v mobile=09XXXXXXXXX -f diagnose_expert_login.sql
|
||||||
|
--
|
||||||
|
-- How the app decides (apps/api/src/modules/customer-auth/customer-auth.service.ts):
|
||||||
|
-- 1. request-otp: an ACTIVE "AdminUser" with role SALES_EXPERT and exactly this
|
||||||
|
-- mobile wins over any Customer with the same number.
|
||||||
|
-- 2. That expert needs a SalesExpertProfile and a usable trading Customer
|
||||||
|
-- (active, not archived). Created on first sign-in; if a Customer with the
|
||||||
|
-- same mobile already exists it is ADOPTED, and when that Customer is
|
||||||
|
-- inactive/archived the expert is NOT treated as an expert at all.
|
||||||
|
-- 3. verify-otp then issues a "sales-expert" token only if all of the above
|
||||||
|
-- hold; GET /api/expert/whoami answers isSalesExpert from that token, and
|
||||||
|
-- the apps show «مشتریان من» only when it is true.
|
||||||
|
-- 4. «مشتریان من» lists "Customer"."referredByAgentId" = the expert's id.
|
||||||
|
|
||||||
|
\echo '== 1. the expert row (must be exactly one, active, role SALES_EXPERT)'
|
||||||
|
SELECT id, mobile, role, "isActive", length(mobile) AS mobile_len
|
||||||
|
FROM "AdminUser" WHERE mobile = :'mobile';
|
||||||
|
|
||||||
|
\echo '== 1b. near-misses: same digits with spaces / country code / Persian digits'
|
||||||
|
SELECT id, mobile, role, "isActive"
|
||||||
|
FROM "AdminUser"
|
||||||
|
WHERE regexp_replace(mobile, '\D', '', 'g') IN (:'mobile', '98' || substr(:'mobile', 2))
|
||||||
|
AND mobile <> :'mobile';
|
||||||
|
|
||||||
|
\echo '== 2. profile + trading account (tradingCustomerId must be set and usable)'
|
||||||
|
SELECT p.id AS profile_id, p."tradingCustomerId",
|
||||||
|
c."isActive" AS trading_active, c."isArchived" AS trading_archived
|
||||||
|
FROM "AdminUser" a
|
||||||
|
LEFT JOIN "SalesExpertProfile" p ON p."adminUserId" = a.id
|
||||||
|
LEFT JOIN "Customer" c ON c.id = p."tradingCustomerId"
|
||||||
|
WHERE a.mobile = :'mobile';
|
||||||
|
|
||||||
|
\echo '== 3. Customer rows with this mobile (an inactive/archived one blocks expert mode)'
|
||||||
|
SELECT id, kind, "isActive", "isArchived", "verificationStep"
|
||||||
|
FROM "Customer" WHERE mobile = :'mobile';
|
||||||
|
|
||||||
|
\echo '== 4. customers referred by this expert (the «مشتریان من» list)'
|
||||||
|
SELECT count(*) AS referred_customers
|
||||||
|
FROM "Customer" c JOIN "AdminUser" a ON a.id = c."referredByAgentId"
|
||||||
|
WHERE a.mobile = :'mobile';
|
||||||
|
|
||||||
|
\echo '== 5. experts overall: how many cannot work as experts (for the whole table)'
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (WHERE a."isActive") AS active_experts,
|
||||||
|
count(*) FILTER (WHERE a."isActive" AND p.id IS NULL) AS no_profile,
|
||||||
|
count(*) FILTER (WHERE a."isActive" AND p.id IS NOT NULL
|
||||||
|
AND p."tradingCustomerId" IS NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM "Customer" c
|
||||||
|
WHERE c.mobile = a.mobile
|
||||||
|
AND (NOT c."isActive" OR c."isArchived"))) AS blocked_by_inactive_same_mobile_customer,
|
||||||
|
count(*) FILTER (WHERE a."isActive" AND c.id IS NOT NULL
|
||||||
|
AND (NOT c."isActive" OR c."isArchived")) AS trading_account_disabled
|
||||||
|
FROM "AdminUser" a
|
||||||
|
LEFT JOIN "SalesExpertProfile" p ON p."adminUserId" = a.id
|
||||||
|
LEFT JOIN "Customer" c ON c.id = p."tradingCustomerId"
|
||||||
|
WHERE a.role = 'SALES_EXPERT';
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
-- Split panel staff out of "AdminUser" into their own table, "Admin".
|
||||||
|
--
|
||||||
|
-- WHY
|
||||||
|
-- ---
|
||||||
|
-- "AdminUser" held two unrelated kinds of person: panel staff and sales
|
||||||
|
-- experts (role SALES_EXPERT). Because the mobile is unique per table, one
|
||||||
|
-- number could not be both an admin and an expert. Staff belong to the ADMIN
|
||||||
|
-- PANEL only; experts belong to the apps. They are now separate tables, each
|
||||||
|
-- unique on its own.
|
||||||
|
--
|
||||||
|
-- SAFETY — READ BEFORE RUNNING ON PRODUCTION
|
||||||
|
-- ------------------------------------------
|
||||||
|
-- This migration NEVER modifies, deletes or locks "AdminUser" (the sales-expert
|
||||||
|
-- table; ~12,000 rows in production). It only
|
||||||
|
-- 1. creates "Admin" and its indexes/foreign keys,
|
||||||
|
-- 2. COPIES the non-expert rows from "AdminUser" into it, keeping their ids,
|
||||||
|
-- 3. re-points the "who did this" foreign keys from "AdminUser" to "Admin".
|
||||||
|
--
|
||||||
|
-- * Ids are preserved, so every issued panel token (its `sub` is the id) and
|
||||||
|
-- every existing ledger / status / SMS / upload row keeps resolving.
|
||||||
|
-- * The old non-expert rows are LEFT in "AdminUser", dormant. The code no
|
||||||
|
-- longer reads them as admins. Remove them later, in a separate release, only
|
||||||
|
-- once the split has been verified.
|
||||||
|
-- * Re-runnable: every statement is guarded (IF NOT EXISTS / ON CONFLICT / drop
|
||||||
|
-- then add), so a half-applied run can simply be run again.
|
||||||
|
-- * No data is moved on the big tables (LedgerEntry, OrderStatusEvent, …):
|
||||||
|
-- the existing columns keep their names and values; only the constraint's
|
||||||
|
-- target changes.
|
||||||
|
--
|
||||||
|
-- The re-pointed foreign keys are added NOT VALID (checked for new/changed rows
|
||||||
|
-- immediately, existing rows not scanned under a heavy lock) and then validated
|
||||||
|
-- at the end. A row that references an id that is not an admin — only possible
|
||||||
|
-- if a sales expert once appears as an actor — leaves that one constraint
|
||||||
|
-- NOT VALID and prints a NOTICE instead of failing the deploy. See
|
||||||
|
-- docs/ADMIN-TABLE-SPLIT.md for the check query.
|
||||||
|
--
|
||||||
|
-- ROLLBACK: deploy the previous code. Nothing was deleted, so the old code
|
||||||
|
-- still finds every admin in "AdminUser". Admins created AFTER the split exist
|
||||||
|
-- only in "Admin" and would need re-creating in the old system.
|
||||||
|
|
||||||
|
-- ── 1. The table ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "Admin" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"fullName" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT,
|
||||||
|
"lastName" TEXT,
|
||||||
|
"mobile" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"role" "AdminRole" NOT NULL,
|
||||||
|
"userTypeId" INTEGER,
|
||||||
|
"branchId" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Admin_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "Admin_mobile_key" ON "Admin" ("mobile");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Admin_role_idx" ON "Admin" ("role");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Admin_userTypeId_idx" ON "Admin" ("userTypeId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Admin_firstName_idx" ON "Admin" ("firstName");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Admin_lastName_idx" ON "Admin" ("lastName");
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Admin" ADD CONSTRAINT "Admin_userTypeId_fkey"
|
||||||
|
FOREIGN KEY ("userTypeId") REFERENCES "user_type"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Admin" ADD CONSTRAINT "Admin_branchId_fkey"
|
||||||
|
FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
-- ── 2. Copy the staff (everyone who is not a sales expert), ids preserved ──
|
||||||
|
--
|
||||||
|
-- Done BEFORE the name-sync trigger exists, so the stored names are copied
|
||||||
|
-- exactly as they are rather than rebuilt.
|
||||||
|
|
||||||
|
INSERT INTO "Admin"
|
||||||
|
("id", "fullName", "firstName", "lastName", "mobile", "passwordHash",
|
||||||
|
"role", "userTypeId", "branchId", "isActive", "createdAt")
|
||||||
|
SELECT
|
||||||
|
"id", "fullName", "firstName", "lastName", "mobile", "passwordHash",
|
||||||
|
"role", "userTypeId", "branchId", "isActive", "createdAt"
|
||||||
|
FROM "AdminUser"
|
||||||
|
WHERE "role" <> 'SALES_EXPERT'
|
||||||
|
ON CONFLICT ("id") DO NOTHING;
|
||||||
|
|
||||||
|
-- Same two-way fullName <-> firstName/lastName sync as AdminUser, sharing its
|
||||||
|
-- function (created in 20260922180000_sales_expert_profile_and_admin_name_parts).
|
||||||
|
DROP TRIGGER IF EXISTS admin_sync_full_name_trg ON "Admin";
|
||||||
|
CREATE TRIGGER admin_sync_full_name_trg
|
||||||
|
BEFORE INSERT OR UPDATE ON "Admin"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION admin_user_sync_full_name();
|
||||||
|
|
||||||
|
-- ── 3. Re-point the foreign keys that mean "an admin did this" ─────────────
|
||||||
|
--
|
||||||
|
-- Same constraint names, same column names, same ON DELETE / ON UPDATE actions;
|
||||||
|
-- only the referenced table changes. "Order"."salesAgentId",
|
||||||
|
-- "Customer"."referredByAgentId" and "SalesExpertProfile"."adminUserId" are
|
||||||
|
-- about SALES EXPERTS and keep pointing at "AdminUser".
|
||||||
|
|
||||||
|
ALTER TABLE "Workshop" DROP CONSTRAINT IF EXISTS "Workshop_supervisorId_fkey";
|
||||||
|
ALTER TABLE "Workshop" ADD CONSTRAINT "Workshop_supervisorId_fkey"
|
||||||
|
FOREIGN KEY ("supervisorId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE "OrderStatusEvent" DROP CONSTRAINT IF EXISTS "OrderStatusEvent_actorAdminUserId_fkey";
|
||||||
|
ALTER TABLE "OrderStatusEvent" ADD CONSTRAINT "OrderStatusEvent_actorAdminUserId_fkey"
|
||||||
|
FOREIGN KEY ("actorAdminUserId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE "PreOrderStatusEvent" DROP CONSTRAINT IF EXISTS "PreOrderStatusEvent_actorAdminUserId_fkey";
|
||||||
|
ALTER TABLE "PreOrderStatusEvent" ADD CONSTRAINT "PreOrderStatusEvent_actorAdminUserId_fkey"
|
||||||
|
FOREIGN KEY ("actorAdminUserId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE "LedgerEntry" DROP CONSTRAINT IF EXISTS "LedgerEntry_postedByAdminUserId_fkey";
|
||||||
|
ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_postedByAdminUserId_fkey"
|
||||||
|
FOREIGN KEY ("postedByAdminUserId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE "BranchAllocation" DROP CONSTRAINT IF EXISTS "BranchAllocation_allocatorAdminUserId_fkey";
|
||||||
|
ALTER TABLE "BranchAllocation" ADD CONSTRAINT "BranchAllocation_allocatorAdminUserId_fkey"
|
||||||
|
FOREIGN KEY ("allocatorAdminUserId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE "SmsMessage" DROP CONSTRAINT IF EXISTS "SmsMessage_sentByAdminUserId_fkey";
|
||||||
|
ALTER TABLE "SmsMessage" ADD CONSTRAINT "SmsMessage_sentByAdminUserId_fkey"
|
||||||
|
FOREIGN KEY ("sentByAdminUserId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE "Notification" DROP CONSTRAINT IF EXISTS "Notification_sentByAdminUserId_fkey";
|
||||||
|
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_sentByAdminUserId_fkey"
|
||||||
|
FOREIGN KEY ("sentByAdminUserId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE "UploadedFile" DROP CONSTRAINT IF EXISTS "UploadedFile_uploadedByAdminUserId_fkey";
|
||||||
|
ALTER TABLE "UploadedFile" ADD CONSTRAINT "UploadedFile_uploadedByAdminUserId_fkey"
|
||||||
|
FOREIGN KEY ("uploadedByAdminUserId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
-- Panel tokens are issued to staff only. CASCADE as before: deleting an admin
|
||||||
|
-- ends their sessions.
|
||||||
|
ALTER TABLE "AdminSession" DROP CONSTRAINT IF EXISTS "AdminSession_adminUserId_fkey";
|
||||||
|
ALTER TABLE "AdminSession" ADD CONSTRAINT "AdminSession_adminUserId_fkey"
|
||||||
|
FOREIGN KEY ("adminUserId") REFERENCES "Admin"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
-- ── 4. Validate; never fail the deploy over an old orphan ──────────────────
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
c record;
|
||||||
|
BEGIN
|
||||||
|
FOR c IN
|
||||||
|
SELECT * FROM (VALUES
|
||||||
|
('Workshop', 'Workshop_supervisorId_fkey'),
|
||||||
|
('OrderStatusEvent', 'OrderStatusEvent_actorAdminUserId_fkey'),
|
||||||
|
('PreOrderStatusEvent', 'PreOrderStatusEvent_actorAdminUserId_fkey'),
|
||||||
|
('LedgerEntry', 'LedgerEntry_postedByAdminUserId_fkey'),
|
||||||
|
('BranchAllocation', 'BranchAllocation_allocatorAdminUserId_fkey'),
|
||||||
|
('SmsMessage', 'SmsMessage_sentByAdminUserId_fkey'),
|
||||||
|
('Notification', 'Notification_sentByAdminUserId_fkey'),
|
||||||
|
('UploadedFile', 'UploadedFile_uploadedByAdminUserId_fkey'),
|
||||||
|
('AdminSession', 'AdminSession_adminUserId_fkey')
|
||||||
|
) AS t(tbl, con)
|
||||||
|
LOOP
|
||||||
|
BEGIN
|
||||||
|
EXECUTE format('ALTER TABLE %I VALIDATE CONSTRAINT %I', c.tbl, c.con);
|
||||||
|
EXCEPTION WHEN foreign_key_violation THEN
|
||||||
|
RAISE NOTICE '% on % left NOT VALID: some existing rows reference an id that is not in "Admin" (see docs/ADMIN-TABLE-SPLIT.md)', c.con, c.tbl;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
@@ -140,6 +140,61 @@ model City {
|
|||||||
@@index([parentId])
|
@@index([parentId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A staff account for the ADMIN PANEL — and nothing else.
|
||||||
|
///
|
||||||
|
/// Split out of AdminUser (which now holds sales experts only) so that:
|
||||||
|
/// * the same mobile can be an admin AND a sales expert — they are two
|
||||||
|
/// separate roles, each unique only within its own table;
|
||||||
|
/// * the expert table (production: ~12,000 rows) is never written by panel
|
||||||
|
/// staff management.
|
||||||
|
/// Migration 20261026120000_split_admin_table copies the existing non-expert
|
||||||
|
/// AdminUser rows here WITH THEIR IDS, so issued panel tokens and every
|
||||||
|
/// "who did this" column keep resolving. The old rows are left in AdminUser,
|
||||||
|
/// dormant; no code reads them as admins any more.
|
||||||
|
///
|
||||||
|
/// ⚠️ fullName/firstName/lastName are kept in sync by the DB trigger
|
||||||
|
/// `admin_sync_full_name_trg` (it shares the function admin_user_sync_full_name) — same semantics as AdminUser: the parts win.
|
||||||
|
model Admin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
fullName String
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
|
||||||
|
mobile String @unique
|
||||||
|
passwordHash String
|
||||||
|
/// Never SALES_EXPERT — that role lives in AdminUser. Enforced in
|
||||||
|
/// AdminUsersService, not in the database, so the enum stays shared.
|
||||||
|
role AdminRole
|
||||||
|
userTypeId Int?
|
||||||
|
userType UserType? @relation(fields: [userTypeId], references: [id])
|
||||||
|
branchId String?
|
||||||
|
branch Branch? @relation(fields: [branchId], references: [id])
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
supervisedWorkshops Workshop[] @relation("WorkshopSupervisor")
|
||||||
|
branchAllocations BranchAllocation[] @relation("AllocationAuthor")
|
||||||
|
postedLedgerEntries LedgerEntry[]
|
||||||
|
orderStatusEvents OrderStatusEvent[]
|
||||||
|
preOrderStatusEvents PreOrderStatusEvent[]
|
||||||
|
sentSmsMessages SmsMessage[] @relation("SmsAuthor")
|
||||||
|
sentNotifications Notification[] @relation("NotificationAuthor")
|
||||||
|
uploadedFiles UploadedFile[] @relation("FileUploader")
|
||||||
|
|
||||||
|
/// Issued admin-panel tokens, recorded only while session tracking is on
|
||||||
|
/// (AUTH_SESSION_TRACKING, or implicitly whenever the IAM is enabled) so a
|
||||||
|
/// token can be revoked before it expires. See AdminSession.
|
||||||
|
sessions AdminSession[]
|
||||||
|
|
||||||
|
@@index([role])
|
||||||
|
@@index([userTypeId])
|
||||||
|
@@index([firstName])
|
||||||
|
@@index([lastName])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A SALES EXPERT (role SALES_EXPERT). Panel staff live in `Admin`; rows here with
|
||||||
|
/// any other role are pre-split leftovers and are ignored by the code.
|
||||||
model AdminUser {
|
model AdminUser {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
@@ -188,23 +243,10 @@ model AdminUser {
|
|||||||
referredCustomers Customer[] @relation("ReferralAgent")
|
referredCustomers Customer[] @relation("ReferralAgent")
|
||||||
/// Orders credited to this sales expert — see Order.salesAgentId.
|
/// Orders credited to this sales expert — see Order.salesAgentId.
|
||||||
creditedOrders Order[] @relation("OrderSalesAgent")
|
creditedOrders Order[] @relation("OrderSalesAgent")
|
||||||
supervisedWorkshops Workshop[] @relation("WorkshopSupervisor")
|
|
||||||
branchAllocations BranchAllocation[] @relation("AllocationAuthor")
|
|
||||||
postedLedgerEntries LedgerEntry[]
|
|
||||||
orderStatusEvents OrderStatusEvent[]
|
|
||||||
preOrderStatusEvents PreOrderStatusEvent[]
|
|
||||||
sentSmsMessages SmsMessage[] @relation("SmsAuthor")
|
|
||||||
sentNotifications Notification[] @relation("NotificationAuthor")
|
|
||||||
uploadedFiles UploadedFile[] @relation("FileUploader")
|
|
||||||
|
|
||||||
/// Only ever set for role = SALES_EXPERT (D17).
|
/// Only ever set for role = SALES_EXPERT (D17).
|
||||||
salesExpertProfile SalesExpertProfile?
|
salesExpertProfile SalesExpertProfile?
|
||||||
|
|
||||||
/// Issued admin-panel tokens, recorded only while session tracking is on
|
|
||||||
/// (AUTH_SESSION_TRACKING, or implicitly whenever the IAM is enabled) so a
|
|
||||||
/// token can be revoked before it expires. See AdminSession.
|
|
||||||
sessions AdminSession[]
|
|
||||||
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@index([userTypeId])
|
@@index([userTypeId])
|
||||||
@@index([avatarFileId])
|
@@index([avatarFileId])
|
||||||
@@ -274,8 +316,8 @@ model AdminSession {
|
|||||||
/// The token's `jti` claim. Unique: one row per issued token.
|
/// The token's `jti` claim. Unique: one row per issued token.
|
||||||
jti String @unique
|
jti String @unique
|
||||||
|
|
||||||
adminUserId String
|
adminId String @map("adminUserId")
|
||||||
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
|
admin Admin @relation(fields: [adminId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
/// Set when the login was verified by the IAM: its session id (`sid`) and the
|
/// Set when the login was verified by the IAM: its session id (`sid`) and the
|
||||||
/// stable user id (`sub`) there. Back-channel logout arrives keyed by these —
|
/// stable user id (`sub`) there. Back-channel logout arrives keyed by these —
|
||||||
@@ -294,7 +336,7 @@ model AdminSession {
|
|||||||
revokedAt DateTime?
|
revokedAt DateTime?
|
||||||
revokedReason String?
|
revokedReason String?
|
||||||
|
|
||||||
@@index([adminUserId])
|
@@index([adminId], map: "AdminSession_adminUserId_idx")
|
||||||
@@index([iamSessionId])
|
@@index([iamSessionId])
|
||||||
@@index([iamSub])
|
@@index([iamSub])
|
||||||
@@index([expiresAt])
|
@@index([expiresAt])
|
||||||
@@ -571,6 +613,7 @@ model Branch {
|
|||||||
cityId String?
|
cityId String?
|
||||||
city City? @relation(fields: [cityId], references: [id])
|
city City? @relation(fields: [cityId], references: [id])
|
||||||
adminUsers AdminUser[]
|
adminUsers AdminUser[]
|
||||||
|
admins Admin[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
allocations BranchAllocation[]
|
allocations BranchAllocation[]
|
||||||
}
|
}
|
||||||
@@ -583,7 +626,7 @@ model Workshop {
|
|||||||
phone String?
|
phone String?
|
||||||
/// The supervisor created alongside the workshop by the legacy Add form.
|
/// The supervisor created alongside the workshop by the legacy Add form.
|
||||||
supervisorId String?
|
supervisorId String?
|
||||||
supervisor AdminUser? @relation("WorkshopSupervisor", fields: [supervisorId], references: [id])
|
supervisor Admin? @relation("WorkshopSupervisor", fields: [supervisorId], references: [id])
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
preOrders PreOrder[]
|
preOrders PreOrder[]
|
||||||
}
|
}
|
||||||
@@ -832,8 +875,8 @@ model OrderStatusEvent {
|
|||||||
fromStatus OrderStatus?
|
fromStatus OrderStatus?
|
||||||
toStatus OrderStatus
|
toStatus OrderStatus
|
||||||
note String?
|
note String?
|
||||||
actorAdminUserId String?
|
actorAdminId String? @map("actorAdminUserId")
|
||||||
actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id])
|
actorAdmin Admin? @relation(fields: [actorAdminId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([orderId])
|
@@index([orderId])
|
||||||
@@ -880,8 +923,8 @@ model PreOrderStatusEvent {
|
|||||||
preOrder PreOrder @relation(fields: [preOrderId], references: [id])
|
preOrder PreOrder @relation(fields: [preOrderId], references: [id])
|
||||||
/// Field-level diff, e.g. {"status": {"old": "PLACED", "new": "ADMIN_APPROVED"}}
|
/// Field-level diff, e.g. {"status": {"old": "PLACED", "new": "ADMIN_APPROVED"}}
|
||||||
changes Json
|
changes Json
|
||||||
actorAdminUserId String?
|
actorAdminId String? @map("actorAdminUserId")
|
||||||
actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id])
|
actorAdmin Admin? @relation(fields: [actorAdminId], references: [id])
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -914,8 +957,8 @@ model LedgerEntry {
|
|||||||
/// Pending entries don't count toward the cached customer balance until confirmed.
|
/// Pending entries don't count toward the cached customer balance until confirmed.
|
||||||
confirmed Boolean @default(false)
|
confirmed Boolean @default(false)
|
||||||
confirmedAt DateTime?
|
confirmedAt DateTime?
|
||||||
postedByAdminUserId String?
|
postedByAdminId String? @map("postedByAdminUserId")
|
||||||
postedByAdminUser AdminUser? @relation(fields: [postedByAdminUserId], references: [id])
|
postedByAdmin Admin? @relation(fields: [postedByAdminId], references: [id])
|
||||||
|
|
||||||
/// Instrument metadata from the legacy "cash" document form. Which fields are
|
/// Instrument metadata from the legacy "cash" document form. Which fields are
|
||||||
/// filled depends on sourceType: a melted-gold receipt carries lab/hallmark and
|
/// filled depends on sourceType: a melted-gold receipt carries lab/hallmark and
|
||||||
@@ -997,6 +1040,7 @@ model UserType {
|
|||||||
|
|
||||||
permissions Permission[]
|
permissions Permission[]
|
||||||
adminUsers AdminUser[]
|
adminUsers AdminUser[]
|
||||||
|
admins Admin[]
|
||||||
|
|
||||||
@@map("user_type")
|
@@map("user_type")
|
||||||
}
|
}
|
||||||
@@ -1251,8 +1295,8 @@ model BranchAllocation {
|
|||||||
branch Branch @relation(fields: [branchId], references: [id])
|
branch Branch @relation(fields: [branchId], references: [id])
|
||||||
|
|
||||||
/// Legacy `allocator_user` — the admin who filed the allocation.
|
/// Legacy `allocator_user` — the admin who filed the allocation.
|
||||||
allocatorAdminUserId String?
|
allocatorAdminId String? @map("allocatorAdminUserId")
|
||||||
allocatorAdminUser AdminUser? @relation("AllocationAuthor", fields: [allocatorAdminUserId], references: [id])
|
allocatorAdmin Admin? @relation("AllocationAuthor", fields: [allocatorAdminId], references: [id])
|
||||||
|
|
||||||
note String?
|
note String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -1299,8 +1343,8 @@ model SmsMessage {
|
|||||||
/// How many numbers the send actually resolved to.
|
/// How many numbers the send actually resolved to.
|
||||||
sentCount Int @default(0)
|
sentCount Int @default(0)
|
||||||
|
|
||||||
sentByAdminUserId String?
|
sentByAdminId String? @map("sentByAdminUserId")
|
||||||
sentByAdminUser AdminUser? @relation("SmsAuthor", fields: [sentByAdminUserId], references: [id])
|
sentByAdmin Admin? @relation("SmsAuthor", fields: [sentByAdminId], references: [id])
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -1313,8 +1357,8 @@ model Notification {
|
|||||||
body String
|
body String
|
||||||
audience MessageAudience @default(ALL_CUSTOMERS)
|
audience MessageAudience @default(ALL_CUSTOMERS)
|
||||||
|
|
||||||
sentByAdminUserId String?
|
sentByAdminId String? @map("sentByAdminUserId")
|
||||||
sentByAdminUser AdminUser? @relation("NotificationAuthor", fields: [sentByAdminUserId], references: [id])
|
sentByAdmin Admin? @relation("NotificationAuthor", fields: [sentByAdminId], references: [id])
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -1379,8 +1423,8 @@ model UploadedFile {
|
|||||||
/// 'customer.nationalId'. Kept loose so a new form needs no schema change.
|
/// 'customer.nationalId'. Kept loose so a new form needs no schema change.
|
||||||
purpose String?
|
purpose String?
|
||||||
|
|
||||||
uploadedByAdminUserId String?
|
uploadedByAdminId String? @map("uploadedByAdminUserId")
|
||||||
uploadedByAdminUser AdminUser? @relation("FileUploader", fields: [uploadedByAdminUserId], references: [id])
|
uploadedByAdmin Admin? @relation("FileUploader", fields: [uploadedByAdminId], references: [id])
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
customerAvatars Customer[] @relation("CustomerAvatar")
|
customerAvatars Customer[] @relation("CustomerAvatar")
|
||||||
|
|||||||
@@ -148,13 +148,13 @@ async function main() {
|
|||||||
create: { id: 'workshop-central', name: 'کارگاه مرکزی ساخت طلا' },
|
create: { id: 'workshop-central', name: 'کارگاه مرکزی ساخت طلا' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Admin users (sales agents + one super admin) ────────────────────
|
// ── Admins (table `Admin`) and sales agents (table `AdminUser`) ─────────────────────────
|
||||||
|
|
||||||
// DEV-ONLY test login, per explicit request: authorized in BOTH the admin
|
// DEV-ONLY test login, per explicit request: authorized in BOTH the admin
|
||||||
// panel and the user-app, password = the phone number itself. Super admin
|
// panel and the user-app, password = the phone number itself. Super admin
|
||||||
// so it can reach every screen while testing. Do not carry this into a real
|
// so it can reach every screen while testing. Do not carry this into a real
|
||||||
// deployment's seed data.
|
// deployment's seed data.
|
||||||
await prisma.adminUser.upsert({
|
await prisma.admin.upsert({
|
||||||
where: { mobile: DEV_TEST_PHONE },
|
where: { mobile: DEV_TEST_PHONE },
|
||||||
update: { passwordHash: DEV_TEST_PHONE_PASSWORD_HASH, userTypeId: 85, branchId: null },
|
update: { passwordHash: DEV_TEST_PHONE_PASSWORD_HASH, userTypeId: 85, branchId: null },
|
||||||
create: {
|
create: {
|
||||||
@@ -167,7 +167,7 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.adminUser.upsert({
|
await prisma.admin.upsert({
|
||||||
where: { mobile: '09120000001' },
|
where: { mobile: '09120000001' },
|
||||||
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 85, branchId: null },
|
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 85, branchId: null },
|
||||||
create: {
|
create: {
|
||||||
@@ -208,7 +208,7 @@ async function main() {
|
|||||||
wagePercent: 1.2,
|
wagePercent: 1.2,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await prisma.adminUser.upsert({
|
await prisma.admin.upsert({
|
||||||
where: { mobile: '09123334444' },
|
where: { mobile: '09123334444' },
|
||||||
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 88 },
|
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 88 },
|
||||||
create: {
|
create: {
|
||||||
@@ -219,7 +219,7 @@ async function main() {
|
|||||||
userTypeId: 88,
|
userTypeId: 88,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await prisma.adminUser.upsert({
|
await prisma.admin.upsert({
|
||||||
where: { mobile: '09125556666' },
|
where: { mobile: '09125556666' },
|
||||||
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 87, branchId: null },
|
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 87, branchId: null },
|
||||||
create: {
|
create: {
|
||||||
|
|||||||
@@ -90343,7 +90343,7 @@ var require_admin_uploads_service = __commonJS({
|
|||||||
mimeType: file2.mimetype,
|
mimeType: file2.mimetype,
|
||||||
sizeBytes: file2.size,
|
sizeBytes: file2.size,
|
||||||
purpose: purpose ?? null,
|
purpose: purpose ?? null,
|
||||||
uploadedByAdminUserId: actorAdminUserId
|
uploadedByAdminId: actorAdminUserId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -91521,7 +91521,7 @@ var require_ledger_service = __commonJS({
|
|||||||
note: input.note,
|
note: input.note,
|
||||||
confirmed,
|
confirmed,
|
||||||
confirmedAt: confirmed ? /* @__PURE__ */ new Date() : null,
|
confirmedAt: confirmed ? /* @__PURE__ */ new Date() : null,
|
||||||
postedByAdminUserId: input.postedByAdminUserId
|
postedByAdminId: input.postedByAdminId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
@@ -91546,7 +91546,7 @@ var require_ledger_service = __commonJS({
|
|||||||
return entry;
|
return entry;
|
||||||
const updated = await tx.ledgerEntry.update({
|
const updated = await tx.ledgerEntry.update({
|
||||||
where: { id: entryId },
|
where: { id: entryId },
|
||||||
data: { confirmed: true, confirmedAt: /* @__PURE__ */ new Date(), postedByAdminUserId: adminUserId ?? entry.postedByAdminUserId }
|
data: { confirmed: true, confirmedAt: /* @__PURE__ */ new Date(), postedByAdminId: adminUserId ?? entry.postedByAdminId }
|
||||||
});
|
});
|
||||||
await this.applyToBalance(entry.customerId, entry.currency, entry.direction, entry.amount, tx);
|
await this.applyToBalance(entry.customerId, entry.currency, entry.direction, entry.amount, tx);
|
||||||
return updated;
|
return updated;
|
||||||
@@ -91660,7 +91660,7 @@ var require_ledger_service = __commonJS({
|
|||||||
note: params.note,
|
note: params.note,
|
||||||
confirmed: false,
|
confirmed: false,
|
||||||
// pending until an admin confirms — intentional deviation from legacy, which auto-confirms on creation
|
// pending until an admin confirms — intentional deviation from legacy, which auto-confirms on creation
|
||||||
postedByAdminUserId: params.postedByAdminUserId
|
postedByAdminId: params.postedByAdminId
|
||||||
}, tx);
|
}, tx);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -91677,7 +91677,7 @@ var require_ledger_service = __commonJS({
|
|||||||
amount: params.amount,
|
amount: params.amount,
|
||||||
note: params.note,
|
note: params.note,
|
||||||
confirmed: false,
|
confirmed: false,
|
||||||
postedByAdminUserId: params.postedByAdminUserId
|
postedByAdminId: params.postedByAdminId
|
||||||
}, tx);
|
}, tx);
|
||||||
}
|
}
|
||||||
async postAdjustment(params, tx = this.prisma) {
|
async postAdjustment(params, tx = this.prisma) {
|
||||||
@@ -91689,7 +91689,7 @@ var require_ledger_service = __commonJS({
|
|||||||
amount: params.amount,
|
amount: params.amount,
|
||||||
note: params.note,
|
note: params.note,
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
postedByAdminUserId: params.postedByAdminUserId
|
postedByAdminId: params.postedByAdminId
|
||||||
}, tx);
|
}, tx);
|
||||||
}
|
}
|
||||||
async getBalance(customerId) {
|
async getBalance(customerId) {
|
||||||
@@ -92254,7 +92254,7 @@ var require_orders_service = __commonJS({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
await tx.orderStatusEvent.create({
|
await tx.orderStatusEvent.create({
|
||||||
data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminUserId }
|
data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminId: actorAdminUserId }
|
||||||
});
|
});
|
||||||
await this.ledger.syncOrderDebt(orderId, tx);
|
await this.ledger.syncOrderDebt(orderId, tx);
|
||||||
if (newStatus === client_1.OrderStatus.CANCELLED && order.status !== client_1.OrderStatus.CANCELLED) {
|
if (newStatus === client_1.OrderStatus.CANCELLED && order.status !== client_1.OrderStatus.CANCELLED) {
|
||||||
@@ -93827,7 +93827,7 @@ var require_admin_session_service = __commonJS({
|
|||||||
await this.prisma.adminSession.create({
|
await this.prisma.adminSession.create({
|
||||||
data: {
|
data: {
|
||||||
jti: input.jti,
|
jti: input.jti,
|
||||||
adminUserId: input.adminUserId,
|
adminId: input.adminId,
|
||||||
iamSessionId: input.iamSessionId ?? null,
|
iamSessionId: input.iamSessionId ?? null,
|
||||||
iamSub: input.iamSub ?? null,
|
iamSub: input.iamSub ?? null,
|
||||||
ipAddress: input.ipAddress ?? null,
|
ipAddress: input.ipAddress ?? null,
|
||||||
@@ -93863,8 +93863,8 @@ var require_admin_session_service = __commonJS({
|
|||||||
return this.revokeWhere({ iamSub }, reason);
|
return this.revokeWhere({ iamSub }, reason);
|
||||||
}
|
}
|
||||||
/** Revoke every session of a local admin (account disabled, password changed). */
|
/** Revoke every session of a local admin (account disabled, password changed). */
|
||||||
async revokeAllForAdmin(adminUserId, reason = "revoked") {
|
async revokeAllForAdmin(adminId, reason = "revoked") {
|
||||||
return this.revokeWhere({ adminUserId }, reason);
|
return this.revokeWhere({ adminId }, reason);
|
||||||
}
|
}
|
||||||
async revokeWhere(where, reason) {
|
async revokeWhere(where, reason) {
|
||||||
if (!this.enabled)
|
if (!this.enabled)
|
||||||
@@ -94003,7 +94003,7 @@ var require_auth_service = __commonJS({
|
|||||||
if (isStaticAdmin) {
|
if (isStaticAdmin) {
|
||||||
await this.ensureStaticAdmin();
|
await this.ensureStaticAdmin();
|
||||||
}
|
}
|
||||||
const user = await this.prisma.adminUser.findUnique({
|
const user = await this.prisma.admin.findUnique({
|
||||||
where: { mobile: isStaticAdmin ? STATIC_ADMIN_MOBILE : dto.phone },
|
where: { mobile: isStaticAdmin ? STATIC_ADMIN_MOBILE : dto.phone },
|
||||||
include: { userType: { include: { permissions: true } } }
|
include: { userType: { include: { permissions: true } } }
|
||||||
});
|
});
|
||||||
@@ -94023,7 +94023,7 @@ var require_auth_service = __commonJS({
|
|||||||
if (jti) {
|
if (jti) {
|
||||||
await this.sessions.record({
|
await this.sessions.record({
|
||||||
jti,
|
jti,
|
||||||
adminUserId: user.id,
|
adminId: user.id,
|
||||||
iamSessionId: verified.identity?.sessionId ?? null,
|
iamSessionId: verified.identity?.sessionId ?? null,
|
||||||
iamSub: verified.identity?.sub ?? null,
|
iamSub: verified.identity?.sub ?? null,
|
||||||
ipAddress: clientIp ?? null,
|
ipAddress: clientIp ?? null,
|
||||||
@@ -94084,7 +94084,7 @@ var require_auth_service = __commonJS({
|
|||||||
if (!claims.phoneNumber) {
|
if (!claims.phoneNumber) {
|
||||||
throw new common_12.UnauthorizedException("\u0647\u0648\u06CC\u062A \u0645\u0634\u062A\u0631\u06A9 \u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06CC\u0644 \u0646\u062F\u0627\u0631\u062F");
|
throw new common_12.UnauthorizedException("\u0647\u0648\u06CC\u062A \u0645\u0634\u062A\u0631\u06A9 \u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06CC\u0644 \u0646\u062F\u0627\u0631\u062F");
|
||||||
}
|
}
|
||||||
const user = await this.prisma.adminUser.findFirst({
|
const user = await this.prisma.admin.findFirst({
|
||||||
where: { mobile: { in: mobileVariants(claims.phoneNumber) }, isActive: true },
|
where: { mobile: { in: mobileVariants(claims.phoneNumber) }, isActive: true },
|
||||||
include: { userType: { include: { permissions: true } } }
|
include: { userType: { include: { permissions: true } } }
|
||||||
});
|
});
|
||||||
@@ -94128,7 +94128,7 @@ var require_auth_service = __commonJS({
|
|||||||
if (jti) {
|
if (jti) {
|
||||||
await this.sessions.record({
|
await this.sessions.record({
|
||||||
jti,
|
jti,
|
||||||
adminUserId: user.id,
|
adminId: user.id,
|
||||||
iamSessionId: identity?.sessionId ?? null,
|
iamSessionId: identity?.sessionId ?? null,
|
||||||
iamSub: identity?.sub ?? null,
|
iamSub: identity?.sub ?? null,
|
||||||
ipAddress: ip ?? null,
|
ipAddress: ip ?? null,
|
||||||
@@ -94152,7 +94152,7 @@ var require_auth_service = __commonJS({
|
|||||||
const fullAccess = await this.prisma.userType.findUnique({ where: { id: 85 } });
|
const fullAccess = await this.prisma.userType.findUnique({ where: { id: 85 } });
|
||||||
const userTypeId = fullAccess ? 85 : null;
|
const userTypeId = fullAccess ? 85 : null;
|
||||||
const passwordHash = await bcrypt.hash(STATIC_ADMIN_MOBILE, 10);
|
const passwordHash = await bcrypt.hash(STATIC_ADMIN_MOBILE, 10);
|
||||||
await this.prisma.adminUser.upsert({
|
await this.prisma.admin.upsert({
|
||||||
where: { mobile: STATIC_ADMIN_MOBILE },
|
where: { mobile: STATIC_ADMIN_MOBILE },
|
||||||
update: { role: client_1.AdminRole.SUPER_ADMIN, userTypeId, branchId: null, isActive: true },
|
update: { role: client_1.AdminRole.SUPER_ADMIN, userTypeId, branchId: null, isActive: true },
|
||||||
create: {
|
create: {
|
||||||
@@ -94230,7 +94230,7 @@ var require_auth_service = __commonJS({
|
|||||||
* legacy. The guard's own checks use the pre-expanded list from JwtStrategy.
|
* legacy. The guard's own checks use the pre-expanded list from JwtStrategy.
|
||||||
*/
|
*/
|
||||||
async showData(adminId) {
|
async showData(adminId) {
|
||||||
const user = await this.prisma.adminUser.findUnique({
|
const user = await this.prisma.admin.findUnique({
|
||||||
where: { id: adminId },
|
where: { id: adminId },
|
||||||
include: { userType: { include: { permissions: true } } }
|
include: { userType: { include: { permissions: true } } }
|
||||||
});
|
});
|
||||||
@@ -94644,7 +94644,7 @@ var require_jwt_strategy = __commonJS({
|
|||||||
if (!await this.sessions.isActive(payload.jti)) {
|
if (!await this.sessions.isActive(payload.jti)) {
|
||||||
throw new common_12.UnauthorizedException();
|
throw new common_12.UnauthorizedException();
|
||||||
}
|
}
|
||||||
const user = await this.prisma.adminUser.findUnique({
|
const user = await this.prisma.admin.findUnique({
|
||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
include: { userType: { include: { permissions: true } } }
|
include: { userType: { include: { permissions: true } } }
|
||||||
});
|
});
|
||||||
@@ -95418,7 +95418,7 @@ var require_admin_orders_service = __commonJS({
|
|||||||
customer: true,
|
customer: true,
|
||||||
branch: true,
|
branch: true,
|
||||||
items: { include: { productVariant: { include: { product: true } } } },
|
items: { include: { productVariant: { include: { product: true } } } },
|
||||||
statusEvents: { include: { actorAdminUser: true }, orderBy: { createdAt: "asc" } }
|
statusEvents: { include: { actorAdmin: true }, orderBy: { createdAt: "asc" } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!order)
|
if (!order)
|
||||||
@@ -96336,7 +96336,7 @@ var require_admin_branches_service = __commonJS({
|
|||||||
/** Legacy `POST branch/table`. */
|
/** Legacy `POST branch/table`. */
|
||||||
async listBranches(query) {
|
async listBranches(query) {
|
||||||
this.table.assertNotExport(query);
|
this.table.assertNotExport(query);
|
||||||
const aliases = { "user.name": "adminUsers.some.fullName" };
|
const aliases = { "user.name": "admins.some.fullName" };
|
||||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||||
searchable: ["name", "address", "phone"],
|
searchable: ["name", "address", "phone"],
|
||||||
allowed: ["id", "name", "address", "phone", "isActive", "cityId", "city.name"],
|
allowed: ["id", "name", "address", "phone", "isActive", "cityId", "city.name"],
|
||||||
@@ -96351,8 +96351,8 @@ var require_admin_branches_service = __commonJS({
|
|||||||
take,
|
take,
|
||||||
include: {
|
include: {
|
||||||
city: true,
|
city: true,
|
||||||
adminUsers: { where: { isActive: true }, take: 1 },
|
admins: { where: { isActive: true }, take: 1 },
|
||||||
_count: { select: { adminUsers: true, orders: true } }
|
_count: { select: { admins: true, orders: true } }
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
this.prisma.branch.count({ where })
|
this.prisma.branch.count({ where })
|
||||||
@@ -96367,15 +96367,15 @@ var require_admin_branches_service = __commonJS({
|
|||||||
city_id: b.cityId,
|
city_id: b.cityId,
|
||||||
city: b.city ? { id: b.city.id, title: b.city.name } : null,
|
city: b.city ? { id: b.city.id, title: b.city.name } : null,
|
||||||
// legacy column key is `user.name` — the branch manager
|
// legacy column key is `user.name` — the branch manager
|
||||||
user: b.adminUsers[0] ? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName } : null,
|
user: b.admins[0] ? { id: b.admins[0].id, name: b.admins[0].fullName } : null,
|
||||||
users_count: b._count.adminUsers,
|
users_count: b._count.admins,
|
||||||
orders_count: b._count.orders
|
orders_count: b._count.orders
|
||||||
})), total, query, page, limit);
|
})), total, query, page, limit);
|
||||||
}
|
}
|
||||||
async findBranch(id) {
|
async findBranch(id) {
|
||||||
const b = await this.prisma.branch.findUnique({
|
const b = await this.prisma.branch.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { city: true, adminUsers: { where: { isActive: true }, take: 1 } }
|
include: { city: true, admins: { where: { isActive: true }, take: 1 } }
|
||||||
});
|
});
|
||||||
if (!b)
|
if (!b)
|
||||||
throw new common_12.NotFoundException("\u0634\u0639\u0628\u0647 \u06CC\u0627\u0641\u062A \u0646\u0634\u062F");
|
throw new common_12.NotFoundException("\u0634\u0639\u0628\u0647 \u06CC\u0627\u0641\u062A \u0646\u0634\u062F");
|
||||||
@@ -96386,8 +96386,8 @@ var require_admin_branches_service = __commonJS({
|
|||||||
phone: b.phone,
|
phone: b.phone,
|
||||||
is_active: b.isActive,
|
is_active: b.isActive,
|
||||||
city_id: b.cityId,
|
city_id: b.cityId,
|
||||||
user_id: b.adminUsers[0]?.id ?? null,
|
user_id: b.admins[0]?.id ?? null,
|
||||||
user: b.adminUsers[0] ? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName } : null
|
user: b.admins[0] ? { id: b.admins[0].id, name: b.admins[0].fullName } : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/** Legacy `POST branch` — the manager is an EXISTING admin picked by id. */
|
/** Legacy `POST branch` — the manager is an EXISTING admin picked by id. */
|
||||||
@@ -96436,12 +96436,12 @@ var require_admin_branches_service = __commonJS({
|
|||||||
*/
|
*/
|
||||||
async assignBranchManager(branchId, userId) {
|
async assignBranchManager(branchId, userId) {
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.adminUser.updateMany({ where: { branchId }, data: { branchId: null } });
|
await tx.admin.updateMany({ where: { branchId }, data: { branchId: null } });
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const user = await tx.adminUser.findUnique({ where: { id: userId } });
|
const user = await tx.admin.findUnique({ where: { id: userId } });
|
||||||
if (!user)
|
if (!user)
|
||||||
throw new common_12.NotFoundException("\u06A9\u0627\u0631\u0628\u0631 \u0627\u0646\u062A\u062E\u0627\u0628\u200C\u0634\u062F\u0647 \u06CC\u0627\u0641\u062A \u0646\u0634\u062F");
|
throw new common_12.NotFoundException("\u06A9\u0627\u0631\u0628\u0631 \u0627\u0646\u062A\u062E\u0627\u0628\u200C\u0634\u062F\u0647 \u06CC\u0627\u0641\u062A \u0646\u0634\u062F");
|
||||||
await tx.adminUser.update({ where: { id: userId }, data: { branchId } });
|
await tx.admin.update({ where: { id: userId }, data: { branchId } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -96564,25 +96564,25 @@ var require_admin_branches_service = __commonJS({
|
|||||||
throw new common_12.BadRequestException("\u0645\u0648\u0628\u0627\u06CC\u0644 \u0633\u0631\u067E\u0631\u0633\u062A \u06A9\u0627\u0631\u06AF\u0627\u0647 \u0627\u0644\u0632\u0627\u0645\u06CC \u0627\u0633\u062A");
|
throw new common_12.BadRequestException("\u0645\u0648\u0628\u0627\u06CC\u0644 \u0633\u0631\u067E\u0631\u0633\u062A \u06A9\u0627\u0631\u06AF\u0627\u0647 \u0627\u0644\u0632\u0627\u0645\u06CC \u0627\u0633\u062A");
|
||||||
const fullName = `${fname} ${lname}`.trim();
|
const fullName = `${fname} ${lname}`.trim();
|
||||||
if (currentId) {
|
if (currentId) {
|
||||||
const current = await this.prisma.adminUser.findUnique({ where: { id: currentId } });
|
const current = await this.prisma.admin.findUnique({ where: { id: currentId } });
|
||||||
if (current) {
|
if (current) {
|
||||||
if (phone !== current.mobile) {
|
if (phone !== current.mobile) {
|
||||||
const clash = await this.prisma.adminUser.findUnique({ where: { mobile: phone } });
|
const clash = await this.prisma.admin.findUnique({ where: { mobile: phone } });
|
||||||
if (clash && clash.id !== currentId) {
|
if (clash && clash.id !== currentId) {
|
||||||
throw new common_12.BadRequestException("\u0627\u06CC\u0646 \u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06CC\u0644 \u0642\u0628\u0644\u0627\u064B \u062B\u0628\u062A \u0634\u062F\u0647 \u0627\u0633\u062A");
|
throw new common_12.BadRequestException("\u0627\u06CC\u0646 \u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06CC\u0644 \u0642\u0628\u0644\u0627\u064B \u062B\u0628\u062A \u0634\u062F\u0647 \u0627\u0633\u062A");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.prisma.adminUser.update({
|
await this.prisma.admin.update({
|
||||||
where: { id: currentId },
|
where: { id: currentId },
|
||||||
data: { fullName, mobile: phone }
|
data: { fullName, mobile: phone }
|
||||||
});
|
});
|
||||||
return currentId;
|
return currentId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const existing = await this.prisma.adminUser.findUnique({ where: { mobile: phone } });
|
const existing = await this.prisma.admin.findUnique({ where: { mobile: phone } });
|
||||||
if (existing)
|
if (existing)
|
||||||
return existing.id;
|
return existing.id;
|
||||||
const created = await this.prisma.adminUser.create({
|
const created = await this.prisma.admin.create({
|
||||||
data: {
|
data: {
|
||||||
fullName,
|
fullName,
|
||||||
mobile: phone,
|
mobile: phone,
|
||||||
@@ -96605,7 +96605,7 @@ var require_admin_branches_service = __commonJS({
|
|||||||
}
|
}
|
||||||
/** The admin picker the branch form uses — legacy `GET user/list`. */
|
/** The admin picker the branch form uses — legacy `GET user/list`. */
|
||||||
async userList() {
|
async userList() {
|
||||||
const rows = await this.prisma.adminUser.findMany({
|
const rows = await this.prisma.admin.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: { fullName: "asc" },
|
orderBy: { fullName: "asc" },
|
||||||
select: { id: true, fullName: true, mobile: true, branchId: true }
|
select: { id: true, fullName: true, mobile: true, branchId: true }
|
||||||
@@ -97157,10 +97157,9 @@ var require_admin_customers_service = __commonJS({
|
|||||||
* NOTE every non-archive list also excludes archived rows, so archiving a
|
* NOTE every non-archive list also excludes archived rows, so archiving a
|
||||||
* customer removes them from ALL type lists at once.
|
* customer removes them from ALL type lists at once.
|
||||||
*
|
*
|
||||||
* «در انتظار» vs «عمده» must not show the same rows. App self-registration
|
* «در انتظار» and «عمده» MAY show the same customer, exactly as in the
|
||||||
* creates APP_SHOPKEEPER + isActive=false + STEP_ONE_SUBMITTED, and step-one
|
* reference: getCustomer has no verification filter, so a customer awaiting
|
||||||
* accept sets isActive=true — so pending is inactive+pending-step only, and
|
* approval is in both lists.
|
||||||
* the main omde list excludes those same inactive pending rows.
|
|
||||||
*/
|
*/
|
||||||
scopeWhere(scope) {
|
scopeWhere(scope) {
|
||||||
const pendingVerification = {
|
const pendingVerification = {
|
||||||
@@ -97186,8 +97185,7 @@ var require_admin_customers_service = __commonJS({
|
|||||||
return {
|
return {
|
||||||
kind: client_1.CustomerKind.APP_SHOPKEEPER,
|
kind: client_1.CustomerKind.APP_SHOPKEEPER,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
...notExpertTradingAccount,
|
...notExpertTradingAccount
|
||||||
NOT: pendingVerification
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100173,7 +100171,7 @@ var require_admin_documents_service = __commonJS({
|
|||||||
orderBy,
|
orderBy,
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
include: { customer: true, postedByAdminUser: true }
|
include: { customer: true, postedByAdmin: true }
|
||||||
}),
|
}),
|
||||||
this.prisma.ledgerEntry.count({ where: scoped })
|
this.prisma.ledgerEntry.count({ where: scoped })
|
||||||
]);
|
]);
|
||||||
@@ -100212,7 +100210,7 @@ var require_admin_documents_service = __commonJS({
|
|||||||
lname: e.customer.lastName ?? "",
|
lname: e.customer.lastName ?? "",
|
||||||
mobile: e.customer.mobile
|
mobile: e.customer.mobile
|
||||||
},
|
},
|
||||||
posted_by: e.postedByAdminUser ? e.postedByAdminUser.fullName : null,
|
posted_by: e.postedByAdmin ? e.postedByAdmin.fullName : null,
|
||||||
caj: e.createdAt,
|
caj: e.createdAt,
|
||||||
uaj: e.createdAt
|
uaj: e.createdAt
|
||||||
};
|
};
|
||||||
@@ -100221,7 +100219,7 @@ var require_admin_documents_service = __commonJS({
|
|||||||
async findOne(id) {
|
async findOne(id) {
|
||||||
const e = await this.prisma.ledgerEntry.findUnique({
|
const e = await this.prisma.ledgerEntry.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { customer: true, postedByAdminUser: true }
|
include: { customer: true, postedByAdmin: true }
|
||||||
});
|
});
|
||||||
if (!e)
|
if (!e)
|
||||||
throw new common_12.NotFoundException("\u0633\u0646\u062F \u06CC\u0627\u0641\u062A \u0646\u0634\u062F");
|
throw new common_12.NotFoundException("\u0633\u0646\u062F \u06CC\u0627\u0641\u062A \u0646\u0634\u062F");
|
||||||
@@ -100250,7 +100248,7 @@ var require_admin_documents_service = __commonJS({
|
|||||||
karatPerMille: data.ayar ? Number(data.ayar) : void 0,
|
karatPerMille: data.ayar ? Number(data.ayar) : void 0,
|
||||||
pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : void 0,
|
pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : void 0,
|
||||||
note: data.note ?? void 0,
|
note: data.note ?? void 0,
|
||||||
postedByAdminUserId: actorAdminUserId
|
postedByAdminId: actorAdminUserId
|
||||||
});
|
});
|
||||||
await this.prisma.ledgerEntry.update({
|
await this.prisma.ledgerEntry.update({
|
||||||
where: { id: entry.id },
|
where: { id: entry.id },
|
||||||
@@ -101292,7 +101290,7 @@ var require_admin_users_service = __commonJS({
|
|||||||
caj: "createdAt",
|
caj: "createdAt",
|
||||||
active: "isActive"
|
active: "isActive"
|
||||||
};
|
};
|
||||||
/** Legacy `GET users` — the admin user grid. */
|
/** Legacy `GET users` — the admin user grid (panel staff only). */
|
||||||
async list(query) {
|
async list(query) {
|
||||||
this.table.assertNotExport(query);
|
this.table.assertNotExport(query);
|
||||||
const mapField = (f) => f ? AdminUsersService_1.FIELD_ALIASES[f] ?? f : f;
|
const mapField = (f) => f ? AdminUsersService_1.FIELD_ALIASES[f] ?? f : f;
|
||||||
@@ -101319,14 +101317,14 @@ var require_admin_users_service = __commonJS({
|
|||||||
AND: [where, { mobile: { not: super_admin_1.SUPER_ADMIN_MOBILE } }]
|
AND: [where, { mobile: { not: super_admin_1.SUPER_ADMIN_MOBILE } }]
|
||||||
};
|
};
|
||||||
const [rows, total] = await Promise.all([
|
const [rows, total] = await Promise.all([
|
||||||
this.prisma.adminUser.findMany({
|
this.prisma.admin.findMany({
|
||||||
where: visible,
|
where: visible,
|
||||||
orderBy,
|
orderBy,
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
include: { userType: true, branch: true, avatarFile: true }
|
include: { userType: true, branch: true }
|
||||||
}),
|
}),
|
||||||
this.prisma.adminUser.count({ where: visible })
|
this.prisma.admin.count({ where: visible })
|
||||||
]);
|
]);
|
||||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||||
}
|
}
|
||||||
@@ -101345,20 +101343,20 @@ var require_admin_users_service = __commonJS({
|
|||||||
user_type: u.userType ? { id: u.userType.id, title: u.userType.title } : null,
|
user_type: u.userType ? { id: u.userType.id, title: u.userType.title } : null,
|
||||||
branch_id: u.branchId,
|
branch_id: u.branchId,
|
||||||
branch: u.branch ? { id: u.branch.id, name: u.branch.name } : null,
|
branch: u.branch ? { id: u.branch.id, name: u.branch.name } : null,
|
||||||
percent: u.wagePercent,
|
percent: u.wagePercent ?? null,
|
||||||
active: u.isActive ? 1 : -1,
|
active: u.isActive ? 1 : -1,
|
||||||
is_active: u.isActive,
|
is_active: u.isActive,
|
||||||
caj: u.createdAt,
|
caj: u.createdAt,
|
||||||
// Sales-agent fields (ReagentCustomer.vue) — set only for role=SALES_EXPERT,
|
// Sales-agent fields (ReagentCustomer.vue) — set only for role=SALES_EXPERT,
|
||||||
// null for every other admin. Same shapes admin-customers.service.ts used
|
// null for every admin. Same shapes admin-customers.service.ts used
|
||||||
// when this data still lived on Customer, so the form binds unchanged.
|
// when this data still lived on Customer, so the form binds unchanged.
|
||||||
address: u.address,
|
address: u.address ?? null,
|
||||||
store_name: u.storeName,
|
store_name: u.storeName ?? null,
|
||||||
pasazh_name: u.pasazhName,
|
pasazh_name: u.pasazhName ?? null,
|
||||||
mcode: u.memberCode,
|
mcode: u.memberCode ?? null,
|
||||||
image_id: u.avatarFileId,
|
image_id: u.avatarFileId ?? null,
|
||||||
image: u.avatarFile ? { id: u.avatarFile.id, image: u.avatarFile.url } : null,
|
image: u.avatarFile ? { id: u.avatarFile.id, image: u.avatarFile.url } : null,
|
||||||
// D17 — from SalesExpertProfile. Null for a non-expert, and null-safe for
|
// D17 — from SalesExpertProfile. Null for an admin, and null-safe for
|
||||||
// an expert whose profile row is somehow missing.
|
// an expert whose profile row is somehow missing.
|
||||||
city_id: u.salesExpertProfile?.cityId ?? null,
|
city_id: u.salesExpertProfile?.cityId ?? null,
|
||||||
city: u.salesExpertProfile?.city ? { id: u.salesExpertProfile.city.id, title: u.salesExpertProfile.city.name } : null,
|
city: u.salesExpertProfile?.city ? { id: u.salesExpertProfile.city.id, title: u.salesExpertProfile.city.name } : null,
|
||||||
@@ -101367,9 +101365,22 @@ var require_admin_users_service = __commonJS({
|
|||||||
ncode: u.salesExpertProfile?.nationalCode ?? null
|
ncode: u.salesExpertProfile?.nationalCode ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async findOne(id) {
|
/** A panel admin by id, or null. The support super admin is never found. */
|
||||||
const u = await this.prisma.adminUser.findUnique({
|
async findAdmin(id) {
|
||||||
|
const admin = await this.prisma.admin.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
include: { userType: true, branch: true }
|
||||||
|
});
|
||||||
|
return admin && !(0, super_admin_1.isSuperAdmin)(admin.mobile) ? admin : null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* A sales expert by id, or null. `role` is part of the lookup on purpose:
|
||||||
|
* "AdminUser" still holds the pre-split copies of the admins (dormant), and
|
||||||
|
* those must never be reachable as experts either.
|
||||||
|
*/
|
||||||
|
async findExpert(id) {
|
||||||
|
const expert = await this.prisma.adminUser.findFirst({
|
||||||
|
where: { id, role: client_1.AdminRole.SALES_EXPERT },
|
||||||
// D17 — the edit form needs the sales-expert fields too.
|
// D17 — the edit form needs the sales-expert fields too.
|
||||||
include: {
|
include: {
|
||||||
userType: true,
|
userType: true,
|
||||||
@@ -101378,9 +101389,16 @@ var require_admin_users_service = __commonJS({
|
|||||||
salesExpertProfile: { include: { city: true } }
|
salesExpertProfile: { include: { city: true } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!u || (0, super_admin_1.isSuperAdmin)(u.mobile))
|
return expert && !(0, super_admin_1.isSuperAdmin)(expert.mobile) ? expert : null;
|
||||||
|
}
|
||||||
|
async findOne(id) {
|
||||||
|
const admin = await this.findAdmin(id);
|
||||||
|
if (admin)
|
||||||
|
return this.toRow(admin);
|
||||||
|
const expert = await this.findExpert(id);
|
||||||
|
if (expert)
|
||||||
|
return this.toRow(expert);
|
||||||
throw new common_12.NotFoundException("\u06A9\u0627\u0631\u0628\u0631 \u06CC\u0627\u0641\u062A \u0646\u0634\u062F");
|
throw new common_12.NotFoundException("\u06A9\u0627\u0631\u0628\u0631 \u06CC\u0627\u0641\u062A \u0646\u0634\u062F");
|
||||||
return this.toRow(u);
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Legacy `POST users`.
|
* Legacy `POST users`.
|
||||||
@@ -101389,6 +101407,8 @@ var require_admin_users_service = __commonJS({
|
|||||||
* caller omitted it, which silently turned every admin created without an
|
* caller omitted it, which silently turned every admin created without an
|
||||||
* explicit role into a sales agent regardless of the access level ("نوع
|
* explicit role into a sales agent regardless of the access level ("نوع
|
||||||
* کاربری") actually chosen. UserCreate.vue now always sends one.
|
* کاربری") actually chosen. UserCreate.vue now always sends one.
|
||||||
|
*
|
||||||
|
* SALES_EXPERT goes to the `AdminUser` table, every other role to `Admin`.
|
||||||
*/
|
*/
|
||||||
async create(data) {
|
async create(data) {
|
||||||
const firstName = String(data.fname ?? data.name ?? "").trim();
|
const firstName = String(data.fname ?? data.name ?? "").trim();
|
||||||
@@ -101405,6 +101425,31 @@ var require_admin_users_service = __commonJS({
|
|||||||
throw new common_12.BadRequestException("\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0627\u0644\u0632\u0627\u0645\u06CC \u0627\u0633\u062A");
|
throw new common_12.BadRequestException("\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0627\u0644\u0632\u0627\u0645\u06CC \u0627\u0633\u062A");
|
||||||
if (!role)
|
if (!role)
|
||||||
throw new common_12.BadRequestException("\u0646\u0642\u0634 \u06A9\u0627\u0631\u0628\u0631 \u0627\u0644\u0632\u0627\u0645\u06CC \u0627\u0633\u062A");
|
throw new common_12.BadRequestException("\u0646\u0642\u0634 \u06A9\u0627\u0631\u0628\u0631 \u0627\u0644\u0632\u0627\u0645\u06CC \u0627\u0633\u062A");
|
||||||
|
if (!Object.values(client_1.AdminRole).includes(role))
|
||||||
|
throw new common_12.BadRequestException("\u0646\u0642\u0634 \u06A9\u0627\u0631\u0628\u0631 \u0645\u0639\u062A\u0628\u0631 \u0646\u06CC\u0633\u062A");
|
||||||
|
if (role === client_1.AdminRole.SALES_EXPERT) {
|
||||||
|
return this.createExpert({ data, firstName, lastName, fullName, mobile, password });
|
||||||
|
}
|
||||||
|
const existing = await this.prisma.admin.findUnique({ where: { mobile } });
|
||||||
|
if (existing)
|
||||||
|
throw new common_12.BadRequestException("\u0627\u06CC\u0646 \u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06CC\u0644 \u0642\u0628\u0644\u0627\u064B \u062B\u0628\u062A \u0634\u062F\u0647 \u0627\u0633\u062A");
|
||||||
|
return this.prisma.admin.create({
|
||||||
|
data: {
|
||||||
|
fullName,
|
||||||
|
firstName: firstName || null,
|
||||||
|
lastName: lastName || null,
|
||||||
|
mobile,
|
||||||
|
passwordHash: await bcrypt.hash(password, 10),
|
||||||
|
role,
|
||||||
|
userTypeId: data.ut !== void 0 && data.ut !== null ? Number(data.ut) : null,
|
||||||
|
branchId: data.branch_id || null,
|
||||||
|
isActive: data.is_active !== void 0 ? Boolean(data.is_active) : true
|
||||||
|
},
|
||||||
|
include: { userType: true, branch: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async createExpert(input) {
|
||||||
|
const { data, firstName, lastName, fullName, mobile, password } = input;
|
||||||
const existing = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
const existing = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
||||||
if (existing)
|
if (existing)
|
||||||
throw new common_12.BadRequestException("\u0627\u06CC\u0646 \u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06CC\u0644 \u0642\u0628\u0644\u0627\u064B \u062B\u0628\u062A \u0634\u062F\u0647 \u0627\u0633\u062A");
|
throw new common_12.BadRequestException("\u0627\u06CC\u0646 \u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06CC\u0644 \u0642\u0628\u0644\u0627\u064B \u062B\u0628\u062A \u0634\u062F\u0647 \u0627\u0633\u062A");
|
||||||
@@ -101415,13 +101460,12 @@ var require_admin_users_service = __commonJS({
|
|||||||
lastName: lastName || null,
|
lastName: lastName || null,
|
||||||
mobile,
|
mobile,
|
||||||
passwordHash: await bcrypt.hash(password, 10),
|
passwordHash: await bcrypt.hash(password, 10),
|
||||||
role,
|
role: client_1.AdminRole.SALES_EXPERT,
|
||||||
userTypeId: data.ut !== void 0 && data.ut !== null ? Number(data.ut) : null,
|
userTypeId: data.ut !== void 0 && data.ut !== null ? Number(data.ut) : null,
|
||||||
branchId: data.branch_id || null,
|
branchId: data.branch_id || null,
|
||||||
wagePercent: data.percent !== void 0 && data.percent !== null ? new client_1.Prisma.Decimal(String(data.percent)) : null,
|
wagePercent: data.percent !== void 0 && data.percent !== null ? new client_1.Prisma.Decimal(String(data.percent)) : null,
|
||||||
isActive: data.is_active !== void 0 ? Boolean(data.is_active) : true,
|
isActive: data.is_active !== void 0 ? Boolean(data.is_active) : true,
|
||||||
// Sales-agent-only fields (ReagentCustomer.vue); undefined for every
|
// Sales-agent-only fields (ReagentCustomer.vue).
|
||||||
// other role, which Prisma leaves at column default (null).
|
|
||||||
address: data.address || null,
|
address: data.address || null,
|
||||||
storeName: data.store_name || null,
|
storeName: data.store_name || null,
|
||||||
pasazhName: data.pasazh_name || null,
|
pasazhName: data.pasazh_name || null,
|
||||||
@@ -101429,7 +101473,7 @@ var require_admin_users_service = __commonJS({
|
|||||||
avatarFileId: data.image_id || null,
|
avatarFileId: data.image_id || null,
|
||||||
// D17 — a sales expert always gets its profile row, so the grid's city
|
// D17 — a sales expert always gets its profile row, so the grid's city
|
||||||
// and credit-limit columns have somewhere to read from immediately.
|
// and credit-limit columns have somewhere to read from immediately.
|
||||||
...role === "SALES_EXPERT" ? { salesExpertProfile: { create: this.salesExpertProfileData(data) } } : {}
|
salesExpertProfile: { create: this.salesExpertProfileData(data) }
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
userType: true,
|
userType: true,
|
||||||
@@ -101456,9 +101500,65 @@ var require_admin_users_service = __commonJS({
|
|||||||
}
|
}
|
||||||
/** Legacy `PUT users/{id}`. */
|
/** Legacy `PUT users/{id}`. */
|
||||||
async update(id, data) {
|
async update(id, data) {
|
||||||
const existing = await this.prisma.adminUser.findUnique({ where: { id } });
|
const admin = await this.findAdmin(id);
|
||||||
if (!existing || (0, super_admin_1.isSuperAdmin)(existing.mobile))
|
if (admin)
|
||||||
|
return this.updateAdmin(admin, data);
|
||||||
|
const expert = await this.findExpert(id);
|
||||||
|
if (expert)
|
||||||
|
return this.updateExpert(expert, data);
|
||||||
throw new common_12.NotFoundException("\u06A9\u0627\u0631\u0628\u0631 \u06CC\u0627\u0641\u062A \u0646\u0634\u062F");
|
throw new common_12.NotFoundException("\u06A9\u0627\u0631\u0628\u0631 \u06CC\u0627\u0641\u062A \u0646\u0634\u062F");
|
||||||
|
}
|
||||||
|
async updateAdmin(existing, data) {
|
||||||
|
const id = existing.id;
|
||||||
|
const patch = {};
|
||||||
|
if (data.fname !== void 0 || data.lname !== void 0) {
|
||||||
|
const firstName = String(data.fname ?? existing.firstName ?? "").trim();
|
||||||
|
const lastName = String(data.lname ?? existing.lastName ?? "").trim();
|
||||||
|
patch.firstName = firstName || null;
|
||||||
|
patch.lastName = lastName || null;
|
||||||
|
} else if (data.name !== void 0) {
|
||||||
|
patch.fullName = String(data.name);
|
||||||
|
}
|
||||||
|
if (data.mobile !== void 0) {
|
||||||
|
const mobile = (0, digits_1.faToEn)(String(data.mobile));
|
||||||
|
if (mobile !== existing.mobile) {
|
||||||
|
const clash = await this.prisma.admin.findUnique({ where: { mobile } });
|
||||||
|
if (clash)
|
||||||
|
throw new common_12.BadRequestException("\u0627\u06CC\u0646 \u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06CC\u0644 \u0642\u0628\u0644\u0627\u064B \u062B\u0628\u062A \u0634\u062F\u0647 \u0627\u0633\u062A");
|
||||||
|
}
|
||||||
|
patch.mobile = mobile;
|
||||||
|
}
|
||||||
|
if (data.password)
|
||||||
|
patch.passwordHash = await bcrypt.hash(String(data.password), 10);
|
||||||
|
if (data.role !== void 0) {
|
||||||
|
const role = data.role;
|
||||||
|
if (!Object.values(client_1.AdminRole).includes(role))
|
||||||
|
throw new common_12.BadRequestException("\u0646\u0642\u0634 \u06A9\u0627\u0631\u0628\u0631 \u0645\u0639\u062A\u0628\u0631 \u0646\u06CC\u0633\u062A");
|
||||||
|
if (role === client_1.AdminRole.SALES_EXPERT) {
|
||||||
|
throw new common_12.BadRequestException("\u0646\u0642\u0634 \u06A9\u0627\u0631\u0634\u0646\u0627\u0633 \u0641\u0631\u0648\u0634 \u0628\u0631\u0627\u06CC \u0627\u062F\u0645\u06CC\u0646 \u0642\u0627\u0628\u0644 \u062A\u0646\u0638\u06CC\u0645 \u0646\u06CC\u0633\u062A\u061B \u06A9\u0627\u0631\u0634\u0646\u0627\u0633 \u0631\u0627 \u0627\u0632 \u0628\u062E\u0634 \u06A9\u0627\u0631\u0634\u0646\u0627\u0633\u0627\u0646 \u0628\u0633\u0627\u0632\u06CC\u062F");
|
||||||
|
}
|
||||||
|
patch.role = role;
|
||||||
|
}
|
||||||
|
if (data.ut !== void 0) {
|
||||||
|
patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true };
|
||||||
|
}
|
||||||
|
if (data.branch_id !== void 0) {
|
||||||
|
patch.branch = data.branch_id ? { connect: { id: String(data.branch_id) } } : { disconnect: true };
|
||||||
|
}
|
||||||
|
if (data.is_active !== void 0)
|
||||||
|
patch.isActive = Boolean(data.is_active);
|
||||||
|
const updated = await this.prisma.admin.update({
|
||||||
|
where: { id },
|
||||||
|
data: patch,
|
||||||
|
include: { userType: true, branch: true }
|
||||||
|
});
|
||||||
|
if (patch.isActive === false || patch.passwordHash !== void 0) {
|
||||||
|
await this.sessions.revokeAllForAdmin(id, patch.isActive === false ? "account_disabled" : "password_changed");
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
async updateExpert(existing, data) {
|
||||||
|
const id = existing.id;
|
||||||
const patch = {};
|
const patch = {};
|
||||||
if (data.fname !== void 0 || data.lname !== void 0) {
|
if (data.fname !== void 0 || data.lname !== void 0) {
|
||||||
const firstName = String(data.fname ?? existing.firstName ?? "").trim();
|
const firstName = String(data.fname ?? existing.firstName ?? "").trim();
|
||||||
@@ -101479,8 +101579,9 @@ var require_admin_users_service = __commonJS({
|
|||||||
}
|
}
|
||||||
if (data.password)
|
if (data.password)
|
||||||
patch.passwordHash = await bcrypt.hash(String(data.password), 10);
|
patch.passwordHash = await bcrypt.hash(String(data.password), 10);
|
||||||
if (data.role !== void 0)
|
if (data.role !== void 0 && data.role !== client_1.AdminRole.SALES_EXPERT) {
|
||||||
patch.role = data.role;
|
throw new common_12.BadRequestException("\u0646\u0642\u0634 \u06A9\u0627\u0631\u0634\u0646\u0627\u0633 \u0641\u0631\u0648\u0634 \u0642\u0627\u0628\u0644 \u062A\u063A\u06CC\u06CC\u0631 \u0646\u06CC\u0633\u062A");
|
||||||
|
}
|
||||||
if (data.ut !== void 0) {
|
if (data.ut !== void 0) {
|
||||||
patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true };
|
patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true };
|
||||||
}
|
}
|
||||||
@@ -101503,9 +101604,8 @@ var require_admin_users_service = __commonJS({
|
|||||||
if (data.image_id !== void 0) {
|
if (data.image_id !== void 0) {
|
||||||
patch.avatarFile = data.image_id ? { connect: { id: String(data.image_id) } } : { disconnect: true };
|
patch.avatarFile = data.image_id ? { connect: { id: String(data.image_id) } } : { disconnect: true };
|
||||||
}
|
}
|
||||||
const effectiveRole = patch.role ?? existing.role;
|
|
||||||
const touchesProfile = data.city_id !== void 0 || data.gram_level !== void 0 || data.phone !== void 0 || data.ncode !== void 0;
|
const touchesProfile = data.city_id !== void 0 || data.gram_level !== void 0 || data.phone !== void 0 || data.ncode !== void 0;
|
||||||
if (effectiveRole === "SALES_EXPERT" && (touchesProfile || patch.role !== void 0)) {
|
if (touchesProfile) {
|
||||||
const profile = this.salesExpertProfileData(data);
|
const profile = this.salesExpertProfileData(data);
|
||||||
patch.salesExpertProfile = {
|
patch.salesExpertProfile = {
|
||||||
upsert: {
|
upsert: {
|
||||||
@@ -101521,7 +101621,8 @@ var require_admin_users_service = __commonJS({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const updated = await this.prisma.adminUser.update({
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.adminUser.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: patch,
|
data: patch,
|
||||||
include: {
|
include: {
|
||||||
@@ -101531,10 +101632,58 @@ var require_admin_users_service = __commonJS({
|
|||||||
salesExpertProfile: { include: { city: true } }
|
salesExpertProfile: { include: { city: true } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (patch.isActive === false || patch.passwordHash !== void 0) {
|
await this.syncTradingAccount(tx, updated, existing.mobile, {
|
||||||
await this.sessions.revokeAllForAdmin(id, patch.isActive === false ? "account_disabled" : "password_changed");
|
name: patch.firstName !== void 0 || patch.lastName !== void 0 || patch.fullName !== void 0,
|
||||||
}
|
mobile: patch.mobile !== void 0,
|
||||||
|
gramLevel: data.gram_level !== void 0,
|
||||||
|
percent: data.percent !== void 0
|
||||||
|
});
|
||||||
return updated;
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Mirror an expert edit onto their trading Customer row (D21).
|
||||||
|
*
|
||||||
|
* The apps show an expert their OWN account through that row (/customer/me),
|
||||||
|
* which is seeded from the expert once, at first sign-in, and was never
|
||||||
|
* updated again — so a name or credit-limit change made here never reached
|
||||||
|
* the app. In the reference the agent and that account were ONE row
|
||||||
|
* (customers.type=1), so an edit changed both; this restores that.
|
||||||
|
*
|
||||||
|
* Only the fields the operator actually changed are written. The mobile is
|
||||||
|
* copied only when no other customer holds it (the column is unique);
|
||||||
|
* otherwise it is left and logged rather than failing the expert's save.
|
||||||
|
*/
|
||||||
|
async syncTradingAccount(tx, expert, previousMobile, changed) {
|
||||||
|
const tradingId = expert.salesExpertProfile?.tradingCustomerId;
|
||||||
|
if (!tradingId)
|
||||||
|
return;
|
||||||
|
const data = {};
|
||||||
|
if (changed.name) {
|
||||||
|
if (expert.firstName !== null || expert.lastName !== null) {
|
||||||
|
data.firstName = expert.firstName;
|
||||||
|
data.lastName = expert.lastName;
|
||||||
|
} else {
|
||||||
|
data.fullName = expert.fullName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed.mobile && expert.mobile !== previousMobile) {
|
||||||
|
const holder = await tx.customer.findUnique({ where: { mobile: expert.mobile }, select: { id: true } });
|
||||||
|
if (!holder) {
|
||||||
|
data.mobile = expert.mobile;
|
||||||
|
} else if (holder.id !== tradingId) {
|
||||||
|
this.logger.warn(`Expert ${expert.id}: new mobile ${expert.mobile} already belongs to customer ${holder.id}; trading account ${tradingId} keeps its old mobile.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed.gramLevel && expert.salesExpertProfile) {
|
||||||
|
data.goldCreditLimitGrams = expert.salesExpertProfile.gramLevel;
|
||||||
|
}
|
||||||
|
if (changed.percent) {
|
||||||
|
data.makingChargeOffsetPercent = expert.wagePercent ?? new client_1.Prisma.Decimal(0);
|
||||||
|
}
|
||||||
|
if (Object.keys(data).length > 0) {
|
||||||
|
await tx.customer.update({ where: { id: tradingId }, data });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Legacy `DELETE users/{id}`.
|
* Legacy `DELETE users/{id}`.
|
||||||
@@ -101544,21 +101693,28 @@ var require_admin_users_service = __commonJS({
|
|||||||
* audit trail. Deactivation is offered instead. Legacy deleted regardless.
|
* audit trail. Deactivation is offered instead. Legacy deleted regardless.
|
||||||
*/
|
*/
|
||||||
async remove(id, actorId) {
|
async remove(id, actorId) {
|
||||||
const u = await this.prisma.adminUser.findUnique({
|
const admin = await this.prisma.admin.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
_count: { select: { postedLedgerEntries: true, orderStatusEvents: true } }
|
_count: { select: { postedLedgerEntries: true, orderStatusEvents: true } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!u || (0, super_admin_1.isSuperAdmin)(u.mobile))
|
if (admin) {
|
||||||
|
if ((0, super_admin_1.isSuperAdmin)(admin.mobile))
|
||||||
throw new common_12.NotFoundException("\u06A9\u0627\u0631\u0628\u0631 \u06CC\u0627\u0641\u062A \u0646\u0634\u062F");
|
throw new common_12.NotFoundException("\u06A9\u0627\u0631\u0628\u0631 \u06CC\u0627\u0641\u062A \u0646\u0634\u062F");
|
||||||
if (id === actorId) {
|
if (id === actorId) {
|
||||||
throw new common_12.BadRequestException("\u0646\u0645\u06CC\u200C\u062A\u0648\u0627\u0646\u06CC\u062F \u062D\u0633\u0627\u0628 \u06A9\u0627\u0631\u0628\u0631\u06CC \u062E\u0648\u062F\u062A\u0627\u0646 \u0631\u0627 \u062D\u0630\u0641 \u06A9\u0646\u06CC\u062F");
|
throw new common_12.BadRequestException("\u0646\u0645\u06CC\u200C\u062A\u0648\u0627\u0646\u06CC\u062F \u062D\u0633\u0627\u0628 \u06A9\u0627\u0631\u0628\u0631\u06CC \u062E\u0648\u062F\u062A\u0627\u0646 \u0631\u0627 \u062D\u0630\u0641 \u06A9\u0646\u06CC\u062F");
|
||||||
}
|
}
|
||||||
const refs = u._count.postedLedgerEntries + u._count.orderStatusEvents;
|
const refs = admin._count.postedLedgerEntries + admin._count.orderStatusEvents;
|
||||||
if (refs > 0) {
|
if (refs > 0) {
|
||||||
throw new common_12.BadRequestException(`\u062D\u0630\u0641 \u0627\u06CC\u0646 \u06A9\u0627\u0631\u0628\u0631 \u0645\u0645\u06A9\u0646 \u0646\u06CC\u0633\u062A\u060C \u0686\u0648\u0646 ${refs} \u0633\u0646\u062F \u06CC\u0627 \u062A\u063A\u06CC\u06CC\u0631 \u0648\u0636\u0639\u06CC\u062A \u0628\u0647 \u0646\u0627\u0645 \u0627\u0648 \u062B\u0628\u062A \u0634\u062F\u0647 \u0627\u0633\u062A. \u0645\u06CC\u200C\u062A\u0648\u0627\u0646\u06CC\u062F \u063A\u06CC\u0631\u0641\u0639\u0627\u0644\u0634 \u06A9\u0646\u06CC\u062F.`);
|
throw new common_12.BadRequestException(`\u062D\u0630\u0641 \u0627\u06CC\u0646 \u06A9\u0627\u0631\u0628\u0631 \u0645\u0645\u06A9\u0646 \u0646\u06CC\u0633\u062A\u060C \u0686\u0648\u0646 ${refs} \u0633\u0646\u062F \u06CC\u0627 \u062A\u063A\u06CC\u06CC\u0631 \u0648\u0636\u0639\u06CC\u062A \u0628\u0647 \u0646\u0627\u0645 \u0627\u0648 \u062B\u0628\u062A \u0634\u062F\u0647 \u0627\u0633\u062A. \u0645\u06CC\u200C\u062A\u0648\u0627\u0646\u06CC\u062F \u063A\u06CC\u0631\u0641\u0639\u0627\u0644\u0634 \u06A9\u0646\u06CC\u062F.`);
|
||||||
}
|
}
|
||||||
|
await this.prisma.admin.delete({ where: { id } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
const expert = await this.findExpert(id);
|
||||||
|
if (!expert)
|
||||||
|
throw new common_12.NotFoundException("\u06A9\u0627\u0631\u0628\u0631 \u06CC\u0627\u0641\u062A \u0646\u0634\u062F");
|
||||||
await this.prisma.adminUser.delete({ where: { id } });
|
await this.prisma.adminUser.delete({ where: { id } });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -101567,12 +101723,12 @@ var require_admin_users_service = __commonJS({
|
|||||||
async listUserTypes() {
|
async listUserTypes() {
|
||||||
const rows = await this.prisma.userType.findMany({
|
const rows = await this.prisma.userType.findMany({
|
||||||
orderBy: { id: "asc" },
|
orderBy: { id: "asc" },
|
||||||
include: { _count: { select: { adminUsers: true, permissions: true } } }
|
include: { _count: { select: { admins: true, adminUsers: true, permissions: true } } }
|
||||||
});
|
});
|
||||||
return rows.map((t) => ({
|
return rows.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
users_count: t._count.adminUsers,
|
users_count: t._count.admins + t._count.adminUsers,
|
||||||
permissions_count: t._count.permissions
|
permissions_count: t._count.permissions
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -101645,14 +101801,19 @@ var require_admin_users_service = __commonJS({
|
|||||||
}
|
}
|
||||||
/** Legacy `GET users/show` equivalent for the "اطلاعات من" screen. */
|
/** Legacy `GET users/show` equivalent for the "اطلاعات من" screen. */
|
||||||
async myData(adminId) {
|
async myData(adminId) {
|
||||||
return this.findOne(adminId);
|
const admin = await this.findAdmin(adminId);
|
||||||
|
if (!admin)
|
||||||
|
throw new common_12.NotFoundException("\u06A9\u0627\u0631\u0628\u0631 \u06CC\u0627\u0641\u062A \u0646\u0634\u062F");
|
||||||
|
return this.toRow(admin);
|
||||||
}
|
}
|
||||||
/** The AdminRole picker for the user form. */
|
/**
|
||||||
|
* The AdminRole picker for the staff form. SALES_EXPERT is not offered: an
|
||||||
|
* expert is a different table and is created from the reagent form.
|
||||||
|
*/
|
||||||
roleOptions() {
|
roleOptions() {
|
||||||
return [
|
return [
|
||||||
{ value: client_1.AdminRole.SUPER_ADMIN, title: "\u0645\u062F\u06CC\u0631 \u0627\u0631\u0634\u062F" },
|
{ value: client_1.AdminRole.SUPER_ADMIN, title: "\u0645\u062F\u06CC\u0631 \u0627\u0631\u0634\u062F" },
|
||||||
{ value: client_1.AdminRole.SYSTEM_APPROVER, title: "\u062A\u0627\u06CC\u06CC\u062F \u0645\u062F\u06CC\u0631 \u0633\u06CC\u0633\u062A\u0645" },
|
{ value: client_1.AdminRole.SYSTEM_APPROVER, title: "\u062A\u0627\u06CC\u06CC\u062F \u0645\u062F\u06CC\u0631 \u0633\u06CC\u0633\u062A\u0645" },
|
||||||
{ value: client_1.AdminRole.SALES_EXPERT, title: "\u06A9\u0627\u0631\u0634\u0646\u0627\u0633 \u0641\u0631\u0648\u0634" },
|
|
||||||
{ value: client_1.AdminRole.WORKSHOP_SUPERVISOR, title: "\u0633\u0631\u067E\u0631\u0633\u062A \u06A9\u0627\u0631\u06AF\u0627\u0647" },
|
{ value: client_1.AdminRole.WORKSHOP_SUPERVISOR, title: "\u0633\u0631\u067E\u0631\u0633\u062A \u06A9\u0627\u0631\u06AF\u0627\u0647" },
|
||||||
{ value: client_1.AdminRole.ACCOUNTANT, title: "\u062D\u0633\u0627\u0628\u062F\u0627\u0631" }
|
{ value: client_1.AdminRole.ACCOUNTANT, title: "\u062D\u0633\u0627\u0628\u062F\u0627\u0631" }
|
||||||
];
|
];
|
||||||
@@ -101915,7 +102076,7 @@ var require_admin_invoices_service = __commonJS({
|
|||||||
"createdAt",
|
"createdAt",
|
||||||
"branchId",
|
"branchId",
|
||||||
"branch.name",
|
"branch.name",
|
||||||
"allocatorAdminUser.fullName",
|
"allocatorAdmin.fullName",
|
||||||
// legacy filter keys
|
// legacy filter keys
|
||||||
"name",
|
"name",
|
||||||
"caj"
|
"caj"
|
||||||
@@ -101923,7 +102084,7 @@ var require_admin_invoices_service = __commonJS({
|
|||||||
static FIELD_ALIASES = {
|
static FIELD_ALIASES = {
|
||||||
name: "branch.name",
|
name: "branch.name",
|
||||||
caj: "createdAt",
|
caj: "createdAt",
|
||||||
"allocator_user.name": "allocatorAdminUser.fullName"
|
"allocator_user.name": "allocatorAdmin.fullName"
|
||||||
};
|
};
|
||||||
/** Legacy `POST branch/product/table`. */
|
/** Legacy `POST branch/product/table`. */
|
||||||
async list(query) {
|
async list(query) {
|
||||||
@@ -101947,7 +102108,7 @@ var require_admin_invoices_service = __commonJS({
|
|||||||
take,
|
take,
|
||||||
include: {
|
include: {
|
||||||
branch: true,
|
branch: true,
|
||||||
allocatorAdminUser: true,
|
allocatorAdmin: true,
|
||||||
items: { include: { product: true } }
|
items: { include: { product: true } }
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -101966,7 +102127,7 @@ var require_admin_invoices_service = __commonJS({
|
|||||||
sum_weight: sumWeight,
|
sum_weight: sumWeight,
|
||||||
count_item: a.items.length,
|
count_item: a.items.length,
|
||||||
note: a.note,
|
note: a.note,
|
||||||
allocator_user: a.allocatorAdminUser ? { id: a.allocatorAdminUser.id, name: a.allocatorAdminUser.fullName } : null,
|
allocator_user: a.allocatorAdmin ? { id: a.allocatorAdmin.id, name: a.allocatorAdmin.fullName } : null,
|
||||||
caj: a.createdAt,
|
caj: a.createdAt,
|
||||||
products: a.items.map((it) => ({
|
products: a.items.map((it) => ({
|
||||||
id: it.productId,
|
id: it.productId,
|
||||||
@@ -101983,7 +102144,7 @@ var require_admin_invoices_service = __commonJS({
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
branch: true,
|
branch: true,
|
||||||
allocatorAdminUser: true,
|
allocatorAdmin: true,
|
||||||
items: { include: { product: true } }
|
items: { include: { product: true } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -102017,7 +102178,7 @@ var require_admin_invoices_service = __commonJS({
|
|||||||
const allocation = await this.prisma.branchAllocation.create({
|
const allocation = await this.prisma.branchAllocation.create({
|
||||||
data: {
|
data: {
|
||||||
branchId,
|
branchId,
|
||||||
allocatorAdminUserId: actorId,
|
allocatorAdminId: actorId,
|
||||||
note: data.note ?? null,
|
note: data.note ?? null,
|
||||||
items: {
|
items: {
|
||||||
create: products.map((p) => ({
|
create: products.map((p) => ({
|
||||||
@@ -102446,7 +102607,7 @@ var require_admin_misc_service = __commonJS({
|
|||||||
orderBy,
|
orderBy,
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
include: { sentByAdminUser: true }
|
include: { sentByAdmin: true }
|
||||||
}),
|
}),
|
||||||
this.prisma.smsMessage.count({ where })
|
this.prisma.smsMessage.count({ where })
|
||||||
]);
|
]);
|
||||||
@@ -102456,7 +102617,7 @@ var require_admin_misc_service = __commonJS({
|
|||||||
text: s.body,
|
text: s.body,
|
||||||
mobile: s.recipients,
|
mobile: s.recipients,
|
||||||
sent_count: s.sentCount,
|
sent_count: s.sentCount,
|
||||||
sender: s.sentByAdminUser?.fullName ?? null,
|
sender: s.sentByAdmin?.fullName ?? null,
|
||||||
caj: s.createdAt
|
caj: s.createdAt
|
||||||
})), total, query, page, limit);
|
})), total, query, page, limit);
|
||||||
}
|
}
|
||||||
@@ -102483,7 +102644,7 @@ var require_admin_misc_service = __commonJS({
|
|||||||
recipients: recipients.join(","),
|
recipients: recipients.join(","),
|
||||||
body,
|
body,
|
||||||
sentCount: recipients.length,
|
sentCount: recipients.length,
|
||||||
sentByAdminUserId: actorId
|
sentByAdminId: actorId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -102566,7 +102727,7 @@ var require_admin_misc_service = __commonJS({
|
|||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
audience,
|
audience,
|
||||||
sentByAdminUserId: actorId,
|
sentByAdminId: actorId,
|
||||||
recipients: { create: customers.map((c) => ({ customerId: c.id })) }
|
recipients: { create: customers.map((c) => ({ customerId: c.id })) }
|
||||||
},
|
},
|
||||||
include: { _count: { select: { recipients: true } } }
|
include: { _count: { select: { recipients: true } } }
|
||||||
@@ -102589,7 +102750,7 @@ var require_admin_misc_service = __commonJS({
|
|||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
audience: client_1.MessageAudience.SINGLE_NUMBER,
|
audience: client_1.MessageAudience.SINGLE_NUMBER,
|
||||||
sentByAdminUserId: actorId,
|
sentByAdminId: actorId,
|
||||||
recipients: { create: [{ customerId }] }
|
recipients: { create: [{ customerId }] }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -102599,7 +102760,7 @@ var require_admin_misc_service = __commonJS({
|
|||||||
const n = await this.prisma.notification.findUnique({
|
const n = await this.prisma.notification.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
sentByAdminUser: true,
|
sentByAdmin: true,
|
||||||
recipients: { include: { customer: true } }
|
recipients: { include: { customer: true } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -102611,7 +102772,7 @@ var require_admin_misc_service = __commonJS({
|
|||||||
body: n.body,
|
body: n.body,
|
||||||
text: n.body,
|
text: n.body,
|
||||||
audience: n.audience,
|
audience: n.audience,
|
||||||
sender: n.sentByAdminUser?.fullName ?? null,
|
sender: n.sentByAdmin?.fullName ?? null,
|
||||||
caj: n.createdAt,
|
caj: n.createdAt,
|
||||||
recipients: n.recipients.map((r) => ({
|
recipients: n.recipients.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -102638,7 +102799,7 @@ var require_admin_misc_service = __commonJS({
|
|||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
include: {
|
include: {
|
||||||
sentByAdminUser: true,
|
sentByAdmin: true,
|
||||||
_count: { select: { recipients: true } }
|
_count: { select: { recipients: true } }
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -102649,7 +102810,7 @@ var require_admin_misc_service = __commonJS({
|
|||||||
title: n.title,
|
title: n.title,
|
||||||
text: n.body,
|
text: n.body,
|
||||||
audience: n.audience,
|
audience: n.audience,
|
||||||
sender: n.sentByAdminUser?.fullName ?? null,
|
sender: n.sentByAdmin?.fullName ?? null,
|
||||||
total_count: n._count.recipients,
|
total_count: n._count.recipients,
|
||||||
caj: n.createdAt
|
caj: n.createdAt
|
||||||
})), total, query, page, limit);
|
})), total, query, page, limit);
|
||||||
@@ -163760,6 +163921,102 @@ var require_app_module = __commonJS({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// dist/prisma/diagnostics.js
|
||||||
|
var require_diagnostics = __commonJS({
|
||||||
|
"dist/prisma/diagnostics.js"(exports2) {
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
||||||
|
exports2.logDataDiagnostics = logDataDiagnostics;
|
||||||
|
var common_12 = require_common();
|
||||||
|
var QUERIES = [
|
||||||
|
{
|
||||||
|
label: "migrations: last 5 applied",
|
||||||
|
sql: `SELECT name, applied_at FROM "_goldbank_migrations" ORDER BY applied_at DESC, name DESC LIMIT 5`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "customers by kind / active / archived / step",
|
||||||
|
sql: `SELECT kind, "isActive" AS active, "isArchived" AS archived, "verificationStep" AS step, count(*)::int AS n
|
||||||
|
FROM "Customer" GROUP BY 1,2,3,4 ORDER BY n DESC`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'omde list ("all" scope) \u2014 what the panel can show',
|
||||||
|
sql: `SELECT
|
||||||
|
count(*)::int AS app_shopkeeper_not_archived,
|
||||||
|
count(*) FILTER (WHERE p.id IS NOT NULL)::int AS of_which_expert_trading_accounts,
|
||||||
|
count(*) FILTER (WHERE p.id IS NULL)::int AS listed_in_omde
|
||||||
|
FROM "Customer" c
|
||||||
|
LEFT JOIN "SalesExpertProfile" p ON p."tradingCustomerId" = c.id
|
||||||
|
WHERE c.kind = 'APP_SHOPKEEPER' AND c."isArchived" = false`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "expert trading accounts: created for the expert vs adopted existing customer",
|
||||||
|
sql: `SELECT
|
||||||
|
count(*)::int AS trading_accounts,
|
||||||
|
count(*) FILTER (WHERE c."createdAt" < a."createdAt")::int AS customer_older_than_expert,
|
||||||
|
count(*) FILTER (WHERE c."createdAt" >= a."createdAt")::int AS customer_newer_than_expert,
|
||||||
|
count(*) FILTER (WHERE c.mobile = a.mobile)::int AS same_mobile,
|
||||||
|
count(*) FILTER (WHERE c."referredByAgentId" IS NOT NULL)::int AS has_referrer,
|
||||||
|
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM "Order" o WHERE o."customerId" = c.id))::int AS has_orders,
|
||||||
|
count(*) FILTER (WHERE c."firstName" IS DISTINCT FROM a."firstName"
|
||||||
|
OR c."lastName" IS DISTINCT FROM a."lastName")::int AS name_differs_from_expert
|
||||||
|
FROM "SalesExpertProfile" p
|
||||||
|
JOIN "Customer" c ON c.id = p."tradingCustomerId"
|
||||||
|
JOIN "AdminUser" a ON a.id = p."adminUserId"`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "AdminUser by role / active (experts + pre-split admin copies)",
|
||||||
|
sql: `SELECT role, "isActive" AS active, count(*)::int AS n FROM "AdminUser" GROUP BY 1,2 ORDER BY n DESC`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "experts: profile / trading account",
|
||||||
|
sql: `SELECT
|
||||||
|
count(*)::int AS experts,
|
||||||
|
count(*) FILTER (WHERE p.id IS NULL)::int AS no_profile,
|
||||||
|
count(*) FILTER (WHERE p."tradingCustomerId" IS NOT NULL)::int AS with_trading_account,
|
||||||
|
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM "Customer" c WHERE c.mobile = a.mobile))::int AS mobile_also_a_customer
|
||||||
|
FROM "AdminUser" a
|
||||||
|
LEFT JOIN "SalesExpertProfile" p ON p."adminUserId" = a.id
|
||||||
|
WHERE a.role = 'SALES_EXPERT'`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "experts: when created (by day, last 10 days with rows)",
|
||||||
|
sql: `SELECT date_trunc('day', "createdAt")::date AS day, count(*)::int AS n
|
||||||
|
FROM "AdminUser" WHERE role = 'SALES_EXPERT' GROUP BY 1 ORDER BY 1 DESC LIMIT 10`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "customers: when created (by day, last 10 days with rows)",
|
||||||
|
sql: `SELECT date_trunc('day', "createdAt")::date AS day, count(*)::int AS n
|
||||||
|
FROM "Customer" GROUP BY 1 ORDER BY 1 DESC LIMIT 10`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "customers with a referring expert",
|
||||||
|
sql: `SELECT count(*) FILTER (WHERE "referredByAgentId" IS NOT NULL)::int AS referred,
|
||||||
|
count(*)::int AS all_customers
|
||||||
|
FROM "Customer"`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Admin table",
|
||||||
|
sql: `SELECT role, "isActive" AS active, count(*)::int AS n FROM "Admin" GROUP BY 1,2 ORDER BY n DESC`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
async function logDataDiagnostics(prisma) {
|
||||||
|
if (process.env.DIAG_LOG === "false")
|
||||||
|
return;
|
||||||
|
const logger = new common_12.Logger("DIAG");
|
||||||
|
logger.log("[DIAG] ===== data diagnostics start (counts only, read-only) =====");
|
||||||
|
for (const q of QUERIES) {
|
||||||
|
try {
|
||||||
|
const rows = await prisma.$queryRawUnsafe(q.sql);
|
||||||
|
logger.log(`[DIAG] ${q.label}: ${JSON.stringify(rows, (_k, v) => typeof v === "bigint" ? Number(v) : v)}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[DIAG] ${q.label}: FAILED \u2014 ${error.message.split("\n").pop()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.log("[DIAG] ===== data diagnostics end =====");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// dist/prisma/migrate.js
|
// dist/prisma/migrate.js
|
||||||
var require_migrate = __commonJS({
|
var require_migrate = __commonJS({
|
||||||
"dist/prisma/migrate.js"(exports2) {
|
"dist/prisma/migrate.js"(exports2) {
|
||||||
@@ -163938,6 +164195,7 @@ var require_bootstrap = __commonJS({
|
|||||||
var util_1 = require("util");
|
var util_1 = require("util");
|
||||||
var common_12 = require_common();
|
var common_12 = require_common();
|
||||||
var client_1 = require("@prisma/client");
|
var client_1 = require("@prisma/client");
|
||||||
|
var diagnostics_1 = require_diagnostics();
|
||||||
var migrate_1 = require_migrate();
|
var migrate_1 = require_migrate();
|
||||||
var execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
var execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
||||||
function resolveSeedScript() {
|
function resolveSeedScript() {
|
||||||
@@ -163960,6 +164218,7 @@ var require_bootstrap = __commonJS({
|
|||||||
} else {
|
} else {
|
||||||
logger.log("No pending migrations");
|
logger.log("No pending migrations");
|
||||||
}
|
}
|
||||||
|
await (0, diagnostics_1.logDataDiagnostics)(prisma);
|
||||||
if (process.env.RUN_SEED === "false")
|
if (process.env.RUN_SEED === "false")
|
||||||
return;
|
return;
|
||||||
const seedScript = resolveSeedScript();
|
const seedScript = resolveSeedScript();
|
||||||
@@ -163993,8 +164252,21 @@ var require_prisma_exception_filter = __commonJS({
|
|||||||
};
|
};
|
||||||
Object.defineProperty(exports2, "__esModule", { value: true });
|
Object.defineProperty(exports2, "__esModule", { value: true });
|
||||||
exports2.PrismaExceptionFilter = void 0;
|
exports2.PrismaExceptionFilter = void 0;
|
||||||
|
exports2.uniqueViolationMessage = uniqueViolationMessage;
|
||||||
var common_12 = require_common();
|
var common_12 = require_common();
|
||||||
var client_1 = require("@prisma/client");
|
var client_1 = require("@prisma/client");
|
||||||
|
function uniqueViolationMessage(target) {
|
||||||
|
const fields = (Array.isArray(target) ? target : [target]).map((t) => String(t ?? "")).join(",").toLowerCase();
|
||||||
|
if (fields.includes("mobile"))
|
||||||
|
return "\u0627\u06CC\u0646 \u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06CC\u0644 \u0642\u0628\u0644\u0627\u064B \u062B\u0628\u062A \u0634\u062F\u0647 \u0627\u0633\u062A";
|
||||||
|
if (fields.includes("nationalcode"))
|
||||||
|
return "\u0627\u06CC\u0646 \u06A9\u062F \u0645\u0644\u06CC \u0642\u0628\u0644\u0627\u064B \u062B\u0628\u062A \u0634\u062F\u0647 \u0627\u0633\u062A";
|
||||||
|
if (fields.includes("membercode"))
|
||||||
|
return "\u0627\u06CC\u0646 \u06A9\u062F \u0639\u0636\u0648\u06CC\u062A \u0642\u0628\u0644\u0627\u064B \u062B\u0628\u062A \u0634\u062F\u0647 \u0627\u0633\u062A";
|
||||||
|
if (fields.includes("ordernumber"))
|
||||||
|
return "\u0627\u06CC\u0646 \u0634\u0645\u0627\u0631\u0647 \u0633\u0641\u0627\u0631\u0634 \u0642\u0628\u0644\u0627\u064B \u062B\u0628\u062A \u0634\u062F\u0647 \u0627\u0633\u062A";
|
||||||
|
return "\u0627\u06CC\u0646 \u0645\u0642\u062F\u0627\u0631 \u0642\u0628\u0644\u0627\u064B \u062B\u0628\u062A \u0634\u062F\u0647 \u0627\u0633\u062A \u0648 \u062A\u06A9\u0631\u0627\u0631\u06CC \u0627\u0633\u062A";
|
||||||
|
}
|
||||||
var PrismaExceptionFilter = class PrismaExceptionFilter {
|
var PrismaExceptionFilter = class PrismaExceptionFilter {
|
||||||
logger = new common_12.Logger("PrismaExceptionFilter");
|
logger = new common_12.Logger("PrismaExceptionFilter");
|
||||||
catch(exception, host) {
|
catch(exception, host) {
|
||||||
@@ -164025,7 +164297,7 @@ var require_prisma_exception_filter = __commonJS({
|
|||||||
return response.status(common_12.HttpStatus.CONFLICT).json({
|
return response.status(common_12.HttpStatus.CONFLICT).json({
|
||||||
statusCode: common_12.HttpStatus.CONFLICT,
|
statusCode: common_12.HttpStatus.CONFLICT,
|
||||||
error: "Conflict",
|
error: "Conflict",
|
||||||
message: `Unique constraint failed on: ${exception.meta?.target?.join(", ")}`
|
message: uniqueViolationMessage(exception.meta?.target)
|
||||||
});
|
});
|
||||||
case "P2003":
|
case "P2003":
|
||||||
return response.status(common_12.HttpStatus.BAD_REQUEST).json({
|
return response.status(common_12.HttpStatus.BAD_REQUEST).json({
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
-- Split panel staff out of "AdminUser" into their own table, "Admin".
|
||||||
|
--
|
||||||
|
-- WHY
|
||||||
|
-- ---
|
||||||
|
-- "AdminUser" held two unrelated kinds of person: panel staff and sales
|
||||||
|
-- experts (role SALES_EXPERT). Because the mobile is unique per table, one
|
||||||
|
-- number could not be both an admin and an expert. Staff belong to the ADMIN
|
||||||
|
-- PANEL only; experts belong to the apps. They are now separate tables, each
|
||||||
|
-- unique on its own.
|
||||||
|
--
|
||||||
|
-- SAFETY — READ BEFORE RUNNING ON PRODUCTION
|
||||||
|
-- ------------------------------------------
|
||||||
|
-- This migration NEVER modifies, deletes or locks "AdminUser" (the sales-expert
|
||||||
|
-- table; ~12,000 rows in production). It only
|
||||||
|
-- 1. creates "Admin" and its indexes/foreign keys,
|
||||||
|
-- 2. COPIES the non-expert rows from "AdminUser" into it, keeping their ids,
|
||||||
|
-- 3. re-points the "who did this" foreign keys from "AdminUser" to "Admin".
|
||||||
|
--
|
||||||
|
-- * Ids are preserved, so every issued panel token (its `sub` is the id) and
|
||||||
|
-- every existing ledger / status / SMS / upload row keeps resolving.
|
||||||
|
-- * The old non-expert rows are LEFT in "AdminUser", dormant. The code no
|
||||||
|
-- longer reads them as admins. Remove them later, in a separate release, only
|
||||||
|
-- once the split has been verified.
|
||||||
|
-- * Re-runnable: every statement is guarded (IF NOT EXISTS / ON CONFLICT / drop
|
||||||
|
-- then add), so a half-applied run can simply be run again.
|
||||||
|
-- * No data is moved on the big tables (LedgerEntry, OrderStatusEvent, …):
|
||||||
|
-- the existing columns keep their names and values; only the constraint's
|
||||||
|
-- target changes.
|
||||||
|
--
|
||||||
|
-- The re-pointed foreign keys are added NOT VALID (checked for new/changed rows
|
||||||
|
-- immediately, existing rows not scanned under a heavy lock) and then validated
|
||||||
|
-- at the end. A row that references an id that is not an admin — only possible
|
||||||
|
-- if a sales expert once appears as an actor — leaves that one constraint
|
||||||
|
-- NOT VALID and prints a NOTICE instead of failing the deploy. See
|
||||||
|
-- docs/ADMIN-TABLE-SPLIT.md for the check query.
|
||||||
|
--
|
||||||
|
-- ROLLBACK: deploy the previous code. Nothing was deleted, so the old code
|
||||||
|
-- still finds every admin in "AdminUser". Admins created AFTER the split exist
|
||||||
|
-- only in "Admin" and would need re-creating in the old system.
|
||||||
|
|
||||||
|
-- ── 1. The table ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "Admin" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"fullName" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT,
|
||||||
|
"lastName" TEXT,
|
||||||
|
"mobile" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"role" "AdminRole" NOT NULL,
|
||||||
|
"userTypeId" INTEGER,
|
||||||
|
"branchId" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Admin_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "Admin_mobile_key" ON "Admin" ("mobile");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Admin_role_idx" ON "Admin" ("role");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Admin_userTypeId_idx" ON "Admin" ("userTypeId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Admin_firstName_idx" ON "Admin" ("firstName");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Admin_lastName_idx" ON "Admin" ("lastName");
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Admin" ADD CONSTRAINT "Admin_userTypeId_fkey"
|
||||||
|
FOREIGN KEY ("userTypeId") REFERENCES "user_type"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "Admin" ADD CONSTRAINT "Admin_branchId_fkey"
|
||||||
|
FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
|
||||||
|
-- ── 2. Copy the staff (everyone who is not a sales expert), ids preserved ──
|
||||||
|
--
|
||||||
|
-- Done BEFORE the name-sync trigger exists, so the stored names are copied
|
||||||
|
-- exactly as they are rather than rebuilt.
|
||||||
|
|
||||||
|
INSERT INTO "Admin"
|
||||||
|
("id", "fullName", "firstName", "lastName", "mobile", "passwordHash",
|
||||||
|
"role", "userTypeId", "branchId", "isActive", "createdAt")
|
||||||
|
SELECT
|
||||||
|
"id", "fullName", "firstName", "lastName", "mobile", "passwordHash",
|
||||||
|
"role", "userTypeId", "branchId", "isActive", "createdAt"
|
||||||
|
FROM "AdminUser"
|
||||||
|
WHERE "role" <> 'SALES_EXPERT'
|
||||||
|
ON CONFLICT ("id") DO NOTHING;
|
||||||
|
|
||||||
|
-- Same two-way fullName <-> firstName/lastName sync as AdminUser, sharing its
|
||||||
|
-- function (created in 20260922180000_sales_expert_profile_and_admin_name_parts).
|
||||||
|
DROP TRIGGER IF EXISTS admin_sync_full_name_trg ON "Admin";
|
||||||
|
CREATE TRIGGER admin_sync_full_name_trg
|
||||||
|
BEFORE INSERT OR UPDATE ON "Admin"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION admin_user_sync_full_name();
|
||||||
|
|
||||||
|
-- ── 3. Re-point the foreign keys that mean "an admin did this" ─────────────
|
||||||
|
--
|
||||||
|
-- Same constraint names, same column names, same ON DELETE / ON UPDATE actions;
|
||||||
|
-- only the referenced table changes. "Order"."salesAgentId",
|
||||||
|
-- "Customer"."referredByAgentId" and "SalesExpertProfile"."adminUserId" are
|
||||||
|
-- about SALES EXPERTS and keep pointing at "AdminUser".
|
||||||
|
|
||||||
|
ALTER TABLE "Workshop" DROP CONSTRAINT IF EXISTS "Workshop_supervisorId_fkey";
|
||||||
|
ALTER TABLE "Workshop" ADD CONSTRAINT "Workshop_supervisorId_fkey"
|
||||||
|
FOREIGN KEY ("supervisorId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE "OrderStatusEvent" DROP CONSTRAINT IF EXISTS "OrderStatusEvent_actorAdminUserId_fkey";
|
||||||
|
ALTER TABLE "OrderStatusEvent" ADD CONSTRAINT "OrderStatusEvent_actorAdminUserId_fkey"
|
||||||
|
FOREIGN KEY ("actorAdminUserId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE "PreOrderStatusEvent" DROP CONSTRAINT IF EXISTS "PreOrderStatusEvent_actorAdminUserId_fkey";
|
||||||
|
ALTER TABLE "PreOrderStatusEvent" ADD CONSTRAINT "PreOrderStatusEvent_actorAdminUserId_fkey"
|
||||||
|
FOREIGN KEY ("actorAdminUserId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE "LedgerEntry" DROP CONSTRAINT IF EXISTS "LedgerEntry_postedByAdminUserId_fkey";
|
||||||
|
ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_postedByAdminUserId_fkey"
|
||||||
|
FOREIGN KEY ("postedByAdminUserId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE "BranchAllocation" DROP CONSTRAINT IF EXISTS "BranchAllocation_allocatorAdminUserId_fkey";
|
||||||
|
ALTER TABLE "BranchAllocation" ADD CONSTRAINT "BranchAllocation_allocatorAdminUserId_fkey"
|
||||||
|
FOREIGN KEY ("allocatorAdminUserId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE "SmsMessage" DROP CONSTRAINT IF EXISTS "SmsMessage_sentByAdminUserId_fkey";
|
||||||
|
ALTER TABLE "SmsMessage" ADD CONSTRAINT "SmsMessage_sentByAdminUserId_fkey"
|
||||||
|
FOREIGN KEY ("sentByAdminUserId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE "Notification" DROP CONSTRAINT IF EXISTS "Notification_sentByAdminUserId_fkey";
|
||||||
|
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_sentByAdminUserId_fkey"
|
||||||
|
FOREIGN KEY ("sentByAdminUserId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE "UploadedFile" DROP CONSTRAINT IF EXISTS "UploadedFile_uploadedByAdminUserId_fkey";
|
||||||
|
ALTER TABLE "UploadedFile" ADD CONSTRAINT "UploadedFile_uploadedByAdminUserId_fkey"
|
||||||
|
FOREIGN KEY ("uploadedByAdminUserId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
-- Panel tokens are issued to staff only. CASCADE as before: deleting an admin
|
||||||
|
-- ends their sessions.
|
||||||
|
ALTER TABLE "AdminSession" DROP CONSTRAINT IF EXISTS "AdminSession_adminUserId_fkey";
|
||||||
|
ALTER TABLE "AdminSession" ADD CONSTRAINT "AdminSession_adminUserId_fkey"
|
||||||
|
FOREIGN KEY ("adminUserId") REFERENCES "Admin"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID;
|
||||||
|
|
||||||
|
-- ── 4. Validate; never fail the deploy over an old orphan ──────────────────
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
c record;
|
||||||
|
BEGIN
|
||||||
|
FOR c IN
|
||||||
|
SELECT * FROM (VALUES
|
||||||
|
('Workshop', 'Workshop_supervisorId_fkey'),
|
||||||
|
('OrderStatusEvent', 'OrderStatusEvent_actorAdminUserId_fkey'),
|
||||||
|
('PreOrderStatusEvent', 'PreOrderStatusEvent_actorAdminUserId_fkey'),
|
||||||
|
('LedgerEntry', 'LedgerEntry_postedByAdminUserId_fkey'),
|
||||||
|
('BranchAllocation', 'BranchAllocation_allocatorAdminUserId_fkey'),
|
||||||
|
('SmsMessage', 'SmsMessage_sentByAdminUserId_fkey'),
|
||||||
|
('Notification', 'Notification_sentByAdminUserId_fkey'),
|
||||||
|
('UploadedFile', 'UploadedFile_uploadedByAdminUserId_fkey'),
|
||||||
|
('AdminSession', 'AdminSession_adminUserId_fkey')
|
||||||
|
) AS t(tbl, con)
|
||||||
|
LOOP
|
||||||
|
BEGIN
|
||||||
|
EXECUTE format('ALTER TABLE %I VALIDATE CONSTRAINT %I', c.tbl, c.con);
|
||||||
|
EXCEPTION WHEN foreign_key_violation THEN
|
||||||
|
RAISE NOTICE '% on % left NOT VALID: some existing rows reference an id that is not in "Admin" (see docs/ADMIN-TABLE-SPLIT.md)', c.con, c.tbl;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
@@ -1846,7 +1846,7 @@ async function main() {
|
|||||||
update: {},
|
update: {},
|
||||||
create: { id: "workshop-central", name: "\u06A9\u0627\u0631\u06AF\u0627\u0647 \u0645\u0631\u06A9\u0632\u06CC \u0633\u0627\u062E\u062A \u0637\u0644\u0627" }
|
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({
|
await prisma.admin.upsert({
|
||||||
where: { mobile: DEV_TEST_PHONE },
|
where: { mobile: DEV_TEST_PHONE },
|
||||||
update: { passwordHash: DEV_TEST_PHONE_PASSWORD_HASH, userTypeId: 85, branchId: null },
|
update: { passwordHash: DEV_TEST_PHONE_PASSWORD_HASH, userTypeId: 85, branchId: null },
|
||||||
create: {
|
create: {
|
||||||
@@ -1858,7 +1858,7 @@ async function main() {
|
|||||||
branchId: null
|
branchId: null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await prisma.adminUser.upsert({
|
await prisma.admin.upsert({
|
||||||
where: { mobile: "09120000001" },
|
where: { mobile: "09120000001" },
|
||||||
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 85, branchId: null },
|
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 85, branchId: null },
|
||||||
create: {
|
create: {
|
||||||
@@ -1899,7 +1899,7 @@ async function main() {
|
|||||||
wagePercent: 1.2
|
wagePercent: 1.2
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await prisma.adminUser.upsert({
|
await prisma.admin.upsert({
|
||||||
where: { mobile: "09123334444" },
|
where: { mobile: "09123334444" },
|
||||||
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 88 },
|
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 88 },
|
||||||
create: {
|
create: {
|
||||||
@@ -1910,7 +1910,7 @@ async function main() {
|
|||||||
userTypeId: 88
|
userTypeId: 88
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
await prisma.adminUser.upsert({
|
await prisma.admin.upsert({
|
||||||
where: { mobile: "09125556666" },
|
where: { mobile: "09125556666" },
|
||||||
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 87, branchId: null },
|
update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 87, branchId: null },
|
||||||
create: {
|
create: {
|
||||||
|
|||||||
@@ -130,6 +130,20 @@ exports.Prisma.CityScalarFieldEnum = {
|
|||||||
percentAux: 'percentAux'
|
percentAux: 'percentAux'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.AdminScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
fullName: 'fullName',
|
||||||
|
firstName: 'firstName',
|
||||||
|
lastName: 'lastName',
|
||||||
|
mobile: 'mobile',
|
||||||
|
passwordHash: 'passwordHash',
|
||||||
|
role: 'role',
|
||||||
|
userTypeId: 'userTypeId',
|
||||||
|
branchId: 'branchId',
|
||||||
|
isActive: 'isActive',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.AdminUserScalarFieldEnum = {
|
exports.Prisma.AdminUserScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
fullName: 'fullName',
|
fullName: 'fullName',
|
||||||
@@ -170,7 +184,7 @@ exports.Prisma.AppSessionScalarFieldEnum = {
|
|||||||
exports.Prisma.AdminSessionScalarFieldEnum = {
|
exports.Prisma.AdminSessionScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
jti: 'jti',
|
jti: 'jti',
|
||||||
adminUserId: 'adminUserId',
|
adminId: 'adminId',
|
||||||
iamSessionId: 'iamSessionId',
|
iamSessionId: 'iamSessionId',
|
||||||
iamSub: 'iamSub',
|
iamSub: 'iamSub',
|
||||||
ipAddress: 'ipAddress',
|
ipAddress: 'ipAddress',
|
||||||
@@ -370,7 +384,7 @@ exports.Prisma.OrderStatusEventScalarFieldEnum = {
|
|||||||
fromStatus: 'fromStatus',
|
fromStatus: 'fromStatus',
|
||||||
toStatus: 'toStatus',
|
toStatus: 'toStatus',
|
||||||
note: 'note',
|
note: 'note',
|
||||||
actorAdminUserId: 'actorAdminUserId',
|
actorAdminId: 'actorAdminId',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -397,7 +411,7 @@ exports.Prisma.PreOrderStatusEventScalarFieldEnum = {
|
|||||||
id: 'id',
|
id: 'id',
|
||||||
preOrderId: 'preOrderId',
|
preOrderId: 'preOrderId',
|
||||||
changes: 'changes',
|
changes: 'changes',
|
||||||
actorAdminUserId: 'actorAdminUserId',
|
actorAdminId: 'actorAdminId',
|
||||||
ipAddress: 'ipAddress',
|
ipAddress: 'ipAddress',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
@@ -414,7 +428,7 @@ exports.Prisma.LedgerEntryScalarFieldEnum = {
|
|||||||
orderId: 'orderId',
|
orderId: 'orderId',
|
||||||
confirmed: 'confirmed',
|
confirmed: 'confirmed',
|
||||||
confirmedAt: 'confirmedAt',
|
confirmedAt: 'confirmedAt',
|
||||||
postedByAdminUserId: 'postedByAdminUserId',
|
postedByAdminId: 'postedByAdminId',
|
||||||
labName: 'labName',
|
labName: 'labName',
|
||||||
hallmark: 'hallmark',
|
hallmark: 'hallmark',
|
||||||
accountName: 'accountName',
|
accountName: 'accountName',
|
||||||
@@ -549,7 +563,7 @@ exports.Prisma.PromotionExclusionScalarFieldEnum = {
|
|||||||
exports.Prisma.BranchAllocationScalarFieldEnum = {
|
exports.Prisma.BranchAllocationScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
branchId: 'branchId',
|
branchId: 'branchId',
|
||||||
allocatorAdminUserId: 'allocatorAdminUserId',
|
allocatorAdminId: 'allocatorAdminId',
|
||||||
note: 'note',
|
note: 'note',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
@@ -567,7 +581,7 @@ exports.Prisma.SmsMessageScalarFieldEnum = {
|
|||||||
recipients: 'recipients',
|
recipients: 'recipients',
|
||||||
body: 'body',
|
body: 'body',
|
||||||
sentCount: 'sentCount',
|
sentCount: 'sentCount',
|
||||||
sentByAdminUserId: 'sentByAdminUserId',
|
sentByAdminId: 'sentByAdminId',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -576,7 +590,7 @@ exports.Prisma.NotificationScalarFieldEnum = {
|
|||||||
title: 'title',
|
title: 'title',
|
||||||
body: 'body',
|
body: 'body',
|
||||||
audience: 'audience',
|
audience: 'audience',
|
||||||
sentByAdminUserId: 'sentByAdminUserId',
|
sentByAdminId: 'sentByAdminId',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -609,7 +623,7 @@ exports.Prisma.UploadedFileScalarFieldEnum = {
|
|||||||
mimeType: 'mimeType',
|
mimeType: 'mimeType',
|
||||||
sizeBytes: 'sizeBytes',
|
sizeBytes: 'sizeBytes',
|
||||||
purpose: 'purpose',
|
purpose: 'purpose',
|
||||||
uploadedByAdminUserId: 'uploadedByAdminUserId',
|
uploadedByAdminId: 'uploadedByAdminId',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -782,6 +796,7 @@ exports.MessageAudience = exports.$Enums.MessageAudience = {
|
|||||||
|
|
||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
City: 'City',
|
City: 'City',
|
||||||
|
Admin: 'Admin',
|
||||||
AdminUser: 'AdminUser',
|
AdminUser: 'AdminUser',
|
||||||
AppSession: 'AppSession',
|
AppSession: 'AppSession',
|
||||||
AdminSession: 'AdminSession',
|
AdminSession: 'AdminSession',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "prisma-client-8945b25ad1891f9eaff8d38ca7f3c1b19a3f2d11c0c8521549c219186ff7566b",
|
"name": "prisma-client-d98bddf3599c950eb3fcd511efa7f226c4ef4c5c3cbc261683fb1118a763a924",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"browser": "index-browser.js",
|
"browser": "index-browser.js",
|
||||||
|
|||||||
@@ -140,6 +140,61 @@ model City {
|
|||||||
@@index([parentId])
|
@@index([parentId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A staff account for the ADMIN PANEL — and nothing else.
|
||||||
|
///
|
||||||
|
/// Split out of AdminUser (which now holds sales experts only) so that:
|
||||||
|
/// * the same mobile can be an admin AND a sales expert — they are two
|
||||||
|
/// separate roles, each unique only within its own table;
|
||||||
|
/// * the expert table (production: ~12,000 rows) is never written by panel
|
||||||
|
/// staff management.
|
||||||
|
/// Migration 20261026120000_split_admin_table copies the existing non-expert
|
||||||
|
/// AdminUser rows here WITH THEIR IDS, so issued panel tokens and every
|
||||||
|
/// "who did this" column keep resolving. The old rows are left in AdminUser,
|
||||||
|
/// dormant; no code reads them as admins any more.
|
||||||
|
///
|
||||||
|
/// ⚠️ fullName/firstName/lastName are kept in sync by the DB trigger
|
||||||
|
/// `admin_sync_full_name_trg` (it shares the function admin_user_sync_full_name) — same semantics as AdminUser: the parts win.
|
||||||
|
model Admin {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
fullName String
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
|
||||||
|
mobile String @unique
|
||||||
|
passwordHash String
|
||||||
|
/// Never SALES_EXPERT — that role lives in AdminUser. Enforced in
|
||||||
|
/// AdminUsersService, not in the database, so the enum stays shared.
|
||||||
|
role AdminRole
|
||||||
|
userTypeId Int?
|
||||||
|
userType UserType? @relation(fields: [userTypeId], references: [id])
|
||||||
|
branchId String?
|
||||||
|
branch Branch? @relation(fields: [branchId], references: [id])
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
supervisedWorkshops Workshop[] @relation("WorkshopSupervisor")
|
||||||
|
branchAllocations BranchAllocation[] @relation("AllocationAuthor")
|
||||||
|
postedLedgerEntries LedgerEntry[]
|
||||||
|
orderStatusEvents OrderStatusEvent[]
|
||||||
|
preOrderStatusEvents PreOrderStatusEvent[]
|
||||||
|
sentSmsMessages SmsMessage[] @relation("SmsAuthor")
|
||||||
|
sentNotifications Notification[] @relation("NotificationAuthor")
|
||||||
|
uploadedFiles UploadedFile[] @relation("FileUploader")
|
||||||
|
|
||||||
|
/// Issued admin-panel tokens, recorded only while session tracking is on
|
||||||
|
/// (AUTH_SESSION_TRACKING, or implicitly whenever the IAM is enabled) so a
|
||||||
|
/// token can be revoked before it expires. See AdminSession.
|
||||||
|
sessions AdminSession[]
|
||||||
|
|
||||||
|
@@index([role])
|
||||||
|
@@index([userTypeId])
|
||||||
|
@@index([firstName])
|
||||||
|
@@index([lastName])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A SALES EXPERT (role SALES_EXPERT). Panel staff live in `Admin`; rows here with
|
||||||
|
/// any other role are pre-split leftovers and are ignored by the code.
|
||||||
model AdminUser {
|
model AdminUser {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
@@ -188,23 +243,10 @@ model AdminUser {
|
|||||||
referredCustomers Customer[] @relation("ReferralAgent")
|
referredCustomers Customer[] @relation("ReferralAgent")
|
||||||
/// Orders credited to this sales expert — see Order.salesAgentId.
|
/// Orders credited to this sales expert — see Order.salesAgentId.
|
||||||
creditedOrders Order[] @relation("OrderSalesAgent")
|
creditedOrders Order[] @relation("OrderSalesAgent")
|
||||||
supervisedWorkshops Workshop[] @relation("WorkshopSupervisor")
|
|
||||||
branchAllocations BranchAllocation[] @relation("AllocationAuthor")
|
|
||||||
postedLedgerEntries LedgerEntry[]
|
|
||||||
orderStatusEvents OrderStatusEvent[]
|
|
||||||
preOrderStatusEvents PreOrderStatusEvent[]
|
|
||||||
sentSmsMessages SmsMessage[] @relation("SmsAuthor")
|
|
||||||
sentNotifications Notification[] @relation("NotificationAuthor")
|
|
||||||
uploadedFiles UploadedFile[] @relation("FileUploader")
|
|
||||||
|
|
||||||
/// Only ever set for role = SALES_EXPERT (D17).
|
/// Only ever set for role = SALES_EXPERT (D17).
|
||||||
salesExpertProfile SalesExpertProfile?
|
salesExpertProfile SalesExpertProfile?
|
||||||
|
|
||||||
/// Issued admin-panel tokens, recorded only while session tracking is on
|
|
||||||
/// (AUTH_SESSION_TRACKING, or implicitly whenever the IAM is enabled) so a
|
|
||||||
/// token can be revoked before it expires. See AdminSession.
|
|
||||||
sessions AdminSession[]
|
|
||||||
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@index([userTypeId])
|
@@index([userTypeId])
|
||||||
@@index([avatarFileId])
|
@@index([avatarFileId])
|
||||||
@@ -274,8 +316,8 @@ model AdminSession {
|
|||||||
/// The token's `jti` claim. Unique: one row per issued token.
|
/// The token's `jti` claim. Unique: one row per issued token.
|
||||||
jti String @unique
|
jti String @unique
|
||||||
|
|
||||||
adminUserId String
|
adminId String @map("adminUserId")
|
||||||
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
|
admin Admin @relation(fields: [adminId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
/// Set when the login was verified by the IAM: its session id (`sid`) and the
|
/// Set when the login was verified by the IAM: its session id (`sid`) and the
|
||||||
/// stable user id (`sub`) there. Back-channel logout arrives keyed by these —
|
/// stable user id (`sub`) there. Back-channel logout arrives keyed by these —
|
||||||
@@ -294,7 +336,7 @@ model AdminSession {
|
|||||||
revokedAt DateTime?
|
revokedAt DateTime?
|
||||||
revokedReason String?
|
revokedReason String?
|
||||||
|
|
||||||
@@index([adminUserId])
|
@@index([adminId], map: "AdminSession_adminUserId_idx")
|
||||||
@@index([iamSessionId])
|
@@index([iamSessionId])
|
||||||
@@index([iamSub])
|
@@index([iamSub])
|
||||||
@@index([expiresAt])
|
@@index([expiresAt])
|
||||||
@@ -571,6 +613,7 @@ model Branch {
|
|||||||
cityId String?
|
cityId String?
|
||||||
city City? @relation(fields: [cityId], references: [id])
|
city City? @relation(fields: [cityId], references: [id])
|
||||||
adminUsers AdminUser[]
|
adminUsers AdminUser[]
|
||||||
|
admins Admin[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
allocations BranchAllocation[]
|
allocations BranchAllocation[]
|
||||||
}
|
}
|
||||||
@@ -583,7 +626,7 @@ model Workshop {
|
|||||||
phone String?
|
phone String?
|
||||||
/// The supervisor created alongside the workshop by the legacy Add form.
|
/// The supervisor created alongside the workshop by the legacy Add form.
|
||||||
supervisorId String?
|
supervisorId String?
|
||||||
supervisor AdminUser? @relation("WorkshopSupervisor", fields: [supervisorId], references: [id])
|
supervisor Admin? @relation("WorkshopSupervisor", fields: [supervisorId], references: [id])
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
preOrders PreOrder[]
|
preOrders PreOrder[]
|
||||||
}
|
}
|
||||||
@@ -832,8 +875,8 @@ model OrderStatusEvent {
|
|||||||
fromStatus OrderStatus?
|
fromStatus OrderStatus?
|
||||||
toStatus OrderStatus
|
toStatus OrderStatus
|
||||||
note String?
|
note String?
|
||||||
actorAdminUserId String?
|
actorAdminId String? @map("actorAdminUserId")
|
||||||
actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id])
|
actorAdmin Admin? @relation(fields: [actorAdminId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([orderId])
|
@@index([orderId])
|
||||||
@@ -880,8 +923,8 @@ model PreOrderStatusEvent {
|
|||||||
preOrder PreOrder @relation(fields: [preOrderId], references: [id])
|
preOrder PreOrder @relation(fields: [preOrderId], references: [id])
|
||||||
/// Field-level diff, e.g. {"status": {"old": "PLACED", "new": "ADMIN_APPROVED"}}
|
/// Field-level diff, e.g. {"status": {"old": "PLACED", "new": "ADMIN_APPROVED"}}
|
||||||
changes Json
|
changes Json
|
||||||
actorAdminUserId String?
|
actorAdminId String? @map("actorAdminUserId")
|
||||||
actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id])
|
actorAdmin Admin? @relation(fields: [actorAdminId], references: [id])
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -914,8 +957,8 @@ model LedgerEntry {
|
|||||||
/// Pending entries don't count toward the cached customer balance until confirmed.
|
/// Pending entries don't count toward the cached customer balance until confirmed.
|
||||||
confirmed Boolean @default(false)
|
confirmed Boolean @default(false)
|
||||||
confirmedAt DateTime?
|
confirmedAt DateTime?
|
||||||
postedByAdminUserId String?
|
postedByAdminId String? @map("postedByAdminUserId")
|
||||||
postedByAdminUser AdminUser? @relation(fields: [postedByAdminUserId], references: [id])
|
postedByAdmin Admin? @relation(fields: [postedByAdminId], references: [id])
|
||||||
|
|
||||||
/// Instrument metadata from the legacy "cash" document form. Which fields are
|
/// Instrument metadata from the legacy "cash" document form. Which fields are
|
||||||
/// filled depends on sourceType: a melted-gold receipt carries lab/hallmark and
|
/// filled depends on sourceType: a melted-gold receipt carries lab/hallmark and
|
||||||
@@ -997,6 +1040,7 @@ model UserType {
|
|||||||
|
|
||||||
permissions Permission[]
|
permissions Permission[]
|
||||||
adminUsers AdminUser[]
|
adminUsers AdminUser[]
|
||||||
|
admins Admin[]
|
||||||
|
|
||||||
@@map("user_type")
|
@@map("user_type")
|
||||||
}
|
}
|
||||||
@@ -1251,8 +1295,8 @@ model BranchAllocation {
|
|||||||
branch Branch @relation(fields: [branchId], references: [id])
|
branch Branch @relation(fields: [branchId], references: [id])
|
||||||
|
|
||||||
/// Legacy `allocator_user` — the admin who filed the allocation.
|
/// Legacy `allocator_user` — the admin who filed the allocation.
|
||||||
allocatorAdminUserId String?
|
allocatorAdminId String? @map("allocatorAdminUserId")
|
||||||
allocatorAdminUser AdminUser? @relation("AllocationAuthor", fields: [allocatorAdminUserId], references: [id])
|
allocatorAdmin Admin? @relation("AllocationAuthor", fields: [allocatorAdminId], references: [id])
|
||||||
|
|
||||||
note String?
|
note String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -1299,8 +1343,8 @@ model SmsMessage {
|
|||||||
/// How many numbers the send actually resolved to.
|
/// How many numbers the send actually resolved to.
|
||||||
sentCount Int @default(0)
|
sentCount Int @default(0)
|
||||||
|
|
||||||
sentByAdminUserId String?
|
sentByAdminId String? @map("sentByAdminUserId")
|
||||||
sentByAdminUser AdminUser? @relation("SmsAuthor", fields: [sentByAdminUserId], references: [id])
|
sentByAdmin Admin? @relation("SmsAuthor", fields: [sentByAdminId], references: [id])
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -1313,8 +1357,8 @@ model Notification {
|
|||||||
body String
|
body String
|
||||||
audience MessageAudience @default(ALL_CUSTOMERS)
|
audience MessageAudience @default(ALL_CUSTOMERS)
|
||||||
|
|
||||||
sentByAdminUserId String?
|
sentByAdminId String? @map("sentByAdminUserId")
|
||||||
sentByAdminUser AdminUser? @relation("NotificationAuthor", fields: [sentByAdminUserId], references: [id])
|
sentByAdmin Admin? @relation("NotificationAuthor", fields: [sentByAdminId], references: [id])
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -1379,8 +1423,8 @@ model UploadedFile {
|
|||||||
/// 'customer.nationalId'. Kept loose so a new form needs no schema change.
|
/// 'customer.nationalId'. Kept loose so a new form needs no schema change.
|
||||||
purpose String?
|
purpose String?
|
||||||
|
|
||||||
uploadedByAdminUserId String?
|
uploadedByAdminId String? @map("uploadedByAdminUserId")
|
||||||
uploadedByAdminUser AdminUser? @relation("FileUploader", fields: [uploadedByAdminUserId], references: [id])
|
uploadedByAdmin Admin? @relation("FileUploader", fields: [uploadedByAdminId], references: [id])
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
customerAvatars Customer[] @relation("CustomerAvatar")
|
customerAvatars Customer[] @relation("CustomerAvatar")
|
||||||
|
|||||||
@@ -130,6 +130,20 @@ exports.Prisma.CityScalarFieldEnum = {
|
|||||||
percentAux: 'percentAux'
|
percentAux: 'percentAux'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.AdminScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
fullName: 'fullName',
|
||||||
|
firstName: 'firstName',
|
||||||
|
lastName: 'lastName',
|
||||||
|
mobile: 'mobile',
|
||||||
|
passwordHash: 'passwordHash',
|
||||||
|
role: 'role',
|
||||||
|
userTypeId: 'userTypeId',
|
||||||
|
branchId: 'branchId',
|
||||||
|
isActive: 'isActive',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.AdminUserScalarFieldEnum = {
|
exports.Prisma.AdminUserScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
fullName: 'fullName',
|
fullName: 'fullName',
|
||||||
@@ -170,7 +184,7 @@ exports.Prisma.AppSessionScalarFieldEnum = {
|
|||||||
exports.Prisma.AdminSessionScalarFieldEnum = {
|
exports.Prisma.AdminSessionScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
jti: 'jti',
|
jti: 'jti',
|
||||||
adminUserId: 'adminUserId',
|
adminId: 'adminId',
|
||||||
iamSessionId: 'iamSessionId',
|
iamSessionId: 'iamSessionId',
|
||||||
iamSub: 'iamSub',
|
iamSub: 'iamSub',
|
||||||
ipAddress: 'ipAddress',
|
ipAddress: 'ipAddress',
|
||||||
@@ -370,7 +384,7 @@ exports.Prisma.OrderStatusEventScalarFieldEnum = {
|
|||||||
fromStatus: 'fromStatus',
|
fromStatus: 'fromStatus',
|
||||||
toStatus: 'toStatus',
|
toStatus: 'toStatus',
|
||||||
note: 'note',
|
note: 'note',
|
||||||
actorAdminUserId: 'actorAdminUserId',
|
actorAdminId: 'actorAdminId',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -397,7 +411,7 @@ exports.Prisma.PreOrderStatusEventScalarFieldEnum = {
|
|||||||
id: 'id',
|
id: 'id',
|
||||||
preOrderId: 'preOrderId',
|
preOrderId: 'preOrderId',
|
||||||
changes: 'changes',
|
changes: 'changes',
|
||||||
actorAdminUserId: 'actorAdminUserId',
|
actorAdminId: 'actorAdminId',
|
||||||
ipAddress: 'ipAddress',
|
ipAddress: 'ipAddress',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
@@ -414,7 +428,7 @@ exports.Prisma.LedgerEntryScalarFieldEnum = {
|
|||||||
orderId: 'orderId',
|
orderId: 'orderId',
|
||||||
confirmed: 'confirmed',
|
confirmed: 'confirmed',
|
||||||
confirmedAt: 'confirmedAt',
|
confirmedAt: 'confirmedAt',
|
||||||
postedByAdminUserId: 'postedByAdminUserId',
|
postedByAdminId: 'postedByAdminId',
|
||||||
labName: 'labName',
|
labName: 'labName',
|
||||||
hallmark: 'hallmark',
|
hallmark: 'hallmark',
|
||||||
accountName: 'accountName',
|
accountName: 'accountName',
|
||||||
@@ -549,7 +563,7 @@ exports.Prisma.PromotionExclusionScalarFieldEnum = {
|
|||||||
exports.Prisma.BranchAllocationScalarFieldEnum = {
|
exports.Prisma.BranchAllocationScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
branchId: 'branchId',
|
branchId: 'branchId',
|
||||||
allocatorAdminUserId: 'allocatorAdminUserId',
|
allocatorAdminId: 'allocatorAdminId',
|
||||||
note: 'note',
|
note: 'note',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
@@ -567,7 +581,7 @@ exports.Prisma.SmsMessageScalarFieldEnum = {
|
|||||||
recipients: 'recipients',
|
recipients: 'recipients',
|
||||||
body: 'body',
|
body: 'body',
|
||||||
sentCount: 'sentCount',
|
sentCount: 'sentCount',
|
||||||
sentByAdminUserId: 'sentByAdminUserId',
|
sentByAdminId: 'sentByAdminId',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -576,7 +590,7 @@ exports.Prisma.NotificationScalarFieldEnum = {
|
|||||||
title: 'title',
|
title: 'title',
|
||||||
body: 'body',
|
body: 'body',
|
||||||
audience: 'audience',
|
audience: 'audience',
|
||||||
sentByAdminUserId: 'sentByAdminUserId',
|
sentByAdminId: 'sentByAdminId',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -609,7 +623,7 @@ exports.Prisma.UploadedFileScalarFieldEnum = {
|
|||||||
mimeType: 'mimeType',
|
mimeType: 'mimeType',
|
||||||
sizeBytes: 'sizeBytes',
|
sizeBytes: 'sizeBytes',
|
||||||
purpose: 'purpose',
|
purpose: 'purpose',
|
||||||
uploadedByAdminUserId: 'uploadedByAdminUserId',
|
uploadedByAdminId: 'uploadedByAdminId',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -782,6 +796,7 @@ exports.MessageAudience = exports.$Enums.MessageAudience = {
|
|||||||
|
|
||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
City: 'City',
|
City: 'City',
|
||||||
|
Admin: 'Admin',
|
||||||
AdminUser: 'AdminUser',
|
AdminUser: 'AdminUser',
|
||||||
AppSession: 'AppSession',
|
AppSession: 'AppSession',
|
||||||
AdminSession: 'AdminSession',
|
AdminSession: 'AdminSession',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export interface PostEntryInput {
|
|||||||
note?: string;
|
note?: string;
|
||||||
/** Order-delivery debt entries are self-confirming; repayments start pending. */
|
/** Order-delivery debt entries are self-confirming; repayments start pending. */
|
||||||
confirmed?: boolean;
|
confirmed?: boolean;
|
||||||
postedByAdminUserId?: string;
|
postedByAdminId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,7 +56,7 @@ export class LedgerService {
|
|||||||
note: input.note,
|
note: input.note,
|
||||||
confirmed,
|
confirmed,
|
||||||
confirmedAt: confirmed ? new Date() : null,
|
confirmedAt: confirmed ? new Date() : null,
|
||||||
postedByAdminUserId: input.postedByAdminUserId,
|
postedByAdminId: input.postedByAdminId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export class LedgerService {
|
|||||||
|
|
||||||
const updated = await tx.ledgerEntry.update({
|
const updated = await tx.ledgerEntry.update({
|
||||||
where: { id: entryId },
|
where: { id: entryId },
|
||||||
data: { confirmed: true, confirmedAt: new Date(), postedByAdminUserId: adminUserId ?? entry.postedByAdminUserId },
|
data: { confirmed: true, confirmedAt: new Date(), postedByAdminId: adminUserId ?? entry.postedByAdminId },
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.applyToBalance(entry.customerId, entry.currency, entry.direction, entry.amount, tx);
|
await this.applyToBalance(entry.customerId, entry.currency, entry.direction, entry.amount, tx);
|
||||||
@@ -180,7 +180,7 @@ export class LedgerService {
|
|||||||
karatPerMille?: number;
|
karatPerMille?: number;
|
||||||
pricePerGramRial?: Prisma.Decimal.Value;
|
pricePerGramRial?: Prisma.Decimal.Value;
|
||||||
note?: string;
|
note?: string;
|
||||||
postedByAdminUserId?: string;
|
postedByAdminId?: string;
|
||||||
},
|
},
|
||||||
tx: Tx = this.prisma,
|
tx: Tx = this.prisma,
|
||||||
) {
|
) {
|
||||||
@@ -230,7 +230,7 @@ export class LedgerService {
|
|||||||
pricePerGramRialAtEntry: params.pricePerGramRial,
|
pricePerGramRialAtEntry: params.pricePerGramRial,
|
||||||
note: params.note,
|
note: params.note,
|
||||||
confirmed: false, // pending until an admin confirms — intentional deviation from legacy, which auto-confirms on creation
|
confirmed: false, // pending until an admin confirms — intentional deviation from legacy, which auto-confirms on creation
|
||||||
postedByAdminUserId: params.postedByAdminUserId,
|
postedByAdminId: params.postedByAdminId,
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
@@ -242,7 +242,7 @@ export class LedgerService {
|
|||||||
* migration analysis) but supported here since the new ledger is symmetric.
|
* migration analysis) but supported here since the new ledger is symmetric.
|
||||||
*/
|
*/
|
||||||
async postRialRepayment(
|
async postRialRepayment(
|
||||||
params: { customerId: string; amount: Prisma.Decimal.Value; note?: string; postedByAdminUserId?: string },
|
params: { customerId: string; amount: Prisma.Decimal.Value; note?: string; postedByAdminId?: string },
|
||||||
tx: Tx = this.prisma,
|
tx: Tx = this.prisma,
|
||||||
) {
|
) {
|
||||||
return this.postEntry(
|
return this.postEntry(
|
||||||
@@ -254,7 +254,7 @@ export class LedgerService {
|
|||||||
amount: params.amount,
|
amount: params.amount,
|
||||||
note: params.note,
|
note: params.note,
|
||||||
confirmed: false,
|
confirmed: false,
|
||||||
postedByAdminUserId: params.postedByAdminUserId,
|
postedByAdminId: params.postedByAdminId,
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
@@ -267,7 +267,7 @@ export class LedgerService {
|
|||||||
direction: LedgerDirection;
|
direction: LedgerDirection;
|
||||||
amount: Prisma.Decimal.Value;
|
amount: Prisma.Decimal.Value;
|
||||||
note: string;
|
note: string;
|
||||||
postedByAdminUserId: string;
|
postedByAdminId: string;
|
||||||
},
|
},
|
||||||
tx: Tx = this.prisma,
|
tx: Tx = this.prisma,
|
||||||
) {
|
) {
|
||||||
@@ -280,7 +280,7 @@ export class LedgerService {
|
|||||||
amount: params.amount,
|
amount: params.amount,
|
||||||
note: params.note,
|
note: params.note,
|
||||||
confirmed: true,
|
confirmed: true,
|
||||||
postedByAdminUserId: params.postedByAdminUserId,
|
postedByAdminId: params.postedByAdminId,
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export class AdminBranchesService {
|
|||||||
async listBranches(query: TableQueryDto) {
|
async listBranches(query: TableQueryDto) {
|
||||||
this.table.assertNotExport(query);
|
this.table.assertNotExport(query);
|
||||||
|
|
||||||
const aliases: Record<string, string> = { 'user.name': 'adminUsers.some.fullName' };
|
const aliases: Record<string, string> = { 'user.name': 'admins.some.fullName' };
|
||||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||||
searchable: ['name', 'address', 'phone'],
|
searchable: ['name', 'address', 'phone'],
|
||||||
allowed: ['id', 'name', 'address', 'phone', 'isActive', 'cityId', 'city.name'],
|
allowed: ['id', 'name', 'address', 'phone', 'isActive', 'cityId', 'city.name'],
|
||||||
@@ -48,8 +48,8 @@ export class AdminBranchesService {
|
|||||||
take,
|
take,
|
||||||
include: {
|
include: {
|
||||||
city: true,
|
city: true,
|
||||||
adminUsers: { where: { isActive: true }, take: 1 },
|
admins: { where: { isActive: true }, take: 1 },
|
||||||
_count: { select: { adminUsers: true, orders: true } },
|
_count: { select: { admins: true, orders: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.branch.count({ where: where as Prisma.BranchWhereInput }),
|
this.prisma.branch.count({ where: where as Prisma.BranchWhereInput }),
|
||||||
@@ -66,10 +66,10 @@ export class AdminBranchesService {
|
|||||||
city_id: b.cityId,
|
city_id: b.cityId,
|
||||||
city: b.city ? { id: b.city.id, title: b.city.name } : null,
|
city: b.city ? { id: b.city.id, title: b.city.name } : null,
|
||||||
// legacy column key is `user.name` — the branch manager
|
// legacy column key is `user.name` — the branch manager
|
||||||
user: b.adminUsers[0]
|
user: b.admins[0]
|
||||||
? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName }
|
? { id: b.admins[0].id, name: b.admins[0].fullName }
|
||||||
: null,
|
: null,
|
||||||
users_count: b._count.adminUsers,
|
users_count: b._count.admins,
|
||||||
orders_count: b._count.orders,
|
orders_count: b._count.orders,
|
||||||
})),
|
})),
|
||||||
total,
|
total,
|
||||||
@@ -82,7 +82,7 @@ export class AdminBranchesService {
|
|||||||
async findBranch(id: string) {
|
async findBranch(id: string) {
|
||||||
const b = await this.prisma.branch.findUnique({
|
const b = await this.prisma.branch.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { city: true, adminUsers: { where: { isActive: true }, take: 1 } },
|
include: { city: true, admins: { where: { isActive: true }, take: 1 } },
|
||||||
});
|
});
|
||||||
if (!b) throw new NotFoundException('شعبه یافت نشد');
|
if (!b) throw new NotFoundException('شعبه یافت نشد');
|
||||||
return {
|
return {
|
||||||
@@ -92,8 +92,8 @@ export class AdminBranchesService {
|
|||||||
phone: b.phone,
|
phone: b.phone,
|
||||||
is_active: b.isActive,
|
is_active: b.isActive,
|
||||||
city_id: b.cityId,
|
city_id: b.cityId,
|
||||||
user_id: b.adminUsers[0]?.id ?? null,
|
user_id: b.admins[0]?.id ?? null,
|
||||||
user: b.adminUsers[0] ? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName } : null,
|
user: b.admins[0] ? { id: b.admins[0].id, name: b.admins[0].fullName } : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,11 +145,11 @@ export class AdminBranchesService {
|
|||||||
*/
|
*/
|
||||||
private async assignBranchManager(branchId: string, userId: string | null) {
|
private async assignBranchManager(branchId: string, userId: string | null) {
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.adminUser.updateMany({ where: { branchId }, data: { branchId: null } });
|
await tx.admin.updateMany({ where: { branchId }, data: { branchId: null } });
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const user = await tx.adminUser.findUnique({ where: { id: userId } });
|
const user = await tx.admin.findUnique({ where: { id: userId } });
|
||||||
if (!user) throw new NotFoundException('کاربر انتخابشده یافت نشد');
|
if (!user) throw new NotFoundException('کاربر انتخابشده یافت نشد');
|
||||||
await tx.adminUser.update({ where: { id: userId }, data: { branchId } });
|
await tx.admin.update({ where: { id: userId }, data: { branchId } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -302,15 +302,15 @@ export class AdminBranchesService {
|
|||||||
const fullName = `${fname} ${lname}`.trim();
|
const fullName = `${fname} ${lname}`.trim();
|
||||||
|
|
||||||
if (currentId) {
|
if (currentId) {
|
||||||
const current = await this.prisma.adminUser.findUnique({ where: { id: currentId } });
|
const current = await this.prisma.admin.findUnique({ where: { id: currentId } });
|
||||||
if (current) {
|
if (current) {
|
||||||
if (phone !== current.mobile) {
|
if (phone !== current.mobile) {
|
||||||
const clash = await this.prisma.adminUser.findUnique({ where: { mobile: phone } });
|
const clash = await this.prisma.admin.findUnique({ where: { mobile: phone } });
|
||||||
if (clash && clash.id !== currentId) {
|
if (clash && clash.id !== currentId) {
|
||||||
throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.prisma.adminUser.update({
|
await this.prisma.admin.update({
|
||||||
where: { id: currentId },
|
where: { id: currentId },
|
||||||
data: { fullName, mobile: phone },
|
data: { fullName, mobile: phone },
|
||||||
});
|
});
|
||||||
@@ -320,10 +320,10 @@ export class AdminBranchesService {
|
|||||||
|
|
||||||
// Reuse an existing admin with that mobile rather than colliding on the
|
// Reuse an existing admin with that mobile rather than colliding on the
|
||||||
// unique constraint.
|
// unique constraint.
|
||||||
const existing = await this.prisma.adminUser.findUnique({ where: { mobile: phone } });
|
const existing = await this.prisma.admin.findUnique({ where: { mobile: phone } });
|
||||||
if (existing) return existing.id;
|
if (existing) return existing.id;
|
||||||
|
|
||||||
const created = await this.prisma.adminUser.create({
|
const created = await this.prisma.admin.create({
|
||||||
data: {
|
data: {
|
||||||
fullName,
|
fullName,
|
||||||
mobile: phone,
|
mobile: phone,
|
||||||
@@ -347,7 +347,7 @@ export class AdminBranchesService {
|
|||||||
|
|
||||||
/** The admin picker the branch form uses — legacy `GET user/list`. */
|
/** The admin picker the branch form uses — legacy `GET user/list`. */
|
||||||
async userList() {
|
async userList() {
|
||||||
const rows = await this.prisma.adminUser.findMany({
|
const rows = await this.prisma.admin.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: { fullName: 'asc' },
|
orderBy: { fullName: 'asc' },
|
||||||
select: { id: true, fullName: true, mobile: true, branchId: true },
|
select: { id: true, fullName: true, mobile: true, branchId: true },
|
||||||
|
|||||||
@@ -99,4 +99,15 @@ describe('AdminCustomersService', () => {
|
|||||||
expect(writes[0].passwordHash).toBe(OTP_ONLY_PASSWORD_HASH);
|
expect(writes[0].passwordHash).toBe(OTP_ONLY_PASSWORD_HASH);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('wholesale list scope (parity with AdminController::getCustomer)', () => {
|
||||||
|
const scope = (name: 'all' | 'non_verified') =>
|
||||||
|
(build().service as unknown as { scopeWhere(s: string): Record<string, unknown> }).scopeWhere(name);
|
||||||
|
|
||||||
|
it('«عمده» has no verification or active filter — pending customers stay listed', () => {
|
||||||
|
const where = scope('all');
|
||||||
|
expect(where).toEqual({ kind: 'APP_SHOPKEEPER', isArchived: false, salesExpertTradingFor: null });
|
||||||
|
expect(JSON.stringify(where)).not.toContain('verificationStep');
|
||||||
|
expect(JSON.stringify(where)).not.toContain('isActive');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -252,10 +252,9 @@ export class AdminCustomersService {
|
|||||||
* NOTE every non-archive list also excludes archived rows, so archiving a
|
* NOTE every non-archive list also excludes archived rows, so archiving a
|
||||||
* customer removes them from ALL type lists at once.
|
* customer removes them from ALL type lists at once.
|
||||||
*
|
*
|
||||||
* «در انتظار» vs «عمده» must not show the same rows. App self-registration
|
* «در انتظار» and «عمده» MAY show the same customer, exactly as in the
|
||||||
* creates APP_SHOPKEEPER + isActive=false + STEP_ONE_SUBMITTED, and step-one
|
* reference: getCustomer has no verification filter, so a customer awaiting
|
||||||
* accept sets isActive=true — so pending is inactive+pending-step only, and
|
* approval is in both lists.
|
||||||
* the main omde list excludes those same inactive pending rows.
|
|
||||||
*/
|
*/
|
||||||
private scopeWhere(scope: OmdeScope): Prisma.CustomerWhereInput {
|
private scopeWhere(scope: OmdeScope): Prisma.CustomerWhereInput {
|
||||||
const pendingVerification: Prisma.CustomerWhereInput = {
|
const pendingVerification: Prisma.CustomerWhereInput = {
|
||||||
@@ -284,12 +283,15 @@ export class AdminCustomersService {
|
|||||||
return { isArchived: true, ...notExpertTradingAccount };
|
return { isArchived: true, ...notExpertTradingAccount };
|
||||||
case 'all':
|
case 'all':
|
||||||
default:
|
default:
|
||||||
// type=2 + not archived, not «در انتظار», not an expert's trading row.
|
// type=2 + not archived (AdminController::getCustomer:166-168), minus an
|
||||||
|
// expert's trading row. NO verification/active filter: the reference
|
||||||
|
// lists every non-archived type-2 customer, pending ones included, and
|
||||||
|
// only counts the inactive ones separately. Filtering them out here
|
||||||
|
// emptied the list for customers migrated with a pending step.
|
||||||
return {
|
return {
|
||||||
kind: CustomerKind.APP_SHOPKEEPER,
|
kind: CustomerKind.APP_SHOPKEEPER,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
...notExpertTradingAccount,
|
...notExpertTradingAccount,
|
||||||
NOT: pendingVerification,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export class AdminDocumentsService {
|
|||||||
orderBy: orderBy as Prisma.LedgerEntryOrderByWithRelationInput,
|
orderBy: orderBy as Prisma.LedgerEntryOrderByWithRelationInput,
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
include: { customer: true, postedByAdminUser: true },
|
include: { customer: true, postedByAdmin: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.ledgerEntry.count({ where: scoped }),
|
this.prisma.ledgerEntry.count({ where: scoped }),
|
||||||
]);
|
]);
|
||||||
@@ -157,7 +157,7 @@ export class AdminDocumentsService {
|
|||||||
|
|
||||||
/** Legacy field names preserved so the ported table model binds unchanged. */
|
/** Legacy field names preserved so the ported table model binds unchanged. */
|
||||||
private toRow(
|
private toRow(
|
||||||
e: Prisma.LedgerEntryGetPayload<{ include: { customer: true; postedByAdminUser: true } }>,
|
e: Prisma.LedgerEntryGetPayload<{ include: { customer: true; postedByAdmin: true } }>,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
@@ -190,7 +190,7 @@ export class AdminDocumentsService {
|
|||||||
lname: e.customer.lastName ?? '',
|
lname: e.customer.lastName ?? '',
|
||||||
mobile: e.customer.mobile,
|
mobile: e.customer.mobile,
|
||||||
},
|
},
|
||||||
posted_by: e.postedByAdminUser ? e.postedByAdminUser.fullName : null,
|
posted_by: e.postedByAdmin ? e.postedByAdmin.fullName : null,
|
||||||
caj: e.createdAt,
|
caj: e.createdAt,
|
||||||
uaj: e.createdAt,
|
uaj: e.createdAt,
|
||||||
};
|
};
|
||||||
@@ -200,7 +200,7 @@ export class AdminDocumentsService {
|
|||||||
async findOne(id: string) {
|
async findOne(id: string) {
|
||||||
const e = await this.prisma.ledgerEntry.findUnique({
|
const e = await this.prisma.ledgerEntry.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { customer: true, postedByAdminUser: true },
|
include: { customer: true, postedByAdmin: true },
|
||||||
});
|
});
|
||||||
if (!e) throw new NotFoundException('سند یافت نشد');
|
if (!e) throw new NotFoundException('سند یافت نشد');
|
||||||
return this.toRow(e);
|
return this.toRow(e);
|
||||||
@@ -241,7 +241,7 @@ export class AdminDocumentsService {
|
|||||||
karatPerMille: data.ayar ? Number(data.ayar) : undefined,
|
karatPerMille: data.ayar ? Number(data.ayar) : undefined,
|
||||||
pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : undefined,
|
pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : undefined,
|
||||||
note: (data.note as string) ?? undefined,
|
note: (data.note as string) ?? undefined,
|
||||||
postedByAdminUserId: actorAdminUserId,
|
postedByAdminId: actorAdminUserId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attach the instrument metadata the ledger service does not model.
|
// Attach the instrument metadata the ledger service does not model.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class AdminInvoicesService {
|
|||||||
'createdAt',
|
'createdAt',
|
||||||
'branchId',
|
'branchId',
|
||||||
'branch.name',
|
'branch.name',
|
||||||
'allocatorAdminUser.fullName',
|
'allocatorAdmin.fullName',
|
||||||
// legacy filter keys
|
// legacy filter keys
|
||||||
'name',
|
'name',
|
||||||
'caj',
|
'caj',
|
||||||
@@ -37,7 +37,7 @@ export class AdminInvoicesService {
|
|||||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||||
name: 'branch.name',
|
name: 'branch.name',
|
||||||
caj: 'createdAt',
|
caj: 'createdAt',
|
||||||
'allocator_user.name': 'allocatorAdminUser.fullName',
|
'allocator_user.name': 'allocatorAdmin.fullName',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Legacy `POST branch/product/table`. */
|
/** Legacy `POST branch/product/table`. */
|
||||||
@@ -67,7 +67,7 @@ export class AdminInvoicesService {
|
|||||||
take,
|
take,
|
||||||
include: {
|
include: {
|
||||||
branch: true,
|
branch: true,
|
||||||
allocatorAdminUser: true,
|
allocatorAdmin: true,
|
||||||
items: { include: { product: true } },
|
items: { include: { product: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -81,7 +81,7 @@ export class AdminInvoicesService {
|
|||||||
a: Prisma.BranchAllocationGetPayload<{
|
a: Prisma.BranchAllocationGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
branch: true;
|
branch: true;
|
||||||
allocatorAdminUser: true;
|
allocatorAdmin: true;
|
||||||
items: { include: { product: true } };
|
items: { include: { product: true } };
|
||||||
};
|
};
|
||||||
}>,
|
}>,
|
||||||
@@ -100,8 +100,8 @@ export class AdminInvoicesService {
|
|||||||
sum_weight: sumWeight,
|
sum_weight: sumWeight,
|
||||||
count_item: a.items.length,
|
count_item: a.items.length,
|
||||||
note: a.note,
|
note: a.note,
|
||||||
allocator_user: a.allocatorAdminUser
|
allocator_user: a.allocatorAdmin
|
||||||
? { id: a.allocatorAdminUser.id, name: a.allocatorAdminUser.fullName }
|
? { id: a.allocatorAdmin.id, name: a.allocatorAdmin.fullName }
|
||||||
: null,
|
: null,
|
||||||
caj: a.createdAt,
|
caj: a.createdAt,
|
||||||
products: a.items.map((it) => ({
|
products: a.items.map((it) => ({
|
||||||
@@ -120,7 +120,7 @@ export class AdminInvoicesService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
branch: true,
|
branch: true,
|
||||||
allocatorAdminUser: true,
|
allocatorAdmin: true,
|
||||||
items: { include: { product: true } },
|
items: { include: { product: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -155,7 +155,7 @@ export class AdminInvoicesService {
|
|||||||
const allocation = await this.prisma.branchAllocation.create({
|
const allocation = await this.prisma.branchAllocation.create({
|
||||||
data: {
|
data: {
|
||||||
branchId,
|
branchId,
|
||||||
allocatorAdminUserId: actorId,
|
allocatorAdminId: actorId,
|
||||||
note: data.note ?? null,
|
note: data.note ?? null,
|
||||||
items: {
|
items: {
|
||||||
create: products.map((p) => ({
|
create: products.map((p) => ({
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export class AdminMiscService {
|
|||||||
orderBy: orderBy as Prisma.SmsMessageOrderByWithRelationInput,
|
orderBy: orderBy as Prisma.SmsMessageOrderByWithRelationInput,
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
include: { sentByAdminUser: true },
|
include: { sentByAdmin: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.smsMessage.count({ where: where as Prisma.SmsMessageWhereInput }),
|
this.prisma.smsMessage.count({ where: where as Prisma.SmsMessageWhereInput }),
|
||||||
]);
|
]);
|
||||||
@@ -236,7 +236,7 @@ export class AdminMiscService {
|
|||||||
text: s.body,
|
text: s.body,
|
||||||
mobile: s.recipients,
|
mobile: s.recipients,
|
||||||
sent_count: s.sentCount,
|
sent_count: s.sentCount,
|
||||||
sender: s.sentByAdminUser?.fullName ?? null,
|
sender: s.sentByAdmin?.fullName ?? null,
|
||||||
caj: s.createdAt,
|
caj: s.createdAt,
|
||||||
})),
|
})),
|
||||||
total,
|
total,
|
||||||
@@ -279,7 +279,7 @@ export class AdminMiscService {
|
|||||||
recipients: recipients.join(','),
|
recipients: recipients.join(','),
|
||||||
body,
|
body,
|
||||||
sentCount: recipients.length,
|
sentCount: recipients.length,
|
||||||
sentByAdminUserId: actorId,
|
sentByAdminId: actorId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -382,7 +382,7 @@ export class AdminMiscService {
|
|||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
audience,
|
audience,
|
||||||
sentByAdminUserId: actorId,
|
sentByAdminId: actorId,
|
||||||
recipients: { create: customers.map((c) => ({ customerId: c.id })) },
|
recipients: { create: customers.map((c) => ({ customerId: c.id })) },
|
||||||
},
|
},
|
||||||
include: { _count: { select: { recipients: true } } },
|
include: { _count: { select: { recipients: true } } },
|
||||||
@@ -409,7 +409,7 @@ export class AdminMiscService {
|
|||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
audience: MessageAudience.SINGLE_NUMBER,
|
audience: MessageAudience.SINGLE_NUMBER,
|
||||||
sentByAdminUserId: actorId,
|
sentByAdminId: actorId,
|
||||||
recipients: { create: [{ customerId }] },
|
recipients: { create: [{ customerId }] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -420,7 +420,7 @@ export class AdminMiscService {
|
|||||||
const n = await this.prisma.notification.findUnique({
|
const n = await this.prisma.notification.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
sentByAdminUser: true,
|
sentByAdmin: true,
|
||||||
recipients: { include: { customer: true } },
|
recipients: { include: { customer: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -432,7 +432,7 @@ export class AdminMiscService {
|
|||||||
body: n.body,
|
body: n.body,
|
||||||
text: n.body,
|
text: n.body,
|
||||||
audience: n.audience,
|
audience: n.audience,
|
||||||
sender: n.sentByAdminUser?.fullName ?? null,
|
sender: n.sentByAdmin?.fullName ?? null,
|
||||||
caj: n.createdAt,
|
caj: n.createdAt,
|
||||||
recipients: n.recipients.map((r) => ({
|
recipients: n.recipients.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -462,7 +462,7 @@ export class AdminMiscService {
|
|||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
include: {
|
include: {
|
||||||
sentByAdminUser: true,
|
sentByAdmin: true,
|
||||||
_count: { select: { recipients: true } },
|
_count: { select: { recipients: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -475,7 +475,7 @@ export class AdminMiscService {
|
|||||||
title: n.title,
|
title: n.title,
|
||||||
text: n.body,
|
text: n.body,
|
||||||
audience: n.audience,
|
audience: n.audience,
|
||||||
sender: n.sentByAdminUser?.fullName ?? null,
|
sender: n.sentByAdmin?.fullName ?? null,
|
||||||
total_count: n._count.recipients,
|
total_count: n._count.recipients,
|
||||||
caj: n.createdAt,
|
caj: n.createdAt,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export class AdminOrdersService {
|
|||||||
customer: true,
|
customer: true,
|
||||||
branch: true,
|
branch: true,
|
||||||
items: { include: { productVariant: { include: { product: true } } } },
|
items: { include: { productVariant: { include: { product: true } } } },
|
||||||
statusEvents: { include: { actorAdminUser: true }, orderBy: { createdAt: 'asc' } },
|
statusEvents: { include: { actorAdmin: true }, orderBy: { createdAt: 'asc' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export class AdminUploadsService {
|
|||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
sizeBytes: file.size,
|
sizeBytes: file.size,
|
||||||
purpose: purpose ?? null,
|
purpose: purpose ?? null,
|
||||||
uploadedByAdminUserId: actorAdminUserId,
|
uploadedByAdminId: actorAdminUserId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { AdminUsersService } from './admin-users.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editing a sales expert in the panel must reach the app: the app shows the
|
||||||
|
* expert their own account through the linked trading Customer row, which is
|
||||||
|
* only seeded once at first sign-in.
|
||||||
|
*/
|
||||||
|
describe('AdminUsersService — expert edit reaches the trading account', () => {
|
||||||
|
const expertRow = {
|
||||||
|
id: 'exp1',
|
||||||
|
role: 'SALES_EXPERT',
|
||||||
|
fullName: 'سعید اسماعیلی',
|
||||||
|
firstName: 'سعید',
|
||||||
|
lastName: 'اسماعیلی',
|
||||||
|
mobile: '09902560722',
|
||||||
|
wagePercent: new Prisma.Decimal(12),
|
||||||
|
isActive: true,
|
||||||
|
salesExpertProfile: { tradingCustomerId: 'cust1', gramLevel: new Prisma.Decimal(0) },
|
||||||
|
};
|
||||||
|
|
||||||
|
function build(customerHolder: { id: string } | null = null) {
|
||||||
|
const customerUpdate = jest.fn(async () => ({}));
|
||||||
|
const tx = {
|
||||||
|
adminUser: {
|
||||||
|
update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => ({
|
||||||
|
...expertRow,
|
||||||
|
...(data.firstName !== undefined ? { firstName: data.firstName, fullName: `${data.firstName} ${data.lastName}` } : {}),
|
||||||
|
...(data.lastName !== undefined ? { lastName: data.lastName } : {}),
|
||||||
|
...(data.mobile !== undefined ? { mobile: data.mobile } : {}),
|
||||||
|
...(data.wagePercent !== undefined ? { wagePercent: data.wagePercent } : {}),
|
||||||
|
salesExpertProfile: {
|
||||||
|
...expertRow.salesExpertProfile,
|
||||||
|
...((data.salesExpertProfile as { upsert?: { update?: { gramLevel?: Prisma.Decimal } } } | undefined)
|
||||||
|
?.upsert?.update?.gramLevel !== undefined
|
||||||
|
? { gramLevel: (data.salesExpertProfile as { upsert: { update: { gramLevel: Prisma.Decimal } } }).upsert.update.gramLevel }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
customer: { update: customerUpdate, findUnique: jest.fn(async () => customerHolder) },
|
||||||
|
};
|
||||||
|
const prisma = {
|
||||||
|
...tx,
|
||||||
|
admin: { findUnique: jest.fn(async () => null) },
|
||||||
|
adminUser: {
|
||||||
|
...tx.adminUser,
|
||||||
|
findFirst: jest.fn(async () => expertRow),
|
||||||
|
// the expert-table mobile clash check: no other expert holds the number
|
||||||
|
findUnique: jest.fn(async () => null),
|
||||||
|
},
|
||||||
|
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
|
||||||
|
};
|
||||||
|
const service = new AdminUsersService(prisma as never, {} as never, {} as never);
|
||||||
|
return { service, customerUpdate };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('copies a name change to the trading account', async () => {
|
||||||
|
const { service, customerUpdate } = build();
|
||||||
|
await service.update('exp1', { fname: 'سعید1', lname: 'اسماعیلی1' });
|
||||||
|
expect(customerUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'cust1' },
|
||||||
|
data: { firstName: 'سعید1', lastName: 'اسماعیلی1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies the credit limit and wage percent the operator changed', async () => {
|
||||||
|
const { service, customerUpdate } = build();
|
||||||
|
await service.update('exp1', { gram_level: '50', percent: '3' });
|
||||||
|
const data = (customerUpdate.mock.calls[0] as unknown as [{ data: Record<string, Prisma.Decimal> }])[0].data;
|
||||||
|
expect(data.goldCreditLimitGrams.toString()).toBe('50');
|
||||||
|
expect(data.makingChargeOffsetPercent.toString()).toBe('3');
|
||||||
|
expect(data).not.toHaveProperty('firstName');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies a new mobile only when no other customer holds it', async () => {
|
||||||
|
const free = build(null);
|
||||||
|
await free.service.update('exp1', { mobile: '09120001111' });
|
||||||
|
expect(free.customerUpdate).toHaveBeenCalledWith({ where: { id: 'cust1' }, data: { mobile: '09120001111' } });
|
||||||
|
|
||||||
|
const taken = build({ id: 'someone-else' });
|
||||||
|
await taken.service.update('exp1', { mobile: '09120001111' });
|
||||||
|
expect(taken.customerUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes nothing to the trading account when nothing it mirrors changed', async () => {
|
||||||
|
const { service, customerUpdate } = build();
|
||||||
|
await service.update('exp1', { address: 'تهران' });
|
||||||
|
expect(customerUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,43 @@ const STORABLE_RIDS: ReadonlySet<string> = new Set(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin users and the permission matrix — legacy views/user/*, served by
|
* What the user form and grid read. Admins (table `Admin`) and sales experts
|
||||||
|
* (table `AdminUser`) are different tables, but the panel's forms bind to one
|
||||||
|
* shape, so both are mapped into it. The expert-only fields are optional and
|
||||||
|
* come out null for an admin.
|
||||||
|
*/
|
||||||
|
type UserRecord = {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
mobile: string;
|
||||||
|
role: AdminRole;
|
||||||
|
userTypeId: number | null;
|
||||||
|
userType: { id: number; title: string } | null;
|
||||||
|
branchId: string | null;
|
||||||
|
branch: { id: string; name: string } | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
wagePercent?: Prisma.Decimal | null;
|
||||||
|
address?: string | null;
|
||||||
|
storeName?: string | null;
|
||||||
|
pasazhName?: string | null;
|
||||||
|
memberCode?: string | null;
|
||||||
|
avatarFileId?: string | null;
|
||||||
|
avatarFile?: { id: string; url: string } | null;
|
||||||
|
// D17 — present on the detail read; absent from the list query, hence optional.
|
||||||
|
salesExpertProfile?: {
|
||||||
|
cityId: string | null;
|
||||||
|
gramLevel: Prisma.Decimal;
|
||||||
|
phone: string | null;
|
||||||
|
nationalCode: string | null;
|
||||||
|
city?: { id: string; name: string } | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panel users and the permission matrix — legacy views/user/*, served by
|
||||||
* UserController and RoleController:
|
* UserController and RoleController:
|
||||||
* POST/GET/PUT/DELETE users -> the admin user list and form
|
* POST/GET/PUT/DELETE users -> the admin user list and form
|
||||||
* GET const/__list__ut -> the user-type ("سطح دسترسی") picker
|
* GET const/__list__ut -> the user-type ("سطح دسترسی") picker
|
||||||
@@ -30,6 +66,13 @@ const STORABLE_RIDS: ReadonlySet<string> = new Set(
|
|||||||
* POST roles/create -> grant rids
|
* POST roles/create -> grant rids
|
||||||
* DELETE roles/{ut}/{rid} -> revoke one rid
|
* DELETE roles/{ut}/{rid} -> revoke one rid
|
||||||
*
|
*
|
||||||
|
* Two tables sit behind the one `users` endpoint:
|
||||||
|
* - `Admin` — panel staff. The grid lists ONLY these.
|
||||||
|
* - `AdminUser` — sales experts (role SALES_EXPERT), created and edited from
|
||||||
|
* the reagent form, which posts here with `role: 'SALES_EXPERT'`. An expert
|
||||||
|
* is never written to `Admin`, and staff management never writes to
|
||||||
|
* `AdminUser`, so the same mobile can be both (each is unique per table).
|
||||||
|
*
|
||||||
* The permission matrix writes the `permission(rid, userTypeId)` rows that
|
* The permission matrix writes the `permission(rid, userTypeId)` rows that
|
||||||
* Phase 1 modelled, so this screen is what actually administers the ACL.
|
* Phase 1 modelled, so this screen is what actually administers the ACL.
|
||||||
*/
|
*/
|
||||||
@@ -65,7 +108,7 @@ export class AdminUsersService {
|
|||||||
active: 'isActive',
|
active: 'isActive',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Legacy `GET users` — the admin user grid. */
|
/** Legacy `GET users` — the admin user grid (panel staff only). */
|
||||||
async list(query: TableQueryDto) {
|
async list(query: TableQueryDto) {
|
||||||
this.table.assertNotExport(query);
|
this.table.assertNotExport(query);
|
||||||
|
|
||||||
@@ -94,39 +137,26 @@ export class AdminUsersService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The support super admin is not listed: to everyone else it does not exist.
|
// The support super admin is not listed: to everyone else it does not exist.
|
||||||
const visible: Prisma.AdminUserWhereInput = {
|
const visible: Prisma.AdminWhereInput = {
|
||||||
AND: [where as Prisma.AdminUserWhereInput, { mobile: { not: SUPER_ADMIN_MOBILE } }],
|
AND: [where as Prisma.AdminWhereInput, { mobile: { not: SUPER_ADMIN_MOBILE } }],
|
||||||
};
|
};
|
||||||
|
|
||||||
const [rows, total] = await Promise.all([
|
const [rows, total] = await Promise.all([
|
||||||
this.prisma.adminUser.findMany({
|
this.prisma.admin.findMany({
|
||||||
where: visible,
|
where: visible,
|
||||||
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
|
orderBy: orderBy as Prisma.AdminOrderByWithRelationInput,
|
||||||
skip,
|
skip,
|
||||||
take,
|
take,
|
||||||
include: { userType: true, branch: true, avatarFile: true },
|
include: { userType: true, branch: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.adminUser.count({ where: visible }),
|
this.prisma.admin.count({ where: visible }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy field names preserved; the password hash is never exposed. */
|
/** Legacy field names preserved; the password hash is never exposed. */
|
||||||
private toRow(
|
private toRow(u: UserRecord) {
|
||||||
u: Prisma.AdminUserGetPayload<{
|
|
||||||
include: { userType: true; branch: true; avatarFile: true };
|
|
||||||
}> & {
|
|
||||||
// D17 — present on the detail read; absent from the list query, hence optional.
|
|
||||||
salesExpertProfile?: {
|
|
||||||
cityId: string | null;
|
|
||||||
gramLevel: Prisma.Decimal;
|
|
||||||
phone: string | null;
|
|
||||||
nationalCode: string | null;
|
|
||||||
city?: { id: string; name: string } | null;
|
|
||||||
} | null;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
name: u.fullName,
|
name: u.fullName,
|
||||||
@@ -140,20 +170,20 @@ export class AdminUsersService {
|
|||||||
user_type: u.userType ? { id: u.userType.id, title: u.userType.title } : null,
|
user_type: u.userType ? { id: u.userType.id, title: u.userType.title } : null,
|
||||||
branch_id: u.branchId,
|
branch_id: u.branchId,
|
||||||
branch: u.branch ? { id: u.branch.id, name: u.branch.name } : null,
|
branch: u.branch ? { id: u.branch.id, name: u.branch.name } : null,
|
||||||
percent: u.wagePercent,
|
percent: u.wagePercent ?? null,
|
||||||
active: u.isActive ? 1 : -1,
|
active: u.isActive ? 1 : -1,
|
||||||
is_active: u.isActive,
|
is_active: u.isActive,
|
||||||
caj: u.createdAt,
|
caj: u.createdAt,
|
||||||
// Sales-agent fields (ReagentCustomer.vue) — set only for role=SALES_EXPERT,
|
// Sales-agent fields (ReagentCustomer.vue) — set only for role=SALES_EXPERT,
|
||||||
// null for every other admin. Same shapes admin-customers.service.ts used
|
// null for every admin. Same shapes admin-customers.service.ts used
|
||||||
// when this data still lived on Customer, so the form binds unchanged.
|
// when this data still lived on Customer, so the form binds unchanged.
|
||||||
address: u.address,
|
address: u.address ?? null,
|
||||||
store_name: u.storeName,
|
store_name: u.storeName ?? null,
|
||||||
pasazh_name: u.pasazhName,
|
pasazh_name: u.pasazhName ?? null,
|
||||||
mcode: u.memberCode,
|
mcode: u.memberCode ?? null,
|
||||||
image_id: u.avatarFileId,
|
image_id: u.avatarFileId ?? null,
|
||||||
image: u.avatarFile ? { id: u.avatarFile.id, image: u.avatarFile.url } : null,
|
image: u.avatarFile ? { id: u.avatarFile.id, image: u.avatarFile.url } : null,
|
||||||
// D17 — from SalesExpertProfile. Null for a non-expert, and null-safe for
|
// D17 — from SalesExpertProfile. Null for an admin, and null-safe for
|
||||||
// an expert whose profile row is somehow missing.
|
// an expert whose profile row is somehow missing.
|
||||||
city_id: u.salesExpertProfile?.cityId ?? null,
|
city_id: u.salesExpertProfile?.cityId ?? null,
|
||||||
city: u.salesExpertProfile?.city
|
city: u.salesExpertProfile?.city
|
||||||
@@ -165,9 +195,23 @@ export class AdminUsersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string) {
|
/** A panel admin by id, or null. The support super admin is never found. */
|
||||||
const u = await this.prisma.adminUser.findUnique({
|
private async findAdmin(id: string) {
|
||||||
|
const admin = await this.prisma.admin.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
include: { userType: true, branch: true },
|
||||||
|
});
|
||||||
|
return admin && !isSuperAdmin(admin.mobile) ? admin : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sales expert by id, or null. `role` is part of the lookup on purpose:
|
||||||
|
* "AdminUser" still holds the pre-split copies of the admins (dormant), and
|
||||||
|
* those must never be reachable as experts either.
|
||||||
|
*/
|
||||||
|
private async findExpert(id: string) {
|
||||||
|
const expert = await this.prisma.adminUser.findFirst({
|
||||||
|
where: { id, role: AdminRole.SALES_EXPERT },
|
||||||
// D17 — the edit form needs the sales-expert fields too.
|
// D17 — the edit form needs the sales-expert fields too.
|
||||||
include: {
|
include: {
|
||||||
userType: true,
|
userType: true,
|
||||||
@@ -176,8 +220,17 @@ export class AdminUsersService {
|
|||||||
salesExpertProfile: { include: { city: true } },
|
salesExpertProfile: { include: { city: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد');
|
return expert && !isSuperAdmin(expert.mobile) ? expert : null;
|
||||||
return this.toRow(u);
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const admin = await this.findAdmin(id);
|
||||||
|
if (admin) return this.toRow(admin);
|
||||||
|
|
||||||
|
const expert = await this.findExpert(id);
|
||||||
|
if (expert) return this.toRow(expert);
|
||||||
|
|
||||||
|
throw new NotFoundException('کاربر یافت نشد');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,6 +240,8 @@ export class AdminUsersService {
|
|||||||
* caller omitted it, which silently turned every admin created without an
|
* caller omitted it, which silently turned every admin created without an
|
||||||
* explicit role into a sales agent regardless of the access level ("نوع
|
* explicit role into a sales agent regardless of the access level ("نوع
|
||||||
* کاربری") actually chosen. UserCreate.vue now always sends one.
|
* کاربری") actually chosen. UserCreate.vue now always sends one.
|
||||||
|
*
|
||||||
|
* SALES_EXPERT goes to the `AdminUser` table, every other role to `Admin`.
|
||||||
*/
|
*/
|
||||||
async create(data: Record<string, unknown>) {
|
async create(data: Record<string, unknown>) {
|
||||||
// D0 requirement 7 — store the real name parts, as Customer does. `name`
|
// D0 requirement 7 — store the real name parts, as Customer does. `name`
|
||||||
@@ -206,7 +261,43 @@ export class AdminUsersService {
|
|||||||
if (!mobile) throw new BadRequestException('موبایل الزامی است');
|
if (!mobile) throw new BadRequestException('موبایل الزامی است');
|
||||||
if (!password) throw new BadRequestException('رمز عبور الزامی است');
|
if (!password) throw new BadRequestException('رمز عبور الزامی است');
|
||||||
if (!role) throw new BadRequestException('نقش کاربر الزامی است');
|
if (!role) throw new BadRequestException('نقش کاربر الزامی است');
|
||||||
|
if (!Object.values(AdminRole).includes(role)) throw new BadRequestException('نقش کاربر معتبر نیست');
|
||||||
|
|
||||||
|
if (role === AdminRole.SALES_EXPERT) {
|
||||||
|
return this.createExpert({ data, firstName, lastName, fullName, mobile, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique within `Admin` only: the same number may also be a sales expert.
|
||||||
|
const existing = await this.prisma.admin.findUnique({ where: { mobile } });
|
||||||
|
if (existing) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||||
|
|
||||||
|
return this.prisma.admin.create({
|
||||||
|
data: {
|
||||||
|
fullName,
|
||||||
|
firstName: firstName || null,
|
||||||
|
lastName: lastName || null,
|
||||||
|
mobile,
|
||||||
|
passwordHash: await bcrypt.hash(password, 10),
|
||||||
|
role,
|
||||||
|
userTypeId: data.ut !== undefined && data.ut !== null ? Number(data.ut) : null,
|
||||||
|
branchId: (data.branch_id as string) || null,
|
||||||
|
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
|
||||||
|
},
|
||||||
|
include: { userType: true, branch: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createExpert(input: {
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
fullName: string;
|
||||||
|
mobile: string;
|
||||||
|
password: string;
|
||||||
|
}) {
|
||||||
|
const { data, firstName, lastName, fullName, mobile, password } = input;
|
||||||
|
|
||||||
|
// Unique within the expert table only: the same number may also be an admin.
|
||||||
const existing = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
const existing = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
||||||
if (existing) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
if (existing) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||||
|
|
||||||
@@ -217,7 +308,7 @@ export class AdminUsersService {
|
|||||||
lastName: lastName || null,
|
lastName: lastName || null,
|
||||||
mobile,
|
mobile,
|
||||||
passwordHash: await bcrypt.hash(password, 10),
|
passwordHash: await bcrypt.hash(password, 10),
|
||||||
role,
|
role: AdminRole.SALES_EXPERT,
|
||||||
userTypeId: data.ut !== undefined && data.ut !== null ? Number(data.ut) : null,
|
userTypeId: data.ut !== undefined && data.ut !== null ? Number(data.ut) : null,
|
||||||
branchId: (data.branch_id as string) || null,
|
branchId: (data.branch_id as string) || null,
|
||||||
wagePercent:
|
wagePercent:
|
||||||
@@ -225,8 +316,7 @@ export class AdminUsersService {
|
|||||||
? new Prisma.Decimal(String(data.percent))
|
? new Prisma.Decimal(String(data.percent))
|
||||||
: null,
|
: null,
|
||||||
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
|
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
|
||||||
// Sales-agent-only fields (ReagentCustomer.vue); undefined for every
|
// Sales-agent-only fields (ReagentCustomer.vue).
|
||||||
// other role, which Prisma leaves at column default (null).
|
|
||||||
address: (data.address as string) || null,
|
address: (data.address as string) || null,
|
||||||
storeName: (data.store_name as string) || null,
|
storeName: (data.store_name as string) || null,
|
||||||
pasazhName: (data.pasazh_name as string) || null,
|
pasazhName: (data.pasazh_name as string) || null,
|
||||||
@@ -234,9 +324,7 @@ export class AdminUsersService {
|
|||||||
avatarFileId: (data.image_id as string) || null,
|
avatarFileId: (data.image_id as string) || null,
|
||||||
// D17 — a sales expert always gets its profile row, so the grid's city
|
// D17 — a sales expert always gets its profile row, so the grid's city
|
||||||
// and credit-limit columns have somewhere to read from immediately.
|
// and credit-limit columns have somewhere to read from immediately.
|
||||||
...(role === 'SALES_EXPERT'
|
salesExpertProfile: { create: this.salesExpertProfileData(data) },
|
||||||
? { salesExpertProfile: { create: this.salesExpertProfileData(data) } }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
userType: true,
|
userType: true,
|
||||||
@@ -268,13 +356,24 @@ export class AdminUsersService {
|
|||||||
|
|
||||||
/** Legacy `PUT users/{id}`. */
|
/** Legacy `PUT users/{id}`. */
|
||||||
async update(id: string, data: Record<string, unknown>) {
|
async update(id: string, data: Record<string, unknown>) {
|
||||||
const existing = await this.prisma.adminUser.findUnique({ where: { id } });
|
const admin = await this.findAdmin(id);
|
||||||
if (!existing || isSuperAdmin(existing.mobile)) throw new NotFoundException('کاربر یافت نشد');
|
if (admin) return this.updateAdmin(admin, data);
|
||||||
|
|
||||||
const patch: Prisma.AdminUserUpdateInput = {};
|
const expert = await this.findExpert(id);
|
||||||
|
if (expert) return this.updateExpert(expert, data);
|
||||||
|
|
||||||
|
throw new NotFoundException('کاربر یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateAdmin(
|
||||||
|
existing: NonNullable<Awaited<ReturnType<AdminUsersService['findAdmin']>>>,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const id = existing.id;
|
||||||
|
const patch: Prisma.AdminUpdateInput = {};
|
||||||
|
|
||||||
// D0 requirement 7 — write the parts and let the
|
// D0 requirement 7 — write the parts and let the
|
||||||
// admin_user_sync_full_name trigger rebuild `fullName`. When a caller sends
|
// admin_sync_full_name trigger rebuild `fullName`. When a caller sends
|
||||||
// both, the parts win (see the migration header).
|
// both, the parts win (see the migration header).
|
||||||
if (data.fname !== undefined || data.lname !== undefined) {
|
if (data.fname !== undefined || data.lname !== undefined) {
|
||||||
const firstName = String(data.fname ?? existing.firstName ?? '').trim();
|
const firstName = String(data.fname ?? existing.firstName ?? '').trim();
|
||||||
@@ -290,14 +389,82 @@ export class AdminUsersService {
|
|||||||
// Normalised before the clash check, for the same reason as create().
|
// Normalised before the clash check, for the same reason as create().
|
||||||
const mobile = faToEn(String(data.mobile));
|
const mobile = faToEn(String(data.mobile));
|
||||||
if (mobile !== existing.mobile) {
|
if (mobile !== existing.mobile) {
|
||||||
const clash = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
const clash = await this.prisma.admin.findUnique({ where: { mobile } });
|
||||||
if (clash) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
if (clash) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||||
}
|
}
|
||||||
patch.mobile = mobile;
|
patch.mobile = mobile;
|
||||||
}
|
}
|
||||||
// A blank password field means "leave it unchanged" — legacy did the same.
|
// A blank password field means "leave it unchanged" — legacy did the same.
|
||||||
if (data.password) patch.passwordHash = await bcrypt.hash(String(data.password), 10);
|
if (data.password) patch.passwordHash = await bcrypt.hash(String(data.password), 10);
|
||||||
if (data.role !== undefined) patch.role = data.role as AdminRole;
|
if (data.role !== undefined) {
|
||||||
|
const role = data.role as AdminRole;
|
||||||
|
if (!Object.values(AdminRole).includes(role)) throw new BadRequestException('نقش کاربر معتبر نیست');
|
||||||
|
// An admin cannot be turned into an expert: they live in different tables.
|
||||||
|
// Create the expert from the reagent form instead.
|
||||||
|
if (role === AdminRole.SALES_EXPERT) {
|
||||||
|
throw new BadRequestException('نقش کارشناس فروش برای ادمین قابل تنظیم نیست؛ کارشناس را از بخش کارشناسان بسازید');
|
||||||
|
}
|
||||||
|
patch.role = role;
|
||||||
|
}
|
||||||
|
if (data.ut !== undefined) {
|
||||||
|
patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true };
|
||||||
|
}
|
||||||
|
if (data.branch_id !== undefined) {
|
||||||
|
patch.branch = data.branch_id
|
||||||
|
? { connect: { id: String(data.branch_id) } }
|
||||||
|
: { disconnect: true };
|
||||||
|
}
|
||||||
|
if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active);
|
||||||
|
|
||||||
|
const updated = await this.prisma.admin.update({
|
||||||
|
where: { id },
|
||||||
|
data: patch,
|
||||||
|
include: { userType: true, branch: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disabling an account or changing its password used to leave every already
|
||||||
|
// issued token working for up to 12h. With session tracking on, those tokens
|
||||||
|
// end here; with it off, nothing happens and the behaviour is as before.
|
||||||
|
if (patch.isActive === false || patch.passwordHash !== undefined) {
|
||||||
|
await this.sessions.revokeAllForAdmin(
|
||||||
|
id,
|
||||||
|
patch.isActive === false ? 'account_disabled' : 'password_changed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateExpert(
|
||||||
|
existing: NonNullable<Awaited<ReturnType<AdminUsersService['findExpert']>>>,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const id = existing.id;
|
||||||
|
const patch: Prisma.AdminUserUpdateInput = {};
|
||||||
|
|
||||||
|
if (data.fname !== undefined || data.lname !== undefined) {
|
||||||
|
const firstName = String(data.fname ?? existing.firstName ?? '').trim();
|
||||||
|
const lastName = String(data.lname ?? existing.lastName ?? '').trim();
|
||||||
|
patch.firstName = firstName || null;
|
||||||
|
patch.lastName = lastName || null;
|
||||||
|
} else if (data.name !== undefined) {
|
||||||
|
patch.fullName = String(data.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.mobile !== undefined) {
|
||||||
|
const mobile = faToEn(String(data.mobile));
|
||||||
|
if (mobile !== existing.mobile) {
|
||||||
|
const clash = await this.prisma.adminUser.findUnique({ where: { mobile } });
|
||||||
|
if (clash) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
patch.mobile = mobile;
|
||||||
|
}
|
||||||
|
if (data.password) patch.passwordHash = await bcrypt.hash(String(data.password), 10);
|
||||||
|
// An expert stays an expert: moving them to another role would have to move
|
||||||
|
// them to the other table.
|
||||||
|
if (data.role !== undefined && data.role !== AdminRole.SALES_EXPERT) {
|
||||||
|
throw new BadRequestException('نقش کارشناس فروش قابل تغییر نیست');
|
||||||
|
}
|
||||||
if (data.ut !== undefined) {
|
if (data.ut !== undefined) {
|
||||||
patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true };
|
patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true };
|
||||||
}
|
}
|
||||||
@@ -322,22 +489,18 @@ export class AdminUsersService {
|
|||||||
: { disconnect: true };
|
: { disconnect: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// D17 — the profile follows the role.
|
// D17 — the profile follows the expert.
|
||||||
//
|
//
|
||||||
// `upsert` covers all three cases in one statement: a user becoming a sales
|
// `upsert` covers both cases in one statement: an existing expert's profile
|
||||||
// expert gets a profile; an existing expert's profile is updated; and an
|
// is updated, and an expert whose profile went missing gets one back rather
|
||||||
// expert whose profile went missing gets one back rather than erroring.
|
// than erroring.
|
||||||
// A profile is never deleted when the role changes away — that would
|
|
||||||
// silently discard the operator's city and credit limit on a mistaken role
|
|
||||||
// switch, and it costs nothing to keep.
|
|
||||||
const effectiveRole = (patch.role as AdminRole | undefined) ?? existing.role;
|
|
||||||
const touchesProfile =
|
const touchesProfile =
|
||||||
data.city_id !== undefined ||
|
data.city_id !== undefined ||
|
||||||
data.gram_level !== undefined ||
|
data.gram_level !== undefined ||
|
||||||
data.phone !== undefined ||
|
data.phone !== undefined ||
|
||||||
data.ncode !== undefined;
|
data.ncode !== undefined;
|
||||||
|
|
||||||
if (effectiveRole === 'SALES_EXPERT' && (touchesProfile || patch.role !== undefined)) {
|
if (touchesProfile) {
|
||||||
const profile = this.salesExpertProfileData(data);
|
const profile = this.salesExpertProfileData(data);
|
||||||
patch.salesExpertProfile = {
|
patch.salesExpertProfile = {
|
||||||
upsert: {
|
upsert: {
|
||||||
@@ -354,7 +517,10 @@ export class AdminUsersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.prisma.adminUser.update({
|
// Experts sign in to the apps, whose sessions are not admin-panel sessions,
|
||||||
|
// so there is nothing to revoke here.
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const updated = await tx.adminUser.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: patch,
|
data: patch,
|
||||||
include: {
|
include: {
|
||||||
@@ -364,18 +530,76 @@ export class AdminUsersService {
|
|||||||
salesExpertProfile: { include: { city: true } },
|
salesExpertProfile: { include: { city: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.syncTradingAccount(tx, updated, existing.mobile, {
|
||||||
// Disabling an account or changing its password used to leave every already
|
name: patch.firstName !== undefined || patch.lastName !== undefined || patch.fullName !== undefined,
|
||||||
// issued token working for up to 12h. With session tracking on, those tokens
|
mobile: patch.mobile !== undefined,
|
||||||
// end here; with it off, nothing happens and the behaviour is as before.
|
gramLevel: data.gram_level !== undefined,
|
||||||
if (patch.isActive === false || patch.passwordHash !== undefined) {
|
percent: data.percent !== undefined,
|
||||||
await this.sessions.revokeAllForAdmin(
|
});
|
||||||
id,
|
return updated;
|
||||||
patch.isActive === false ? 'account_disabled' : 'password_changed',
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated;
|
/**
|
||||||
|
* Mirror an expert edit onto their trading Customer row (D21).
|
||||||
|
*
|
||||||
|
* The apps show an expert their OWN account through that row (/customer/me),
|
||||||
|
* which is seeded from the expert once, at first sign-in, and was never
|
||||||
|
* updated again — so a name or credit-limit change made here never reached
|
||||||
|
* the app. In the reference the agent and that account were ONE row
|
||||||
|
* (customers.type=1), so an edit changed both; this restores that.
|
||||||
|
*
|
||||||
|
* Only the fields the operator actually changed are written. The mobile is
|
||||||
|
* copied only when no other customer holds it (the column is unique);
|
||||||
|
* otherwise it is left and logged rather than failing the expert's save.
|
||||||
|
*/
|
||||||
|
private async syncTradingAccount(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
expert: {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
mobile: string;
|
||||||
|
wagePercent: Prisma.Decimal | null;
|
||||||
|
salesExpertProfile: { tradingCustomerId: string | null; gramLevel: Prisma.Decimal } | null;
|
||||||
|
},
|
||||||
|
previousMobile: string,
|
||||||
|
changed: { name: boolean; mobile: boolean; gramLevel: boolean; percent: boolean },
|
||||||
|
) {
|
||||||
|
const tradingId = expert.salesExpertProfile?.tradingCustomerId;
|
||||||
|
if (!tradingId) return;
|
||||||
|
|
||||||
|
const data: Prisma.CustomerUpdateInput = {};
|
||||||
|
if (changed.name) {
|
||||||
|
// Write the parts; the customer_sync_full_name trigger rebuilds fullName.
|
||||||
|
if (expert.firstName !== null || expert.lastName !== null) {
|
||||||
|
data.firstName = expert.firstName;
|
||||||
|
data.lastName = expert.lastName;
|
||||||
|
} else {
|
||||||
|
data.fullName = expert.fullName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed.mobile && expert.mobile !== previousMobile) {
|
||||||
|
const holder = await tx.customer.findUnique({ where: { mobile: expert.mobile }, select: { id: true } });
|
||||||
|
if (!holder) {
|
||||||
|
data.mobile = expert.mobile;
|
||||||
|
} else if (holder.id !== tradingId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Expert ${expert.id}: new mobile ${expert.mobile} already belongs to customer ${holder.id}; trading account ${tradingId} keeps its old mobile.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed.gramLevel && expert.salesExpertProfile) {
|
||||||
|
data.goldCreditLimitGrams = expert.salesExpertProfile.gramLevel;
|
||||||
|
}
|
||||||
|
if (changed.percent) {
|
||||||
|
data.makingChargeOffsetPercent = expert.wagePercent ?? new Prisma.Decimal(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(data).length > 0) {
|
||||||
|
await tx.customer.update({ where: { id: tradingId }, data });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -386,25 +610,36 @@ export class AdminUsersService {
|
|||||||
* audit trail. Deactivation is offered instead. Legacy deleted regardless.
|
* audit trail. Deactivation is offered instead. Legacy deleted regardless.
|
||||||
*/
|
*/
|
||||||
async remove(id: string, actorId: string) {
|
async remove(id: string, actorId: string) {
|
||||||
const u = await this.prisma.adminUser.findUnique({
|
const admin = await this.prisma.admin.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
_count: { select: { postedLedgerEntries: true, orderStatusEvents: true } },
|
_count: { select: { postedLedgerEntries: true, orderStatusEvents: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد');
|
|
||||||
|
if (admin) {
|
||||||
|
if (isSuperAdmin(admin.mobile)) throw new NotFoundException('کاربر یافت نشد');
|
||||||
|
|
||||||
if (id === actorId) {
|
if (id === actorId) {
|
||||||
throw new BadRequestException('نمیتوانید حساب کاربری خودتان را حذف کنید');
|
throw new BadRequestException('نمیتوانید حساب کاربری خودتان را حذف کنید');
|
||||||
}
|
}
|
||||||
|
|
||||||
const refs = u._count.postedLedgerEntries + u._count.orderStatusEvents;
|
const refs = admin._count.postedLedgerEntries + admin._count.orderStatusEvents;
|
||||||
if (refs > 0) {
|
if (refs > 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`حذف این کاربر ممکن نیست، چون ${refs} سند یا تغییر وضعیت به نام او ثبت شده است. میتوانید غیرفعالش کنید.`,
|
`حذف این کاربر ممکن نیست، چون ${refs} سند یا تغییر وضعیت به نام او ثبت شده است. میتوانید غیرفعالش کنید.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.prisma.admin.delete({ where: { id } });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sales expert. Their orders / referred customers are set null by the
|
||||||
|
// foreign keys, as before the split.
|
||||||
|
const expert = await this.findExpert(id);
|
||||||
|
if (!expert) throw new NotFoundException('کاربر یافت نشد');
|
||||||
|
|
||||||
await this.prisma.adminUser.delete({ where: { id } });
|
await this.prisma.adminUser.delete({ where: { id } });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -415,13 +650,13 @@ export class AdminUsersService {
|
|||||||
async listUserTypes() {
|
async listUserTypes() {
|
||||||
const rows = await this.prisma.userType.findMany({
|
const rows = await this.prisma.userType.findMany({
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
include: { _count: { select: { adminUsers: true, permissions: true } } },
|
include: { _count: { select: { admins: true, adminUsers: true, permissions: true } } },
|
||||||
});
|
});
|
||||||
// legacy returned {id, title} pairs; the counts are additive, not breaking.
|
// legacy returned {id, title} pairs; the counts are additive, not breaking.
|
||||||
return rows.map((t) => ({
|
return rows.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
users_count: t._count.adminUsers,
|
users_count: t._count.admins + t._count.adminUsers,
|
||||||
permissions_count: t._count.permissions,
|
permissions_count: t._count.permissions,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -505,15 +740,19 @@ export class AdminUsersService {
|
|||||||
|
|
||||||
/** Legacy `GET users/show` equivalent for the "اطلاعات من" screen. */
|
/** Legacy `GET users/show` equivalent for the "اطلاعات من" screen. */
|
||||||
async myData(adminId: string) {
|
async myData(adminId: string) {
|
||||||
return this.findOne(adminId);
|
const admin = await this.findAdmin(adminId);
|
||||||
|
if (!admin) throw new NotFoundException('کاربر یافت نشد');
|
||||||
|
return this.toRow(admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The AdminRole picker for the user form. */
|
/**
|
||||||
|
* The AdminRole picker for the staff form. SALES_EXPERT is not offered: an
|
||||||
|
* expert is a different table and is created from the reagent form.
|
||||||
|
*/
|
||||||
roleOptions() {
|
roleOptions() {
|
||||||
return [
|
return [
|
||||||
{ value: AdminRole.SUPER_ADMIN, title: 'مدیر ارشد' },
|
{ value: AdminRole.SUPER_ADMIN, title: 'مدیر ارشد' },
|
||||||
{ value: AdminRole.SYSTEM_APPROVER, title: 'تایید مدیر سیستم' },
|
{ value: AdminRole.SYSTEM_APPROVER, title: 'تایید مدیر سیستم' },
|
||||||
{ value: AdminRole.SALES_EXPERT, title: 'کارشناس فروش' },
|
|
||||||
{ value: AdminRole.WORKSHOP_SUPERVISOR, title: 'سرپرست کارگاه' },
|
{ value: AdminRole.WORKSHOP_SUPERVISOR, title: 'سرپرست کارگاه' },
|
||||||
{ value: AdminRole.ACCOUNTANT, title: 'حسابدار' },
|
{ value: AdminRole.ACCOUNTANT, title: 'حسابدار' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ describe('AuthService login', () => {
|
|||||||
sessions: Partial<AdminSessionService> = untrackedSessions(),
|
sessions: Partial<AdminSessionService> = untrackedSessions(),
|
||||||
) => {
|
) => {
|
||||||
const prisma = {
|
const prisma = {
|
||||||
adminUser: { findUnique: jest.fn().mockResolvedValue(user) },
|
admin: { findUnique: jest.fn().mockResolvedValue(user) },
|
||||||
};
|
};
|
||||||
const jwt = { signAsync: jest.fn().mockResolvedValue('local.jwt.token') };
|
const jwt = { signAsync: jest.fn().mockResolvedValue('local.jwt.token') };
|
||||||
|
|
||||||
@@ -241,7 +241,7 @@ describe('AuthService login', () => {
|
|||||||
// Without the IAM session id, a back-channel logout could not find this session
|
// Without the IAM session id, a back-channel logout could not find this session
|
||||||
expect(tracked.record).toHaveBeenCalledWith({
|
expect(tracked.record).toHaveBeenCalledWith({
|
||||||
jti: 'jti-1',
|
jti: 'jti-1',
|
||||||
adminUserId: 'admin-1',
|
adminId: 'admin-1',
|
||||||
iamSessionId: 'sid-1',
|
iamSessionId: 'sid-1',
|
||||||
iamSub: 'sub-1',
|
iamSub: 'sub-1',
|
||||||
ipAddress: '203.0.113.5',
|
ipAddress: '203.0.113.5',
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export class AuthService {
|
|||||||
await this.ensureStaticAdmin();
|
await this.ensureStaticAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.prisma.adminUser.findUnique({
|
const user = await this.prisma.admin.findUnique({
|
||||||
where: { mobile: isStaticAdmin ? STATIC_ADMIN_MOBILE : dto.phone },
|
where: { mobile: isStaticAdmin ? STATIC_ADMIN_MOBILE : dto.phone },
|
||||||
include: { userType: { include: { permissions: true } } },
|
include: { userType: { include: { permissions: true } } },
|
||||||
});
|
});
|
||||||
@@ -83,7 +83,7 @@ export class AuthService {
|
|||||||
// this token before it expires — see AdminSessionService.
|
// this token before it expires — see AdminSessionService.
|
||||||
await this.sessions.record({
|
await this.sessions.record({
|
||||||
jti,
|
jti,
|
||||||
adminUserId: user.id,
|
adminId: user.id,
|
||||||
iamSessionId: verified.identity?.sessionId ?? null,
|
iamSessionId: verified.identity?.sessionId ?? null,
|
||||||
iamSub: verified.identity?.sub ?? null,
|
iamSub: verified.identity?.sub ?? null,
|
||||||
ipAddress: clientIp ?? null,
|
ipAddress: clientIp ?? null,
|
||||||
@@ -153,7 +153,7 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد');
|
throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.prisma.adminUser.findFirst({
|
const user = await this.prisma.admin.findFirst({
|
||||||
where: { mobile: { in: mobileVariants(claims.phoneNumber) }, isActive: true },
|
where: { mobile: { in: mobileVariants(claims.phoneNumber) }, isActive: true },
|
||||||
include: { userType: { include: { permissions: true } } },
|
include: { userType: { include: { permissions: true } } },
|
||||||
});
|
});
|
||||||
@@ -211,7 +211,7 @@ export class AuthService {
|
|||||||
if (jti) {
|
if (jti) {
|
||||||
await this.sessions.record({
|
await this.sessions.record({
|
||||||
jti,
|
jti,
|
||||||
adminUserId: user.id,
|
adminId: user.id,
|
||||||
iamSessionId: identity?.sessionId ?? null,
|
iamSessionId: identity?.sessionId ?? null,
|
||||||
iamSub: identity?.sub ?? null,
|
iamSub: identity?.sub ?? null,
|
||||||
ipAddress: ip ?? null,
|
ipAddress: ip ?? null,
|
||||||
@@ -238,7 +238,7 @@ export class AuthService {
|
|||||||
const fullAccess = await this.prisma.userType.findUnique({ where: { id: 85 } });
|
const fullAccess = await this.prisma.userType.findUnique({ where: { id: 85 } });
|
||||||
const userTypeId = fullAccess ? 85 : null;
|
const userTypeId = fullAccess ? 85 : null;
|
||||||
const passwordHash = await bcrypt.hash(STATIC_ADMIN_MOBILE, 10);
|
const passwordHash = await bcrypt.hash(STATIC_ADMIN_MOBILE, 10);
|
||||||
await this.prisma.adminUser.upsert({
|
await this.prisma.admin.upsert({
|
||||||
where: { mobile: STATIC_ADMIN_MOBILE },
|
where: { mobile: STATIC_ADMIN_MOBILE },
|
||||||
update: { role: AdminRole.SUPER_ADMIN, userTypeId, branchId: null, isActive: true },
|
update: { role: AdminRole.SUPER_ADMIN, userTypeId, branchId: null, isActive: true },
|
||||||
create: {
|
create: {
|
||||||
@@ -334,7 +334,7 @@ export class AuthService {
|
|||||||
* legacy. The guard's own checks use the pre-expanded list from JwtStrategy.
|
* legacy. The guard's own checks use the pre-expanded list from JwtStrategy.
|
||||||
*/
|
*/
|
||||||
async showData(adminId: string) {
|
async showData(adminId: string) {
|
||||||
const user = await this.prisma.adminUser.findUnique({
|
const user = await this.prisma.admin.findUnique({
|
||||||
where: { id: adminId },
|
where: { id: adminId },
|
||||||
include: { userType: { include: { permissions: true } } },
|
include: { userType: { include: { permissions: true } } },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.prisma.adminUser.findUnique({
|
const user = await this.prisma.admin.findUnique({
|
||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
include: { userType: { include: { permissions: true } } },
|
include: { userType: { include: { permissions: true } } },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ export class OrdersService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await tx.orderStatusEvent.create({
|
await tx.orderStatusEvent.create({
|
||||||
data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminUserId },
|
data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminId: actorAdminUserId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debt follows the status, as legacy's recomputed `sts > 4` did: owed
|
// Debt follows the status, as legacy's recomputed `sts > 4` did: owed
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ describe('AdminSessionService', () => {
|
|||||||
const { service, prisma } = build();
|
const { service, prisma } = build();
|
||||||
|
|
||||||
expect(service.newTokenId()).toBeNull();
|
expect(service.newTokenId()).toBeNull();
|
||||||
await service.record({ jti: 'x', adminUserId: 'a' });
|
await service.record({ jti: 'x', adminId: 'a' });
|
||||||
|
|
||||||
expect(prisma.adminSession.create).not.toHaveBeenCalled();
|
expect(prisma.adminSession.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -110,10 +110,10 @@ describe('AdminSessionService', () => {
|
|||||||
const { service, prisma } = build({ iamEnabled: true });
|
const { service, prisma } = build({ iamEnabled: true });
|
||||||
process.env.JWT_EXPIRES_IN = '12h';
|
process.env.JWT_EXPIRES_IN = '12h';
|
||||||
|
|
||||||
await service.record({ jti: 'jti-1', adminUserId: 'admin-1', iamSessionId: 'sid-1', iamSub: 'sub-1' });
|
await service.record({ jti: 'jti-1', adminId: 'admin-1', iamSessionId: 'sid-1', iamSub: 'sub-1' });
|
||||||
|
|
||||||
const data = prisma.adminSession.create.mock.calls[0][0].data;
|
const data = prisma.adminSession.create.mock.calls[0][0].data;
|
||||||
expect(data).toMatchObject({ jti: 'jti-1', adminUserId: 'admin-1', iamSessionId: 'sid-1' });
|
expect(data).toMatchObject({ jti: 'jti-1', adminId: 'admin-1', iamSessionId: 'sid-1' });
|
||||||
expect(data.expiresAt.getTime()).toBeGreaterThan(Date.now() + 11 * 3600 * 1000);
|
expect(data.expiresAt.getTime()).toBeGreaterThan(Date.now() + 11 * 3600 * 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ describe('AdminSessionService', () => {
|
|||||||
prisma.adminSession.create.mockRejectedValue(new Error('unique violation'));
|
prisma.adminSession.create.mockRejectedValue(new Error('unique violation'));
|
||||||
|
|
||||||
// Worst case is a session that cannot be revoked early — not a failed login
|
// Worst case is a session that cannot be revoked early — not a failed login
|
||||||
await expect(service.record({ jti: 'jti-1', adminUserId: 'a' })).resolves.toBeUndefined();
|
await expect(service.record({ jti: 'jti-1', adminId: 'a' })).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('revokes by the identity service session id', async () => {
|
it('revokes by the identity service session id', async () => {
|
||||||
@@ -150,7 +150,7 @@ describe('AdminSessionService', () => {
|
|||||||
|
|
||||||
await service.revokeAllForAdmin('admin-1', 'account_disabled');
|
await service.revokeAllForAdmin('admin-1', 'account_disabled');
|
||||||
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
|
expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({
|
||||||
where: { adminUserId: 'admin-1', revokedAt: null },
|
where: { adminId: 'admin-1', revokedAt: null },
|
||||||
data: expect.objectContaining({ revokedReason: 'account_disabled' }),
|
data: expect.objectContaining({ revokedReason: 'account_disabled' }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class AdminSessionService {
|
|||||||
*/
|
*/
|
||||||
async record(input: {
|
async record(input: {
|
||||||
jti: string;
|
jti: string;
|
||||||
adminUserId: string;
|
adminId: string;
|
||||||
iamSessionId?: string | null;
|
iamSessionId?: string | null;
|
||||||
iamSub?: string | null;
|
iamSub?: string | null;
|
||||||
ipAddress?: string | null;
|
ipAddress?: string | null;
|
||||||
@@ -60,7 +60,7 @@ export class AdminSessionService {
|
|||||||
await this.prisma.adminSession.create({
|
await this.prisma.adminSession.create({
|
||||||
data: {
|
data: {
|
||||||
jti: input.jti,
|
jti: input.jti,
|
||||||
adminUserId: input.adminUserId,
|
adminId: input.adminId,
|
||||||
iamSessionId: input.iamSessionId ?? null,
|
iamSessionId: input.iamSessionId ?? null,
|
||||||
iamSub: input.iamSub ?? null,
|
iamSub: input.iamSub ?? null,
|
||||||
ipAddress: input.ipAddress ?? null,
|
ipAddress: input.ipAddress ?? null,
|
||||||
@@ -103,12 +103,12 @@ export class AdminSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Revoke every session of a local admin (account disabled, password changed). */
|
/** Revoke every session of a local admin (account disabled, password changed). */
|
||||||
async revokeAllForAdmin(adminUserId: string, reason = 'revoked'): Promise<number> {
|
async revokeAllForAdmin(adminId: string, reason = 'revoked'): Promise<number> {
|
||||||
return this.revokeWhere({ adminUserId }, reason);
|
return this.revokeWhere({ adminId }, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async revokeWhere(
|
private async revokeWhere(
|
||||||
where: { iamSessionId?: string; iamSub?: string; adminUserId?: string },
|
where: { iamSessionId?: string; iamSub?: string; adminId?: string },
|
||||||
reason: string,
|
reason: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
if (!this.enabled) return 0;
|
if (!this.enabled) return 0;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { join } from 'path';
|
|||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { logDataDiagnostics } from './diagnostics';
|
||||||
import { runMigrations, runOnce } from './migrate';
|
import { runMigrations, runOnce } from './migrate';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -40,6 +41,9 @@ export async function prepareDatabase(): Promise<void> {
|
|||||||
logger.log('No pending migrations');
|
logger.log('No pending migrations');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TEMPORARY — see diagnostics.ts. Never throws.
|
||||||
|
await logDataDiagnostics(prisma);
|
||||||
|
|
||||||
if (process.env.RUN_SEED === 'false') return;
|
if (process.env.RUN_SEED === 'false') return;
|
||||||
|
|
||||||
const seedScript = resolveSeedScript();
|
const seedScript = resolveSeedScript();
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TEMPORARY boot-time data diagnostics — prints aggregate COUNTS only to the
|
||||||
|
* container log, prefixed "[DIAG]", so the production state behind two open
|
||||||
|
* issues can be read from Dokploy's log without database access:
|
||||||
|
*
|
||||||
|
* 1. «لیست مشتریان عمده» is empty after the last deploy;
|
||||||
|
* 2. a sales expert's app data vs the panel.
|
||||||
|
*
|
||||||
|
* Read-only (SELECT only). No mobile, name or id is printed. Every query is
|
||||||
|
* isolated, so one failing query (e.g. a missing table) is reported and the
|
||||||
|
* rest still run; nothing here can stop the API from booting.
|
||||||
|
*
|
||||||
|
* Disable with DIAG_LOG=false. Remove this file and its call in bootstrap.ts
|
||||||
|
* once the issues are closed.
|
||||||
|
*/
|
||||||
|
const QUERIES: { label: string; sql: string }[] = [
|
||||||
|
{
|
||||||
|
label: 'migrations: last 5 applied',
|
||||||
|
sql: `SELECT name, applied_at FROM "_goldbank_migrations" ORDER BY applied_at DESC, name DESC LIMIT 5`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'customers by kind / active / archived / step',
|
||||||
|
sql: `SELECT kind, "isActive" AS active, "isArchived" AS archived, "verificationStep" AS step, count(*)::int AS n
|
||||||
|
FROM "Customer" GROUP BY 1,2,3,4 ORDER BY n DESC`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'omde list ("all" scope) — what the panel can show',
|
||||||
|
sql: `SELECT
|
||||||
|
count(*)::int AS app_shopkeeper_not_archived,
|
||||||
|
count(*) FILTER (WHERE p.id IS NOT NULL)::int AS of_which_expert_trading_accounts,
|
||||||
|
count(*) FILTER (WHERE p.id IS NULL)::int AS listed_in_omde
|
||||||
|
FROM "Customer" c
|
||||||
|
LEFT JOIN "SalesExpertProfile" p ON p."tradingCustomerId" = c.id
|
||||||
|
WHERE c.kind = 'APP_SHOPKEEPER' AND c."isArchived" = false`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'expert trading accounts: created for the expert vs adopted existing customer',
|
||||||
|
sql: `SELECT
|
||||||
|
count(*)::int AS trading_accounts,
|
||||||
|
count(*) FILTER (WHERE c."createdAt" < a."createdAt")::int AS customer_older_than_expert,
|
||||||
|
count(*) FILTER (WHERE c."createdAt" >= a."createdAt")::int AS customer_newer_than_expert,
|
||||||
|
count(*) FILTER (WHERE c.mobile = a.mobile)::int AS same_mobile,
|
||||||
|
count(*) FILTER (WHERE c."referredByAgentId" IS NOT NULL)::int AS has_referrer,
|
||||||
|
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM "Order" o WHERE o."customerId" = c.id))::int AS has_orders,
|
||||||
|
count(*) FILTER (WHERE c."firstName" IS DISTINCT FROM a."firstName"
|
||||||
|
OR c."lastName" IS DISTINCT FROM a."lastName")::int AS name_differs_from_expert
|
||||||
|
FROM "SalesExpertProfile" p
|
||||||
|
JOIN "Customer" c ON c.id = p."tradingCustomerId"
|
||||||
|
JOIN "AdminUser" a ON a.id = p."adminUserId"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'AdminUser by role / active (experts + pre-split admin copies)',
|
||||||
|
sql: `SELECT role, "isActive" AS active, count(*)::int AS n FROM "AdminUser" GROUP BY 1,2 ORDER BY n DESC`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'experts: profile / trading account',
|
||||||
|
sql: `SELECT
|
||||||
|
count(*)::int AS experts,
|
||||||
|
count(*) FILTER (WHERE p.id IS NULL)::int AS no_profile,
|
||||||
|
count(*) FILTER (WHERE p."tradingCustomerId" IS NOT NULL)::int AS with_trading_account,
|
||||||
|
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM "Customer" c WHERE c.mobile = a.mobile))::int AS mobile_also_a_customer
|
||||||
|
FROM "AdminUser" a
|
||||||
|
LEFT JOIN "SalesExpertProfile" p ON p."adminUserId" = a.id
|
||||||
|
WHERE a.role = 'SALES_EXPERT'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'experts: when created (by day, last 10 days with rows)',
|
||||||
|
sql: `SELECT date_trunc('day', "createdAt")::date AS day, count(*)::int AS n
|
||||||
|
FROM "AdminUser" WHERE role = 'SALES_EXPERT' GROUP BY 1 ORDER BY 1 DESC LIMIT 10`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'customers: when created (by day, last 10 days with rows)',
|
||||||
|
sql: `SELECT date_trunc('day', "createdAt")::date AS day, count(*)::int AS n
|
||||||
|
FROM "Customer" GROUP BY 1 ORDER BY 1 DESC LIMIT 10`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'customers with a referring expert',
|
||||||
|
sql: `SELECT count(*) FILTER (WHERE "referredByAgentId" IS NOT NULL)::int AS referred,
|
||||||
|
count(*)::int AS all_customers
|
||||||
|
FROM "Customer"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Admin table',
|
||||||
|
sql: `SELECT role, "isActive" AS active, count(*)::int AS n FROM "Admin" GROUP BY 1,2 ORDER BY n DESC`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function logDataDiagnostics(prisma: PrismaClient): Promise<void> {
|
||||||
|
if (process.env.DIAG_LOG === 'false') return;
|
||||||
|
const logger = new Logger('DIAG');
|
||||||
|
|
||||||
|
logger.log('[DIAG] ===== data diagnostics start (counts only, read-only) =====');
|
||||||
|
for (const q of QUERIES) {
|
||||||
|
try {
|
||||||
|
const rows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(q.sql);
|
||||||
|
logger.log(`[DIAG] ${q.label}: ${JSON.stringify(rows, (_k, v) => (typeof v === 'bigint' ? Number(v) : v))}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[DIAG] ${q.label}: FAILED — ${(error as Error).message.split('\n').pop()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.log('[DIAG] ===== data diagnostics end =====');
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readdirSync } from 'fs';
|
import { readdirSync, readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { sortMigrations } from './migrate';
|
import { sortMigrations, splitSqlStatements } from './migrate';
|
||||||
|
|
||||||
describe('sortMigrations', () => {
|
describe('sortMigrations', () => {
|
||||||
it('puts the baseline before 0002 so an empty database can boot', () => {
|
it('puts the baseline before 0002 so an empty database can boot', () => {
|
||||||
@@ -26,3 +26,25 @@ describe('sortMigrations', () => {
|
|||||||
expect(sortMigrations(entries)[0].name).toBe('20260910113720_first');
|
expect(sortMigrations(entries)[0].name).toBe('20260910113720_first');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('split_admin_table migration', () => {
|
||||||
|
const sql = readFileSync(
|
||||||
|
join(__dirname, '..', '..', 'prisma', 'migrations', '20261026120000_split_admin_table', 'migration.sql'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const statements = splitSqlStatements(sql);
|
||||||
|
|
||||||
|
it('splits into whole statements the runtime runner can execute one by one', () => {
|
||||||
|
// The validation loop is one DO $$ … $$ block; it must not be cut at its inner ';'.
|
||||||
|
const doBlocks = statements.filter((s) => s.startsWith('DO $$'));
|
||||||
|
expect(doBlocks.some((s) => s.includes('VALIDATE CONSTRAINT') && s.trimEnd().endsWith('END $$'))).toBe(true);
|
||||||
|
expect(statements.every((s) => s.length > 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never writes to the sales-expert table (AdminUser) — it only reads from it', () => {
|
||||||
|
const writes = statements.filter((s) =>
|
||||||
|
/^(UPDATE|DELETE\s+FROM|INSERT\s+INTO|TRUNCATE|ALTER\s+TABLE)\s+(ONLY\s+)?"?AdminUser"?\b/i.test(s),
|
||||||
|
);
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { uniqueViolationMessage } from './prisma-exception.filter';
|
||||||
|
|
||||||
|
describe('uniqueViolationMessage', () => {
|
||||||
|
it('names a duplicate mobile in Persian, whether Prisma sends columns or an index name', () => {
|
||||||
|
expect(uniqueViolationMessage(['mobile'])).toBe('این شماره موبایل قبلاً ثبت شده است');
|
||||||
|
expect(uniqueViolationMessage('Customer_mobile_key')).toBe('این شماره موبایل قبلاً ثبت شده است');
|
||||||
|
expect(uniqueViolationMessage('Admin_mobile_key')).toBe('این شماره موبایل قبلاً ثبت شده است');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names a duplicate national code', () => {
|
||||||
|
expect(uniqueViolationMessage(['nationalCode'])).toBe('این کد ملی قبلاً ثبت شده است');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never leaks the English Prisma wording, even for an unknown column or no target', () => {
|
||||||
|
for (const t of [['somethingElse'], undefined, null, []]) {
|
||||||
|
const msg = uniqueViolationMessage(t);
|
||||||
|
expect(msg).toBe('این مقدار قبلاً ثبت شده است و تکراری است');
|
||||||
|
expect(msg).not.toMatch(/[A-Za-z]/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,27 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nest
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persian, user-facing text for a unique-index violation. The panel shows
|
||||||
|
* `message` as-is, and the raw Prisma wording ("Unique constraint failed on:
|
||||||
|
* mobile") is meaningless to the operator.
|
||||||
|
*
|
||||||
|
* `target` is the list of columns on Postgres, or the index name when the
|
||||||
|
* driver only reports that — so both are matched by substring.
|
||||||
|
*/
|
||||||
|
export function uniqueViolationMessage(target: unknown): string {
|
||||||
|
const fields = (Array.isArray(target) ? target : [target])
|
||||||
|
.map((t) => String(t ?? ''))
|
||||||
|
.join(',')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
if (fields.includes('mobile')) return 'این شماره موبایل قبلاً ثبت شده است';
|
||||||
|
if (fields.includes('nationalcode')) return 'این کد ملی قبلاً ثبت شده است';
|
||||||
|
if (fields.includes('membercode')) return 'این کد عضویت قبلاً ثبت شده است';
|
||||||
|
if (fields.includes('ordernumber')) return 'این شماره سفارش قبلاً ثبت شده است';
|
||||||
|
return 'این مقدار قبلاً ثبت شده است و تکراری است';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Without this, an unmigrated table (P2021), a bad foreign key (P2003), etc.
|
* Without this, an unmigrated table (P2021), a bad foreign key (P2003), etc.
|
||||||
* all surfaced as an opaque "Internal Server Error" with no indication of
|
* all surfaced as an opaque "Internal Server Error" with no indication of
|
||||||
@@ -44,7 +65,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
|||||||
return response.status(HttpStatus.CONFLICT).json({
|
return response.status(HttpStatus.CONFLICT).json({
|
||||||
statusCode: HttpStatus.CONFLICT,
|
statusCode: HttpStatus.CONFLICT,
|
||||||
error: 'Conflict',
|
error: 'Conflict',
|
||||||
message: `Unique constraint failed on: ${(exception.meta?.target as string[] | undefined)?.join(', ')}`,
|
message: uniqueViolationMessage(exception.meta?.target),
|
||||||
});
|
});
|
||||||
case 'P2003': // foreign key constraint violation
|
case 'P2003': // foreign key constraint violation
|
||||||
return response.status(HttpStatus.BAD_REQUEST).json({
|
return response.status(HttpStatus.BAD_REQUEST).json({
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# جدا شدن جدول ادمینها از کارشناسان فروش
|
||||||
|
|
||||||
|
## چه چیزی عوض شد
|
||||||
|
|
||||||
|
| | قبل | بعد |
|
||||||
|
|---|---|---|
|
||||||
|
| پرسنل پنل (مدیر ارشد، تأییدکننده، سرپرست کارگاه، حسابدار) | جدول `AdminUser` | جدول جدید **`Admin`** |
|
||||||
|
| کارشناس فروش | جدول `AdminUser` | جدول `AdminUser` (**بدون هیچ تغییری**) |
|
||||||
|
| یکتایی شماره موبایل | یک شماره = فقط یک نقش | یکتا **در هر جدول جدا**؛ یک شماره میتواند هم ادمین باشد هم کارشناس |
|
||||||
|
|
||||||
|
ادمین فقط برای پنل است. کارشناس فقط برای اپها. توکن اپ کارشناس هیچوقت به پنل دسترسی نمیدهد
|
||||||
|
(`JwtStrategy` فقط در `Admin` میگردد).
|
||||||
|
|
||||||
|
## مایگریشن — `20261026120000_split_admin_table`
|
||||||
|
|
||||||
|
فقط **افزودنی** است. به `AdminUser` (کارشناسها) چیزی نمینویسد، حذف نمیکند و قفل نمیکند.
|
||||||
|
|
||||||
|
1. جدول `Admin` را میسازد.
|
||||||
|
2. ردیفهای غیرکارشناس `AdminUser` را با **همان `id`** در `Admin` کپی میکند
|
||||||
|
(توکنهای صادرشده و همهٔ ستونهای «چه کسی انجام داد» معتبر میمانند).
|
||||||
|
3. کلید خارجیهای «ثبتکننده» را از `AdminUser` به `Admin` منتقل میکند. **نام ستونها و دیتا عوض نمیشود**،
|
||||||
|
فقط مقصد constraint:
|
||||||
|
`LedgerEntry.postedByAdminUserId`، `OrderStatusEvent` و `PreOrderStatusEvent.actorAdminUserId`،
|
||||||
|
`BranchAllocation.allocatorAdminUserId`، `SmsMessage` و `Notification.sentByAdminUserId`،
|
||||||
|
`UploadedFile.uploadedByAdminUserId`، `Workshop.supervisorId`، `AdminSession.adminUserId`.
|
||||||
|
4. بهصورت `NOT VALID` اضافه و بعد validate میشوند؛ اگر ردیف قدیمیای به id غیرادمین اشاره کند، deploy شکست
|
||||||
|
نمیخورد و فقط یک `NOTICE` چاپ میشود.
|
||||||
|
|
||||||
|
بدون تغییر ماندند (چون دربارهٔ کارشناساند): `Order.salesAgentId`، `Customer.referredByAgentId`،
|
||||||
|
`SalesExpertProfile.adminUserId`.
|
||||||
|
|
||||||
|
قابل اجرای مجدد است (idempotent).
|
||||||
|
|
||||||
|
### آزمایششده روی
|
||||||
|
|
||||||
|
کپی دیتابیس با ۱۲۰۰۰ کارشناس: چکسام (md5) تمام ردیفهای `SALES_EXPERT` قبل و بعد از مایگریشن **یکسان** بود.
|
||||||
|
زنجیرهٔ کامل مایگریشنها روی دیتابیس خالی اجرا شد و `prisma migrate diff` هیچ اختلافی بین `schema.prisma`
|
||||||
|
و دیتابیس نشان نداد.
|
||||||
|
|
||||||
|
## قبل از دیپلوی روی سرور اصلی
|
||||||
|
|
||||||
|
1. از دیتابیس **بکاپ** بگیرید.
|
||||||
|
2. (اختیاری، فقطخواندنی) ببینید ردیفی هست که به id غیرادمین اشاره کند؛ برای هر جدول:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- نمونه برای دفتر حساب؛ برای بقیهٔ جدولها/ستونها همین الگو
|
||||||
|
SELECT count(*) FROM "LedgerEntry" l
|
||||||
|
WHERE l."postedByAdminUserId" IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM "Admin" a WHERE a."id" = l."postedByAdminUserId");
|
||||||
|
```
|
||||||
|
|
||||||
|
قبل از اجرای مایگریشن `Admin` هنوز وجود ندارد؛ این کوئری را بعد از دیپلوی بزنید. جواب `0` یعنی همه چیز سالم است.
|
||||||
|
3. ابتدا مایگریشن (با بوت شدن API جدید خودکار اجرا میشود)، سپس همان کد.
|
||||||
|
چون additive است، کد قدیمی در فاصله هم کار میکند.
|
||||||
|
|
||||||
|
## یک محدودیت تا «فاز ۲»
|
||||||
|
|
||||||
|
ردیفهای قدیمی ادمینها در `AdminUser` **باقی میمانند** (غیرفعال، بدون استفاده در کد). شمارهٔ آنها هنوز در
|
||||||
|
`AdminUser` قفل است، پس برای **ادمینهای موجود** ساختن کارشناس با همان شماره خطای «قبلاً ثبت شده» میدهد.
|
||||||
|
(ادمینهای تازهساخته و شمارههای جدید مشکلی ندارند.)
|
||||||
|
|
||||||
|
برای آزاد کردن آن شمارهها، بعد از اطمینان از درستی کار:
|
||||||
|
`apps/api/prisma/manual/20261026_remove_dormant_admin_rows.sql`
|
||||||
|
— دستی است، عمداً داخل `migrations/` نیست، فقط ردیفهای **غیرکارشناس** را که در `Admin` کپی دارند حذف میکند،
|
||||||
|
و اگر تعداد کارشناسها یک عدد هم تغییر کند خودش rollback میکند.
|
||||||
|
|
||||||
|
## برگشت (Rollback)
|
||||||
|
|
||||||
|
کد قبلی را دوباره deploy کنید. چیزی حذف نشده، پس کد قدیمی همهٔ ادمینهای پیشین را در `AdminUser` پیدا میکند.
|
||||||
|
ادمینهایی که **بعد** از جدا شدن ساخته شدهاند فقط در `Admin` هستند و باید دوباره ساخته شوند.
|
||||||
|
|
||||||
|
## تغییر رفتار پنل
|
||||||
|
|
||||||
|
- جدول «کاربران» فقط ادمینها را نشان میدهد؛ کارشناسها از بخش کارشناسان.
|
||||||
|
- نقش «کارشناس فروش» از لیست نقشهای فرم ادمین حذف شد؛ فرم کارشناس همان `POST admin/users` را با
|
||||||
|
`role: SALES_EXPERT` میزند و در `AdminUser` ساخته میشود.
|
||||||
|
- نقش یک ادمین به کارشناس (و برعکس) قابل تغییر نیست، چون دو جدول جدا هستند.
|
||||||
|
- کارشناس دیگر نمیتواند با رمز خودش وارد پنل شود (قبلاً چون در همان جدول بود، میتوانست).
|
||||||