diff --git a/apps/api/prisma/manual/20261026_remove_dormant_admin_rows.sql b/apps/api/prisma/manual/20261026_remove_dormant_admin_rows.sql new file mode 100644 index 0000000..a62d0a2 --- /dev/null +++ b/apps/api/prisma/manual/20261026_remove_dormant_admin_rows.sql @@ -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; diff --git a/apps/api/prisma/manual/diagnose_expert_login.sql b/apps/api/prisma/manual/diagnose_expert_login.sql new file mode 100644 index 0000000..5d8a395 --- /dev/null +++ b/apps/api/prisma/manual/diagnose_expert_login.sql @@ -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'; diff --git a/apps/api/prisma/migrations/20261026120000_split_admin_table/migration.sql b/apps/api/prisma/migrations/20261026120000_split_admin_table/migration.sql new file mode 100644 index 0000000..88a0a6c --- /dev/null +++ b/apps/api/prisma/migrations/20261026120000_split_admin_table/migration.sql @@ -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 $$; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index cb5f813..402d122 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -140,6 +140,61 @@ model City { @@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 { id String @id @default(cuid()) @@ -188,23 +243,10 @@ model AdminUser { referredCustomers Customer[] @relation("ReferralAgent") /// Orders credited to this sales expert — see Order.salesAgentId. creditedOrders Order[] @relation("OrderSalesAgent") - supervisedWorkshops Workshop[] @relation("WorkshopSupervisor") - branchAllocations BranchAllocation[] @relation("AllocationAuthor") - postedLedgerEntries LedgerEntry[] - orderStatusEvents OrderStatusEvent[] - preOrderStatusEvents PreOrderStatusEvent[] - sentSmsMessages SmsMessage[] @relation("SmsAuthor") - sentNotifications Notification[] @relation("NotificationAuthor") - uploadedFiles UploadedFile[] @relation("FileUploader") /// Only ever set for role = SALES_EXPERT (D17). 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([userTypeId]) @@index([avatarFileId]) @@ -274,8 +316,8 @@ model AdminSession { /// The token's `jti` claim. Unique: one row per issued token. jti String @unique - adminUserId String - adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade) + adminId String @map("adminUserId") + admin Admin @relation(fields: [adminId], references: [id], onDelete: Cascade) /// 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 — @@ -294,7 +336,7 @@ model AdminSession { revokedAt DateTime? revokedReason String? - @@index([adminUserId]) + @@index([adminId], map: "AdminSession_adminUserId_idx") @@index([iamSessionId]) @@index([iamSub]) @@index([expiresAt]) @@ -571,6 +613,7 @@ model Branch { cityId String? city City? @relation(fields: [cityId], references: [id]) adminUsers AdminUser[] + admins Admin[] orders Order[] allocations BranchAllocation[] } @@ -583,7 +626,7 @@ model Workshop { phone String? /// The supervisor created alongside the workshop by the legacy Add form. supervisorId String? - supervisor AdminUser? @relation("WorkshopSupervisor", fields: [supervisorId], references: [id]) + supervisor Admin? @relation("WorkshopSupervisor", fields: [supervisorId], references: [id]) isActive Boolean @default(true) preOrders PreOrder[] } @@ -832,8 +875,8 @@ model OrderStatusEvent { fromStatus OrderStatus? toStatus OrderStatus note String? - actorAdminUserId String? - actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id]) + actorAdminId String? @map("actorAdminUserId") + actorAdmin Admin? @relation(fields: [actorAdminId], references: [id]) createdAt DateTime @default(now()) @@index([orderId]) @@ -880,8 +923,8 @@ model PreOrderStatusEvent { preOrder PreOrder @relation(fields: [preOrderId], references: [id]) /// Field-level diff, e.g. {"status": {"old": "PLACED", "new": "ADMIN_APPROVED"}} changes Json - actorAdminUserId String? - actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id]) + actorAdminId String? @map("actorAdminUserId") + actorAdmin Admin? @relation(fields: [actorAdminId], references: [id]) ipAddress String? createdAt DateTime @default(now()) @@ -914,8 +957,8 @@ model LedgerEntry { /// Pending entries don't count toward the cached customer balance until confirmed. confirmed Boolean @default(false) confirmedAt DateTime? - postedByAdminUserId String? - postedByAdminUser AdminUser? @relation(fields: [postedByAdminUserId], references: [id]) + postedByAdminId String? @map("postedByAdminUserId") + postedByAdmin Admin? @relation(fields: [postedByAdminId], references: [id]) /// Instrument metadata from the legacy "cash" document form. Which fields are /// filled depends on sourceType: a melted-gold receipt carries lab/hallmark and @@ -997,6 +1040,7 @@ model UserType { permissions Permission[] adminUsers AdminUser[] + admins Admin[] @@map("user_type") } @@ -1251,8 +1295,8 @@ model BranchAllocation { branch Branch @relation(fields: [branchId], references: [id]) /// Legacy `allocator_user` — the admin who filed the allocation. - allocatorAdminUserId String? - allocatorAdminUser AdminUser? @relation("AllocationAuthor", fields: [allocatorAdminUserId], references: [id]) + allocatorAdminId String? @map("allocatorAdminUserId") + allocatorAdmin Admin? @relation("AllocationAuthor", fields: [allocatorAdminId], references: [id]) note String? createdAt DateTime @default(now()) @@ -1299,8 +1343,8 @@ model SmsMessage { /// How many numbers the send actually resolved to. sentCount Int @default(0) - sentByAdminUserId String? - sentByAdminUser AdminUser? @relation("SmsAuthor", fields: [sentByAdminUserId], references: [id]) + sentByAdminId String? @map("sentByAdminUserId") + sentByAdmin Admin? @relation("SmsAuthor", fields: [sentByAdminId], references: [id]) createdAt DateTime @default(now()) @@ -1313,8 +1357,8 @@ model Notification { body String audience MessageAudience @default(ALL_CUSTOMERS) - sentByAdminUserId String? - sentByAdminUser AdminUser? @relation("NotificationAuthor", fields: [sentByAdminUserId], references: [id]) + sentByAdminId String? @map("sentByAdminUserId") + sentByAdmin Admin? @relation("NotificationAuthor", fields: [sentByAdminId], references: [id]) createdAt DateTime @default(now()) @@ -1379,8 +1423,8 @@ model UploadedFile { /// 'customer.nationalId'. Kept loose so a new form needs no schema change. purpose String? - uploadedByAdminUserId String? - uploadedByAdminUser AdminUser? @relation("FileUploader", fields: [uploadedByAdminUserId], references: [id]) + uploadedByAdminId String? @map("uploadedByAdminUserId") + uploadedByAdmin Admin? @relation("FileUploader", fields: [uploadedByAdminId], references: [id]) createdAt DateTime @default(now()) customerAvatars Customer[] @relation("CustomerAvatar") diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 98afa09..b0bb138 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -148,13 +148,13 @@ async function main() { 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 // 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 // deployment's seed data. - await prisma.adminUser.upsert({ + await prisma.admin.upsert({ where: { mobile: DEV_TEST_PHONE }, update: { passwordHash: DEV_TEST_PHONE_PASSWORD_HASH, userTypeId: 85, branchId: null }, create: { @@ -167,7 +167,7 @@ async function main() { }, }); - await prisma.adminUser.upsert({ + await prisma.admin.upsert({ where: { mobile: '09120000001' }, update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 85, branchId: null }, create: { @@ -208,7 +208,7 @@ async function main() { wagePercent: 1.2, }, }); - await prisma.adminUser.upsert({ + await prisma.admin.upsert({ where: { mobile: '09123334444' }, update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 88 }, create: { @@ -219,7 +219,7 @@ async function main() { userTypeId: 88, }, }); - await prisma.adminUser.upsert({ + await prisma.admin.upsert({ where: { mobile: '09125556666' }, update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 87, branchId: null }, create: { diff --git a/apps/api/src/modules/accounting/ledger.service.ts b/apps/api/src/modules/accounting/ledger.service.ts index d9df811..d82cb2f 100644 --- a/apps/api/src/modules/accounting/ledger.service.ts +++ b/apps/api/src/modules/accounting/ledger.service.ts @@ -22,7 +22,7 @@ export interface PostEntryInput { note?: string; /** Order-delivery debt entries are self-confirming; repayments start pending. */ confirmed?: boolean; - postedByAdminUserId?: string; + postedByAdminId?: string; } /** @@ -56,7 +56,7 @@ export class LedgerService { note: input.note, confirmed, confirmedAt: confirmed ? new Date() : null, - postedByAdminUserId: input.postedByAdminUserId, + postedByAdminId: input.postedByAdminId, }, }); @@ -83,7 +83,7 @@ export class LedgerService { const updated = await tx.ledgerEntry.update({ 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); @@ -180,7 +180,7 @@ export class LedgerService { karatPerMille?: number; pricePerGramRial?: Prisma.Decimal.Value; note?: string; - postedByAdminUserId?: string; + postedByAdminId?: string; }, tx: Tx = this.prisma, ) { @@ -230,7 +230,7 @@ export class LedgerService { pricePerGramRialAtEntry: params.pricePerGramRial, note: params.note, confirmed: false, // pending until an admin confirms — intentional deviation from legacy, which auto-confirms on creation - postedByAdminUserId: params.postedByAdminUserId, + postedByAdminId: params.postedByAdminId, }, tx, ); @@ -242,7 +242,7 @@ export class LedgerService { * migration analysis) but supported here since the new ledger is symmetric. */ 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, ) { return this.postEntry( @@ -254,7 +254,7 @@ export class LedgerService { amount: params.amount, note: params.note, confirmed: false, - postedByAdminUserId: params.postedByAdminUserId, + postedByAdminId: params.postedByAdminId, }, tx, ); @@ -267,7 +267,7 @@ export class LedgerService { direction: LedgerDirection; amount: Prisma.Decimal.Value; note: string; - postedByAdminUserId: string; + postedByAdminId: string; }, tx: Tx = this.prisma, ) { @@ -280,7 +280,7 @@ export class LedgerService { amount: params.amount, note: params.note, confirmed: true, - postedByAdminUserId: params.postedByAdminUserId, + postedByAdminId: params.postedByAdminId, }, tx, ); diff --git a/apps/api/src/modules/admin-branches/admin-branches.service.ts b/apps/api/src/modules/admin-branches/admin-branches.service.ts index 82cd20c..410b1f9 100644 --- a/apps/api/src/modules/admin-branches/admin-branches.service.ts +++ b/apps/api/src/modules/admin-branches/admin-branches.service.ts @@ -32,7 +32,7 @@ export class AdminBranchesService { async listBranches(query: TableQueryDto) { this.table.assertNotExport(query); - const aliases: Record = { 'user.name': 'adminUsers.some.fullName' }; + const aliases: Record = { 'user.name': 'admins.some.fullName' }; const { where, orderBy, skip, take, page, limit } = this.table.build(query, { searchable: ['name', 'address', 'phone'], allowed: ['id', 'name', 'address', 'phone', 'isActive', 'cityId', 'city.name'], @@ -48,8 +48,8 @@ export class AdminBranchesService { take, include: { city: true, - adminUsers: { where: { isActive: true }, take: 1 }, - _count: { select: { adminUsers: true, orders: true } }, + admins: { where: { isActive: true }, take: 1 }, + _count: { select: { admins: true, orders: true } }, }, }), this.prisma.branch.count({ where: where as Prisma.BranchWhereInput }), @@ -66,10 +66,10 @@ export class AdminBranchesService { city_id: b.cityId, city: b.city ? { id: b.city.id, title: b.city.name } : null, // legacy column key is `user.name` — the branch manager - user: b.adminUsers[0] - ? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName } + 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, })), total, @@ -82,7 +82,7 @@ export class AdminBranchesService { async findBranch(id: string) { const b = await this.prisma.branch.findUnique({ 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('شعبه یافت نشد'); return { @@ -92,8 +92,8 @@ export class AdminBranchesService { phone: b.phone, is_active: b.isActive, city_id: b.cityId, - user_id: b.adminUsers[0]?.id ?? null, - user: b.adminUsers[0] ? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName } : null, + user_id: b.admins[0]?.id ?? 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) { 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) { - const user = await tx.adminUser.findUnique({ where: { id: userId } }); + const user = await tx.admin.findUnique({ where: { id: userId } }); 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(); 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 (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) { throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است'); } } - await this.prisma.adminUser.update({ + await this.prisma.admin.update({ where: { id: currentId }, data: { fullName, mobile: phone }, }); @@ -320,10 +320,10 @@ export class AdminBranchesService { // Reuse an existing admin with that mobile rather than colliding on the // 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; - const created = await this.prisma.adminUser.create({ + const created = await this.prisma.admin.create({ data: { fullName, mobile: phone, @@ -347,7 +347,7 @@ export class AdminBranchesService { /** The admin picker the branch form uses — legacy `GET user/list`. */ async userList() { - const rows = await this.prisma.adminUser.findMany({ + const rows = await this.prisma.admin.findMany({ where: { isActive: true }, orderBy: { fullName: 'asc' }, select: { id: true, fullName: true, mobile: true, branchId: true }, diff --git a/apps/api/src/modules/admin-documents/admin-documents.service.ts b/apps/api/src/modules/admin-documents/admin-documents.service.ts index 35bcd93..7ed089f 100644 --- a/apps/api/src/modules/admin-documents/admin-documents.service.ts +++ b/apps/api/src/modules/admin-documents/admin-documents.service.ts @@ -147,7 +147,7 @@ export class AdminDocumentsService { orderBy: orderBy as Prisma.LedgerEntryOrderByWithRelationInput, skip, take, - include: { customer: true, postedByAdminUser: true }, + include: { customer: true, postedByAdmin: true }, }), this.prisma.ledgerEntry.count({ where: scoped }), ]); @@ -157,7 +157,7 @@ export class AdminDocumentsService { /** Legacy field names preserved so the ported table model binds unchanged. */ private toRow( - e: Prisma.LedgerEntryGetPayload<{ include: { customer: true; postedByAdminUser: true } }>, + e: Prisma.LedgerEntryGetPayload<{ include: { customer: true; postedByAdmin: true } }>, ) { return { id: e.id, @@ -190,7 +190,7 @@ export class AdminDocumentsService { lname: e.customer.lastName ?? '', mobile: e.customer.mobile, }, - posted_by: e.postedByAdminUser ? e.postedByAdminUser.fullName : null, + posted_by: e.postedByAdmin ? e.postedByAdmin.fullName : null, caj: e.createdAt, uaj: e.createdAt, }; @@ -200,7 +200,7 @@ export class AdminDocumentsService { async findOne(id: string) { const e = await this.prisma.ledgerEntry.findUnique({ where: { id }, - include: { customer: true, postedByAdminUser: true }, + include: { customer: true, postedByAdmin: true }, }); if (!e) throw new NotFoundException('سند یافت نشد'); return this.toRow(e); @@ -241,7 +241,7 @@ export class AdminDocumentsService { karatPerMille: data.ayar ? Number(data.ayar) : undefined, pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : undefined, note: (data.note as string) ?? undefined, - postedByAdminUserId: actorAdminUserId, + postedByAdminId: actorAdminUserId, }); // Attach the instrument metadata the ledger service does not model. diff --git a/apps/api/src/modules/admin-invoices/admin-invoices.service.ts b/apps/api/src/modules/admin-invoices/admin-invoices.service.ts index 234b231..41c709d 100644 --- a/apps/api/src/modules/admin-invoices/admin-invoices.service.ts +++ b/apps/api/src/modules/admin-invoices/admin-invoices.service.ts @@ -28,7 +28,7 @@ export class AdminInvoicesService { 'createdAt', 'branchId', 'branch.name', - 'allocatorAdminUser.fullName', + 'allocatorAdmin.fullName', // legacy filter keys 'name', 'caj', @@ -37,7 +37,7 @@ export class AdminInvoicesService { private static readonly FIELD_ALIASES: Record = { name: 'branch.name', caj: 'createdAt', - 'allocator_user.name': 'allocatorAdminUser.fullName', + 'allocator_user.name': 'allocatorAdmin.fullName', }; /** Legacy `POST branch/product/table`. */ @@ -67,7 +67,7 @@ export class AdminInvoicesService { take, include: { branch: true, - allocatorAdminUser: true, + allocatorAdmin: true, items: { include: { product: true } }, }, }), @@ -81,7 +81,7 @@ export class AdminInvoicesService { a: Prisma.BranchAllocationGetPayload<{ include: { branch: true; - allocatorAdminUser: true; + allocatorAdmin: true; items: { include: { product: true } }; }; }>, @@ -100,8 +100,8 @@ export class AdminInvoicesService { sum_weight: sumWeight, count_item: a.items.length, note: a.note, - allocator_user: a.allocatorAdminUser - ? { id: a.allocatorAdminUser.id, name: a.allocatorAdminUser.fullName } + allocator_user: a.allocatorAdmin + ? { id: a.allocatorAdmin.id, name: a.allocatorAdmin.fullName } : null, caj: a.createdAt, products: a.items.map((it) => ({ @@ -120,7 +120,7 @@ export class AdminInvoicesService { where: { id }, include: { branch: true, - allocatorAdminUser: true, + allocatorAdmin: true, items: { include: { product: true } }, }, }); @@ -155,7 +155,7 @@ export class AdminInvoicesService { const allocation = await this.prisma.branchAllocation.create({ data: { branchId, - allocatorAdminUserId: actorId, + allocatorAdminId: actorId, note: data.note ?? null, items: { create: products.map((p) => ({ diff --git a/apps/api/src/modules/admin-misc/admin-misc.service.ts b/apps/api/src/modules/admin-misc/admin-misc.service.ts index d2de577..83fe6ec 100644 --- a/apps/api/src/modules/admin-misc/admin-misc.service.ts +++ b/apps/api/src/modules/admin-misc/admin-misc.service.ts @@ -224,7 +224,7 @@ export class AdminMiscService { orderBy: orderBy as Prisma.SmsMessageOrderByWithRelationInput, skip, take, - include: { sentByAdminUser: true }, + include: { sentByAdmin: true }, }), this.prisma.smsMessage.count({ where: where as Prisma.SmsMessageWhereInput }), ]); @@ -236,7 +236,7 @@ export class AdminMiscService { text: s.body, mobile: s.recipients, sent_count: s.sentCount, - sender: s.sentByAdminUser?.fullName ?? null, + sender: s.sentByAdmin?.fullName ?? null, caj: s.createdAt, })), total, @@ -279,7 +279,7 @@ export class AdminMiscService { recipients: recipients.join(','), body, sentCount: recipients.length, - sentByAdminUserId: actorId, + sentByAdminId: actorId, }, }); } @@ -382,7 +382,7 @@ export class AdminMiscService { title, body, audience, - sentByAdminUserId: actorId, + sentByAdminId: actorId, recipients: { create: customers.map((c) => ({ customerId: c.id })) }, }, include: { _count: { select: { recipients: true } } }, @@ -409,7 +409,7 @@ export class AdminMiscService { title, body, audience: MessageAudience.SINGLE_NUMBER, - sentByAdminUserId: actorId, + sentByAdminId: actorId, recipients: { create: [{ customerId }] }, }, }); @@ -420,7 +420,7 @@ export class AdminMiscService { const n = await this.prisma.notification.findUnique({ where: { id }, include: { - sentByAdminUser: true, + sentByAdmin: true, recipients: { include: { customer: true } }, }, }); @@ -432,7 +432,7 @@ export class AdminMiscService { body: n.body, text: n.body, audience: n.audience, - sender: n.sentByAdminUser?.fullName ?? null, + sender: n.sentByAdmin?.fullName ?? null, caj: n.createdAt, recipients: n.recipients.map((r) => ({ id: r.id, @@ -462,7 +462,7 @@ export class AdminMiscService { skip, take, include: { - sentByAdminUser: true, + sentByAdmin: true, _count: { select: { recipients: true } }, }, }), @@ -475,7 +475,7 @@ export class AdminMiscService { title: n.title, text: n.body, audience: n.audience, - sender: n.sentByAdminUser?.fullName ?? null, + sender: n.sentByAdmin?.fullName ?? null, total_count: n._count.recipients, caj: n.createdAt, })), diff --git a/apps/api/src/modules/admin-orders/admin-orders.service.ts b/apps/api/src/modules/admin-orders/admin-orders.service.ts index f42950a..b660d1d 100644 --- a/apps/api/src/modules/admin-orders/admin-orders.service.ts +++ b/apps/api/src/modules/admin-orders/admin-orders.service.ts @@ -243,7 +243,7 @@ export class AdminOrdersService { customer: true, branch: 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('سفارش یافت نشد'); diff --git a/apps/api/src/modules/admin-uploads/admin-uploads.service.ts b/apps/api/src/modules/admin-uploads/admin-uploads.service.ts index eadc9f3..358c723 100644 --- a/apps/api/src/modules/admin-uploads/admin-uploads.service.ts +++ b/apps/api/src/modules/admin-uploads/admin-uploads.service.ts @@ -63,7 +63,7 @@ export class AdminUploadsService { mimeType: file.mimetype, sizeBytes: file.size, purpose: purpose ?? null, - uploadedByAdminUserId: actorAdminUserId, + uploadedByAdminId: actorAdminUserId, }, }); } diff --git a/apps/api/src/modules/admin-users/admin-users.service.ts b/apps/api/src/modules/admin-users/admin-users.service.ts index ce643f1..aaaf4db 100644 --- a/apps/api/src/modules/admin-users/admin-users.service.ts +++ b/apps/api/src/modules/admin-users/admin-users.service.ts @@ -21,7 +21,43 @@ const STORABLE_RIDS: ReadonlySet = 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: * POST/GET/PUT/DELETE users -> the admin user list and form * GET const/__list__ut -> the user-type ("سطح دسترسی") picker @@ -30,6 +66,13 @@ const STORABLE_RIDS: ReadonlySet = new Set( * POST roles/create -> grant rids * 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 * Phase 1 modelled, so this screen is what actually administers the ACL. */ @@ -65,7 +108,7 @@ export class AdminUsersService { active: 'isActive', }; - /** Legacy `GET users` — the admin user grid. */ + /** Legacy `GET users` — the admin user grid (panel staff only). */ async list(query: TableQueryDto) { 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. - const visible: Prisma.AdminUserWhereInput = { - AND: [where as Prisma.AdminUserWhereInput, { mobile: { not: SUPER_ADMIN_MOBILE } }], + const visible: Prisma.AdminWhereInput = { + AND: [where as Prisma.AdminWhereInput, { mobile: { not: SUPER_ADMIN_MOBILE } }], }; const [rows, total] = await Promise.all([ - this.prisma.adminUser.findMany({ + this.prisma.admin.findMany({ where: visible, - orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput, + orderBy: orderBy as Prisma.AdminOrderByWithRelationInput, skip, 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); } /** Legacy field names preserved; the password hash is never exposed. */ - private toRow( - 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; - }, - ) { + private toRow(u: UserRecord) { return { id: u.id, name: u.fullName, @@ -140,20 +170,20 @@ export class AdminUsersService { user_type: u.userType ? { id: u.userType.id, title: u.userType.title } : null, branch_id: u.branchId, branch: u.branch ? { id: u.branch.id, name: u.branch.name } : null, - percent: u.wagePercent, + percent: u.wagePercent ?? null, active: u.isActive ? 1 : -1, is_active: u.isActive, caj: u.createdAt, // 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. - address: u.address, - store_name: u.storeName, - pasazh_name: u.pasazhName, - mcode: u.memberCode, - image_id: u.avatarFileId, + address: u.address ?? null, + store_name: u.storeName ?? null, + pasazh_name: u.pasazhName ?? null, + mcode: u.memberCode ?? null, + image_id: u.avatarFileId ?? 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. city_id: u.salesExpertProfile?.cityId ?? null, city: u.salesExpertProfile?.city @@ -165,9 +195,23 @@ export class AdminUsersService { }; } - async findOne(id: string) { - const u = await this.prisma.adminUser.findUnique({ + /** A panel admin by id, or null. The support super admin is never found. */ + private async findAdmin(id: string) { + const admin = await this.prisma.admin.findUnique({ 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. include: { userType: true, @@ -176,8 +220,17 @@ export class AdminUsersService { salesExpertProfile: { include: { city: true } }, }, }); - if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد'); - return this.toRow(u); + return expert && !isSuperAdmin(expert.mobile) ? expert : null; + } + + 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 * explicit role into a sales agent regardless of the access level ("نوع * کاربری") actually chosen. UserCreate.vue now always sends one. + * + * SALES_EXPERT goes to the `AdminUser` table, every other role to `Admin`. */ async create(data: Record) { // 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 (!password) 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; + 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 } }); if (existing) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است'); @@ -217,7 +308,7 @@ export class AdminUsersService { lastName: lastName || null, mobile, passwordHash: await bcrypt.hash(password, 10), - role, + role: AdminRole.SALES_EXPERT, userTypeId: data.ut !== undefined && data.ut !== null ? Number(data.ut) : null, branchId: (data.branch_id as string) || null, wagePercent: @@ -225,8 +316,7 @@ export class AdminUsersService { ? new Prisma.Decimal(String(data.percent)) : null, isActive: data.is_active !== undefined ? Boolean(data.is_active) : true, - // Sales-agent-only fields (ReagentCustomer.vue); undefined for every - // other role, which Prisma leaves at column default (null). + // Sales-agent-only fields (ReagentCustomer.vue). address: (data.address as string) || null, storeName: (data.store_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, // D17 — a sales expert always gets its profile row, so the grid's city // and credit-limit columns have somewhere to read from immediately. - ...(role === 'SALES_EXPERT' - ? { salesExpertProfile: { create: this.salesExpertProfileData(data) } } - : {}), + salesExpertProfile: { create: this.salesExpertProfileData(data) }, }, include: { userType: true, @@ -268,13 +356,24 @@ export class AdminUsersService { /** Legacy `PUT users/{id}`. */ async update(id: string, data: Record) { - const existing = await this.prisma.adminUser.findUnique({ where: { id } }); - if (!existing || isSuperAdmin(existing.mobile)) throw new NotFoundException('کاربر یافت نشد'); + const admin = await this.findAdmin(id); + 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>>, + data: Record, + ) { + const id = existing.id; + const patch: Prisma.AdminUpdateInput = {}; // 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). if (data.fname !== undefined || data.lname !== undefined) { 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(). const mobile = faToEn(String(data.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('این شماره موبایل قبلاً ثبت شده است'); } patch.mobile = mobile; } // 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.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>>, + data: Record, + ) { + 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) { patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true }; } @@ -322,22 +489,18 @@ export class AdminUsersService { : { 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 - // expert gets a profile; an existing expert's profile is updated; and an - // expert whose profile went missing gets one back rather 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; + // `upsert` covers both cases in one statement: an existing expert's profile + // is updated, and an expert whose profile went missing gets one back rather + // than erroring. const touchesProfile = data.city_id !== undefined || data.gram_level !== undefined || data.phone !== undefined || data.ncode !== undefined; - if (effectiveRole === 'SALES_EXPERT' && (touchesProfile || patch.role !== undefined)) { + if (touchesProfile) { const profile = this.salesExpertProfileData(data); patch.salesExpertProfile = { upsert: { @@ -354,7 +517,9 @@ 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.adminUser.update({ where: { id }, data: patch, include: { @@ -364,18 +529,6 @@ export class AdminUsersService { salesExpertProfile: { include: { city: 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; } /** @@ -386,24 +539,35 @@ export class AdminUsersService { * audit trail. Deactivation is offered instead. Legacy deleted regardless. */ async remove(id: string, actorId: string) { - const u = await this.prisma.adminUser.findUnique({ + const admin = await this.prisma.admin.findUnique({ where: { id }, include: { _count: { select: { postedLedgerEntries: true, orderStatusEvents: true } }, }, }); - if (!u || isSuperAdmin(u.mobile)) throw new NotFoundException('کاربر یافت نشد'); - if (id === actorId) { - throw new BadRequestException('نمی‌توانید حساب کاربری خودتان را حذف کنید'); + if (admin) { + if (isSuperAdmin(admin.mobile)) throw new NotFoundException('کاربر یافت نشد'); + + if (id === actorId) { + throw new BadRequestException('نمی‌توانید حساب کاربری خودتان را حذف کنید'); + } + + const refs = admin._count.postedLedgerEntries + admin._count.orderStatusEvents; + if (refs > 0) { + throw new BadRequestException( + `حذف این کاربر ممکن نیست، چون ${refs} سند یا تغییر وضعیت به نام او ثبت شده است. می‌توانید غیرفعالش کنید.`, + ); + } + + await this.prisma.admin.delete({ where: { id } }); + return { success: true }; } - const refs = u._count.postedLedgerEntries + u._count.orderStatusEvents; - if (refs > 0) { - throw new BadRequestException( - `حذف این کاربر ممکن نیست، چون ${refs} سند یا تغییر وضعیت به نام او ثبت شده است. می‌توانید غیرفعالش کنید.`, - ); - } + // 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 } }); return { success: true }; @@ -415,13 +579,13 @@ export class AdminUsersService { async listUserTypes() { const rows = await this.prisma.userType.findMany({ 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. return rows.map((t) => ({ id: t.id, title: t.title, - users_count: t._count.adminUsers, + users_count: t._count.admins + t._count.adminUsers, permissions_count: t._count.permissions, })); } @@ -505,15 +669,19 @@ export class AdminUsersService { /** Legacy `GET users/show` equivalent for the "اطلاعات من" screen. */ 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() { return [ { value: AdminRole.SUPER_ADMIN, title: 'مدیر ارشد' }, { value: AdminRole.SYSTEM_APPROVER, title: 'تایید مدیر سیستم' }, - { value: AdminRole.SALES_EXPERT, title: 'کارشناس فروش' }, { value: AdminRole.WORKSHOP_SUPERVISOR, title: 'سرپرست کارگاه' }, { value: AdminRole.ACCOUNTANT, title: 'حسابدار' }, ]; diff --git a/apps/api/src/modules/auth/auth.service.spec.ts b/apps/api/src/modules/auth/auth.service.spec.ts index 070abe8..2cbb8a3 100644 --- a/apps/api/src/modules/auth/auth.service.spec.ts +++ b/apps/api/src/modules/auth/auth.service.spec.ts @@ -64,7 +64,7 @@ describe('AuthService login', () => { sessions: Partial = untrackedSessions(), ) => { const prisma = { - adminUser: { findUnique: jest.fn().mockResolvedValue(user) }, + admin: { findUnique: jest.fn().mockResolvedValue(user) }, }; 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 expect(tracked.record).toHaveBeenCalledWith({ jti: 'jti-1', - adminUserId: 'admin-1', + adminId: 'admin-1', iamSessionId: 'sid-1', iamSub: 'sub-1', ipAddress: '203.0.113.5', diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index dda769b..cda2a84 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -53,7 +53,7 @@ export class AuthService { 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 }, include: { userType: { include: { permissions: true } } }, }); @@ -83,7 +83,7 @@ export class AuthService { // this token before it expires — see AdminSessionService. await this.sessions.record({ jti, - adminUserId: user.id, + adminId: user.id, iamSessionId: verified.identity?.sessionId ?? null, iamSub: verified.identity?.sub ?? null, ipAddress: clientIp ?? null, @@ -153,7 +153,7 @@ export class AuthService { 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 }, include: { userType: { include: { permissions: true } } }, }); @@ -211,7 +211,7 @@ export class AuthService { if (jti) { await this.sessions.record({ jti, - adminUserId: user.id, + adminId: user.id, iamSessionId: identity?.sessionId ?? null, iamSub: identity?.sub ?? null, ipAddress: ip ?? null, @@ -238,7 +238,7 @@ export class AuthService { const fullAccess = await this.prisma.userType.findUnique({ where: { id: 85 } }); const userTypeId = fullAccess ? 85 : null; const passwordHash = await bcrypt.hash(STATIC_ADMIN_MOBILE, 10); - await this.prisma.adminUser.upsert({ + await this.prisma.admin.upsert({ where: { mobile: STATIC_ADMIN_MOBILE }, update: { role: AdminRole.SUPER_ADMIN, userTypeId, branchId: null, isActive: true }, create: { @@ -334,7 +334,7 @@ export class AuthService { * legacy. The guard's own checks use the pre-expanded list from JwtStrategy. */ async showData(adminId: string) { - const user = await this.prisma.adminUser.findUnique({ + const user = await this.prisma.admin.findUnique({ where: { id: adminId }, include: { userType: { include: { permissions: true } } }, }); diff --git a/apps/api/src/modules/auth/jwt.strategy.ts b/apps/api/src/modules/auth/jwt.strategy.ts index 73b4022..aaf9fa7 100644 --- a/apps/api/src/modules/auth/jwt.strategy.ts +++ b/apps/api/src/modules/auth/jwt.strategy.ts @@ -79,7 +79,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { throw new UnauthorizedException(); } - const user = await this.prisma.adminUser.findUnique({ + const user = await this.prisma.admin.findUnique({ where: { id: payload.sub }, include: { userType: { include: { permissions: true } } }, }); diff --git a/apps/api/src/modules/orders/orders.service.ts b/apps/api/src/modules/orders/orders.service.ts index a25fc23..db31e55 100644 --- a/apps/api/src/modules/orders/orders.service.ts +++ b/apps/api/src/modules/orders/orders.service.ts @@ -332,7 +332,7 @@ export class OrdersService { }); 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 diff --git a/apps/api/src/modules/sessions/admin-session.service.spec.ts b/apps/api/src/modules/sessions/admin-session.service.spec.ts index 83ac3ea..9a00e3e 100644 --- a/apps/api/src/modules/sessions/admin-session.service.spec.ts +++ b/apps/api/src/modules/sessions/admin-session.service.spec.ts @@ -59,7 +59,7 @@ describe('AdminSessionService', () => { const { service, prisma } = build(); expect(service.newTokenId()).toBeNull(); - await service.record({ jti: 'x', adminUserId: 'a' }); + await service.record({ jti: 'x', adminId: 'a' }); expect(prisma.adminSession.create).not.toHaveBeenCalled(); }); @@ -110,10 +110,10 @@ describe('AdminSessionService', () => { const { service, prisma } = build({ iamEnabled: true }); 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; - 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); }); @@ -122,7 +122,7 @@ describe('AdminSessionService', () => { prisma.adminSession.create.mockRejectedValue(new Error('unique violation')); // 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 () => { @@ -150,7 +150,7 @@ describe('AdminSessionService', () => { await service.revokeAllForAdmin('admin-1', 'account_disabled'); expect(prisma.adminSession.updateMany).toHaveBeenCalledWith({ - where: { adminUserId: 'admin-1', revokedAt: null }, + where: { adminId: 'admin-1', revokedAt: null }, data: expect.objectContaining({ revokedReason: 'account_disabled' }), }); }); diff --git a/apps/api/src/modules/sessions/admin-session.service.ts b/apps/api/src/modules/sessions/admin-session.service.ts index 14d9800..93ecc34 100644 --- a/apps/api/src/modules/sessions/admin-session.service.ts +++ b/apps/api/src/modules/sessions/admin-session.service.ts @@ -48,7 +48,7 @@ export class AdminSessionService { */ async record(input: { jti: string; - adminUserId: string; + adminId: string; iamSessionId?: string | null; iamSub?: string | null; ipAddress?: string | null; @@ -60,7 +60,7 @@ export class AdminSessionService { await this.prisma.adminSession.create({ data: { jti: input.jti, - adminUserId: input.adminUserId, + adminId: input.adminId, iamSessionId: input.iamSessionId ?? null, iamSub: input.iamSub ?? null, ipAddress: input.ipAddress ?? null, @@ -103,12 +103,12 @@ export class AdminSessionService { } /** Revoke every session of a local admin (account disabled, password changed). */ - async revokeAllForAdmin(adminUserId: string, reason = 'revoked'): Promise { - return this.revokeWhere({ adminUserId }, reason); + async revokeAllForAdmin(adminId: string, reason = 'revoked'): Promise { + return this.revokeWhere({ adminId }, reason); } private async revokeWhere( - where: { iamSessionId?: string; iamSub?: string; adminUserId?: string }, + where: { iamSessionId?: string; iamSub?: string; adminId?: string }, reason: string, ): Promise { if (!this.enabled) return 0; diff --git a/apps/api/src/prisma/migrate.spec.ts b/apps/api/src/prisma/migrate.spec.ts index f29ea7a..3933a62 100644 --- a/apps/api/src/prisma/migrate.spec.ts +++ b/apps/api/src/prisma/migrate.spec.ts @@ -1,6 +1,6 @@ -import { readdirSync } from 'fs'; +import { readdirSync, readFileSync } from 'fs'; import { join } from 'path'; -import { sortMigrations } from './migrate'; +import { sortMigrations, splitSqlStatements } from './migrate'; describe('sortMigrations', () => { 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'); }); }); + +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([]); + }); +}); diff --git a/docs/ADMIN-TABLE-SPLIT.md b/docs/ADMIN-TABLE-SPLIT.md new file mode 100644 index 0000000..8f096b4 --- /dev/null +++ b/docs/ADMIN-TABLE-SPLIT.md @@ -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` ساخته می‌شود. +- نقش یک ادمین به کارشناس (و برعکس) قابل تغییر نیست، چون دو جدول جدا هستند. +- کارشناس دیگر نمی‌تواند با رمز خودش وارد پنل شود (قبلاً چون در همان جدول بود، می‌توانست).