feat(auth): split panel admins out of AdminUser into their own Admin table

AdminUser held both panel staff and sales experts, so one mobile could not be
both. Staff now live in "Admin" (panel only); sales experts stay in "AdminUser"
untouched. Uniqueness is per table.

- migration 20261026120000_split_admin_table: additive only. Creates "Admin",
  copies non-expert rows with their ids, re-points the "who did this" foreign
  keys (ledger, order status, sms, notification, upload, allocation, workshop,
  admin session) to "Admin" without moving data. Never writes to "AdminUser".
- login, JWT, SSO, branch/workshop supervisors and the users grid use "Admin";
  SALES_EXPERT create/edit still goes through admin/users into "AdminUser".
- an admin cannot be turned into an expert or the reverse (different tables).
- manual/: optional phase-2 script to drop the dormant admin copies from
  "AdminUser", and a read-only expert-login diagnostic.
- docs/ADMIN-TABLE-SPLIT.md: what changed, deploy checklist, rollback.

Tested on a copy with 12,000 experts: expert rows byte-identical (md5) before
and after; full migration chain applies and matches schema.prisma.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-25 23:52:33 +03:30
co-authored by Claude Sonnet 5.5
parent 4679e2498c
commit 06c8a5f434
21 changed files with 798 additions and 192 deletions
@@ -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 $$;
+75 -31
View File
@@ -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")
+5 -5
View File
@@ -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: {
@@ -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,
);
@@ -32,7 +32,7 @@ export class AdminBranchesService {
async listBranches(query: TableQueryDto) {
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, {
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 },
@@ -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.
@@ -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<string, string> = {
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) => ({
@@ -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,
})),
@@ -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('سفارش یافت نشد');
@@ -63,7 +63,7 @@ export class AdminUploadsService {
mimeType: file.mimetype,
sizeBytes: file.size,
purpose: purpose ?? null,
uploadedByAdminUserId: actorAdminUserId,
uploadedByAdminId: actorAdminUserId,
},
});
}
@@ -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:
* 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<string> = 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<string, unknown>) {
// 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<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 } });
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<string, unknown>) {
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<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
// 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<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) {
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: 'حسابدار' },
];
@@ -64,7 +64,7 @@ describe('AuthService login', () => {
sessions: Partial<AdminSessionService> = 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',
+6 -6
View File
@@ -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 } } },
});
+1 -1
View File
@@ -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 } } },
});
@@ -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
@@ -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' }),
});
});
@@ -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<number> {
return this.revokeWhere({ adminUserId }, reason);
async revokeAllForAdmin(adminId: string, reason = 'revoked'): Promise<number> {
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<number> {
if (!this.enabled) return 0;
+24 -2
View File
@@ -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([]);
});
});
+78
View File
@@ -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` ساخته می‌شود.
- نقش یک ادمین به کارشناس (و برعکس) قابل تغییر نیست، چون دو جدول جدا هستند.
- کارشناس دیگر نمی‌تواند با رمز خودش وارد پنل شود (قبلاً چون در همان جدول بود، می‌توانست).