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,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 $$;