visibleStages hides a queue whose actions this role cannot perform, so without these three entries Needs a Person was an ops-only queue and the underwriter could not see a flag sent to them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
160 lines
7.4 KiB
JavaScript
160 lines
7.4 KiB
JavaScript
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'],
|
|
// Ops only. The customer's questions are normally Engage's, and this is
|
|
// the way a person answers one Engage declined — through the same WhatsApp
|
|
// thread, so the reply lands on the lead file instead of somewhere nobody
|
|
// can find it later.
|
|
'zk-act-answer-customer': ['ops_admin'],
|
|
'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'],
|
|
// Zero roles in the workflow since 86 — the scheduler performs it, and a
|
|
// scheduled activity with any role list fails silently. Left here as an
|
|
// empty list so the mirror still records that the activity exists.
|
|
'zk-act-nudge': [],
|
|
'zk-act-realise': ['ops_admin'],
|
|
'zk-act-onboard': ['ops_admin', 'ai_engage'],
|
|
'zk-act-resume': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'],
|
|
|
|
// The AI's one way of saying "a person should take this". Every employee
|
|
// holds it, and so does ops — a person can park a lead for the same reasons.
|
|
'zk-act-flag-review': ['ops_admin', 'ai_intake', 'ai_engage', 'ai_kyc', 'ai_advisor', 'ai_uw_referral'],
|
|
// And the two ways back out of Needs a Person. The underwriter is here too:
|
|
// a flag they are sent is theirs to route, not only ops'.
|
|
'zk-act-review-resume':['ops_admin', 'sme_underwriter'],
|
|
'zk-act-review-refer': ['ops_admin', 'sme_underwriter'],
|
|
'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))
|
|
}
|