import { ACTIONS, STAGES } from './config.js' /** * Who may perform what. * * A MIRROR of workflow.tbl_wf_activity_permissions on app 536, in the same * sense as STAGES and ACTIONS: the console needs it to decide what to draw, * and the API returns a refusal rather than a capability list. * * ── This is presentation, never enforcement ────────────────────────────── * The workflow refuses server-side on every submission, and that refusal is * the real control. Nothing here can grant anything; a role added here that * the platform does not recognise gets a 403 the moment it submits. What this * buys is that an operator is not shown four buttons they will be refused for * pressing — which on a demo reads as a broken app rather than as a fence. * * Keep in step with the permissions table. A role REMOVED there but left here * shows a button that 403s: recoverable and visible. A role ADDED there but * missing here hides a button that would have worked: invisible, and the * failure mode worth watching for. */ export const ACTIVITY_ROLES = { 'zk-act-init-agent': ['ops_admin', 'partner_agent'], 'zk-act-init-bank': ['ops_admin', 'bank_rm'], 'zk-act-init-direct': ['ops_admin', 'csr_direct'], 'zk-act-qualify': ['ops_admin', 'ai_intake'], 'zk-act-contact': ['ops_admin', 'ai_engage'], 'zk-act-request-docs': ['ops_admin', 'ai_engage'], 'zk-act-collect-docs': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], 'zk-act-capture-motor':['ops_admin', 'ai_engage'], 'zk-act-capture-sme': ['ops_admin', 'ai_engage'], 'zk-act-advise': ['ai_advisor'], 'zk-act-quote': ['ops_admin', 'ai_engage'], 'zk-act-accept': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], 'zk-act-kyc': ['ops_admin', 'ai_kyc'], 'zk-act-uw-screen': ['ops_admin', 'ai_kyc'], 'zk-act-uw-prepare': ['ai_uw_referral'], // The only exclusive permission in the application. Ops runs everything // else and cannot touch these two — a referral is cleared by an underwriter // or it is not cleared. 'zk-act-uw-clear': ['sme_underwriter'], 'zk-act-uw-decline': ['sme_underwriter'], 'zk-act-payment': ['ops_admin', 'ai_engage'], 'zk-act-nudge': ['ops_admin', 'ai_engage'], 'zk-act-realise': ['ops_admin'], 'zk-act-onboard': ['ops_admin', 'ai_engage'], 'zk-act-resume': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], 'zk-act-drop': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], // ── The one place this mirror deliberately DISAGREES with the workflow ── // // Both of these carry NO roles in tbl_wf_activity_permissions, which the // platform reads as "open to anyone". That is not generosity: the scheduler // performs them as the synthetic `system` actor, and the trigger's own // perform step ignores allow_system_perform, so zero roles is the only // configuration under which a scheduled activity can run at all. See // 63_scheduled_activities_rbac.sql. // // Open to the SYSTEM is not the same as open to everyone signing in. Left // literal, an underwriter is offered "Send Document Reminder" on a queue // they have no business in. So the UI narrows them to the roles that would // plausibly chase by hand. The workflow still accepts either from anyone — // this hides a button, it does not close a door. 'zk-act-doc-reminder': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], 'zk-act-retry-call': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], } /** Roles that belong to an AI employee. They never sign in. */ export const AI_ROLES = new Set([ 'ai_intake', 'ai_engage', 'ai_advisor', 'ai_kyc', 'ai_uw_referral', ]) export function rolesOf(user) { const r = user?.roles if (Array.isArray(r)) return r.filter(Boolean).map(String) if (Array.isArray(user?.role_assignments)) { return user.role_assignments.map((x) => x?.role_id).filter(Boolean).map(String) } return [] } /** * An empty role list means the activity is open to any signed-in user — that * is what the platform's RBAC does with no roles attached, so mirroring it * here keeps the two from disagreeing. */ export function canPerform(roles, activityUid) { const allowed = ACTIVITY_ROLES[activityUid] if (!allowed) return true // unknown activity: show it, let the API decide if (allowed.length === 0) return true return roles.some((r) => allowed.includes(r)) } /** The activities this user may actually run from a given stage. */ export function actionsFor(roles, stageUid) { return (ACTIONS[stageUid] || []).filter((a) => canPerform(roles, a.uid)) } /** * Activities that are available almost everywhere, or that no person runs. * * They must not count towards "does this role have business in this queue". * Mark Lost is offered in eight stages to every agent role, and the two * scheduled activities carry no roles at all — which the platform reads as * open. Left in the test, between them they make every queue look relevant * to everybody, which is the whole thing this is trying to avoid. */ const AMBIENT = new Set(['zk-act-drop', 'zk-act-doc-reminder', 'zk-act-retry-call']) /** * Which queues a role has any business WORKING IN. * * Terminal stages stay visible to everyone — closed business is reporting, * not work, and hiding it would leave an underwriter unable to see what * became of a file they declined. Everything else appears only where the * role can do something substantive. * * This gates the SIDEBAR, not the data. The overview still counts the whole * book for every role, because an agent tracking leads they filed is a * reasonable thing to want and a queue they cannot act in is not. */ export function visibleStages(roles) { // Operations runs the desk. They cannot clear a referral — that stays // exclusive to the underwriter — but the underwriting queue is theirs to // watch, and an ops console that hides a queue because it holds one // activity they may not press is hiding the wrong thing. if (roles.includes('ops_admin')) return STAGES return STAGES.filter((s) => { if (s.kind === 'end') return true return (ACTIONS[s.uid] || []) .filter((a) => !AMBIENT.has(a.uid)) .some((a) => canPerform(roles, a.uid)) }) } /** Whether this user can file a lead at all, and through which doors. */ export function entryDoorsFor(roles, entries) { return entries.filter((e) => canPerform(roles, e.uid)) }