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: {