Customers created in the staff UI had no NUMid and so could not log in to my.jorgecuadros.com at all: the id is a CustomerLegacyRef row, not a column, and create() deliberately writes none. Allocation is a staff action (POST /customers/:id/portal-access, MANAGER) rather than part of create, because insurance is expected to move to the platform before utilities and an insurance-only customer has no reason to spend a utilities id. The audit that decides which ids are reusable took three passes. "Owns no rows" matches nobody -- migration gave all 1,171 NUMids a property and a transaction. "No transaction in N years" also matches nobody -- every customer carries a synthetic Jan-1 opening-balance row, so everyone looks active this year. Subtracting that row is what makes dormancy measurable, and it leaves 4 never-used ids and 10 dormant ones on dev. Two further traps are encoded in the queries: insurance/DATGRAL is a separate id space that reuses the sourceTable name and runs past 4,000, and ACCOUNT CANCELED is a transaction line type, not an account state -- all 8 customers carrying it have current-year activity. Recycling ships switched off (numid.recycleEmpty, default false). Every reusable id still exists in Access DATGRAL, and a --sync run reassigns refs with ON DUPLICATE KEY UPDATE customerId, so an id recycled before the utilities cutover is silently handed back to its Access owner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
108 lines
3.9 KiB
TypeScript
108 lines
3.9 KiB
TypeScript
// Server-authoritative permission matrix. Roles form an ordered rank
|
|
// (ADMIN > MANAGER > STAFF > VIEWER — this is the "level" concept); every
|
|
// write action carries a minimum rank. VIEWER holds rank 0 and is the
|
|
// read-only role. Reads are not listed here — they stay on AuthenticatedGuard
|
|
// alone, so any logged-in user (including VIEWER) can read.
|
|
//
|
|
// This is the single source of truth: the API enforces it via AbilityGuard and
|
|
// ships the resolved per-user map to the web through /auth/me, so the UI never
|
|
// keeps its own copy of the rules.
|
|
|
|
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
|
|
|
|
export const ROLE_RANK: Record<Role, number> = {
|
|
VIEWER: 0,
|
|
STAFF: 1,
|
|
MANAGER: 2,
|
|
ADMIN: 3,
|
|
};
|
|
|
|
export type Ability =
|
|
| "customer:create"
|
|
| "customer:update"
|
|
| "customer:delete"
|
|
| "customer:portal-access"
|
|
| "policy:create"
|
|
| "policy:update"
|
|
| "policy:delete"
|
|
| "policy:ingest"
|
|
| "policy:ocr-review"
|
|
| "renewal:send"
|
|
| "property:create"
|
|
| "property:update"
|
|
| "property:delete"
|
|
| "ledger:create"
|
|
| "ledger:void"
|
|
| "bank:create"
|
|
| "bank:void"
|
|
| "bank:manage-accounts"
|
|
| "statement:ingest"
|
|
| "statement:review"
|
|
| "lookup:manage"
|
|
| "user:manage"
|
|
| "db:manage"
|
|
| "notification:send"
|
|
| "setting:manage";
|
|
|
|
/** Minimum role required for each ability. */
|
|
export const ABILITY_MIN: Record<Ability, Role> = {
|
|
"customer:create": "STAFF",
|
|
"customer:update": "STAFF",
|
|
"customer:delete": "ADMIN",
|
|
// Assigning a portal NUMid is granting someone the ability to log in to
|
|
// my.jorgecuadros.com and read an account, so it sits above customer:update:
|
|
// editing a phone number is the day job, handing out portal identity is not.
|
|
// It is also close to irreversible in practice — the id is what the customer
|
|
// then types at every login.
|
|
"customer:portal-access": "MANAGER",
|
|
"policy:create": "STAFF",
|
|
"policy:update": "STAFF",
|
|
"policy:delete": "MANAGER",
|
|
// Insurance OCR intake is the same trust tier as statement OCR: STAFF can
|
|
// upload + confirm, nothing reaches the books unconfirmed.
|
|
"policy:ingest": "STAFF",
|
|
"policy:ocr-review": "STAFF",
|
|
"renewal:send": "MANAGER",
|
|
"property:create": "STAFF",
|
|
"property:update": "STAFF",
|
|
"property:delete": "MANAGER",
|
|
"ledger:create": "STAFF",
|
|
"ledger:void": "MANAGER",
|
|
"bank:create": "STAFF",
|
|
"bank:void": "MANAGER",
|
|
// Opening or renaming a chequera is rarer and higher-stakes than posting a
|
|
// movement into one — a wrong account silently mixes two sets of books.
|
|
"bank:manage-accounts": "MANAGER",
|
|
// Uploading a stack of scans and reviewing what the OCR read are both
|
|
// "capturing a receipt" — the same trust tier as ledger:create, since
|
|
// confirming a statement *is* capturing it. The review step is what makes
|
|
// this safe at STAFF level: nothing reaches the ledger unconfirmed.
|
|
"statement:ingest": "STAFF",
|
|
"statement:review": "STAFF",
|
|
"lookup:manage": "MANAGER",
|
|
"user:manage": "ADMIN",
|
|
"db:manage": "ADMIN",
|
|
// Mass email notifications — fires mail to customers on the office's
|
|
// behalf, with no per-row review. Same trust tier as `renewal:send`:
|
|
// a STAFF user typing one customer receipt is fine; a STAFF user firing
|
|
// 260 mail merges on the customer base is not.
|
|
"notification:send": "MANAGER",
|
|
// Editing operator configuration. Above `notification:send` on purpose:
|
|
// firing a sweep is the day job, but changing WHERE the audit summaries
|
|
// land is how someone would quietly stop them being read.
|
|
"setting:manage": "ADMIN",
|
|
};
|
|
|
|
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
|
|
|
|
export function can(role: Role, ability: Ability): boolean {
|
|
return ROLE_RANK[role] >= ROLE_RANK[ABILITY_MIN[ability]];
|
|
}
|
|
|
|
/** Resolved {ability: boolean} map for a role — sent to the web via /auth/me. */
|
|
export function abilitiesFor(role: Role): Record<Ability, boolean> {
|
|
return Object.fromEntries(
|
|
ALL_ABILITIES.map((a) => [a, can(role, a)]),
|
|
) as Record<Ability, boolean>;
|
|
}
|