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
+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")