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';