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
+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([]);
});
});