Skip to content

Health Badge System

Back to Regnify Product Docs

Previous Next


Overview

The health badge system assigns every representative a GREEN, AMBER, or RED compliance health flag. This flag is computed server-side by RepProfileService.computeRepHealth() (services/nestjs/src/domain/rep-profile/rep-profile.service.ts) and is visible in the Admin Rep Register, COMP dashboard, Rep Portal dashboard, and the chatbot's get_rep_register tool output.

The flag is a deterministic rule-engine rollup against MAS Notice FAA-N26 (the competency notice for Financial Adviser representatives, in force since 1 April 2024). The FAA-N26 CPD annual thresholds (FAA-N26 §5.4) are hardcoded as constants:

CPD_ANNUAL_MINIMUM_HOURS = 30       // FAA-N26 §5.4 Table 3 row 1(b): total for general investment product reps
CPD_CORE_MINIMUM_HOURS = 6          // FAA-N26 §5.4(a)(i): Core CPD (ethics/rules/regulations, IBF/SCI-accredited)
CPD_SUPPLEMENTARY_MINIMUM_HOURS = 24 // FAA-N26 §5.4(a)(ii) Table 3 row 1(b): Supplementary CPD

The Prisma model uses hoursEthics, hoursProduct, hoursOther as an activity-logging taxonomy. For health badge computation: Core CPD = hoursEthics, Supplementary CPD = hoursProduct + hoursOther.

The rollup is a closed specification (published regulation thresholds), making the deterministic computation CLAUDE.md S10-clean. The LLM-driven narrative path (_classify_rep_flag in chatbot_tools.py) remains available for advisory follow-up, but the admin-facing register uses the server-side computation exclusively.

Health Flag Definitions

GREEN

Definition: All required CMFAS modules are in good standing AND CPD hours meet all three FAA-N26 minima for the current calendar year.

Conditions: - Zero CMFAS modules with status not_taken, failed, or expired - cpdHoursYtd >= 30 (total) - cpdHoursEthicsYtd >= 6 (Core CPD — ethics/rules/regulations, IBF/SCI-accredited) - (cpdHoursProductYtd + cpdHoursOtherYtd) >= 24 (Supplementary CPD — all other CPD activities)

Transition out: GREEN -> AMBER when CPD hours fall below any minimum threshold. GREEN -> RED when any CMFAS module status changes to not_taken, failed, or expired.

AMBER

Definition: All required CMFAS modules are in good standing, BUT current-year CPD hours fall below at least one FAA-N26 minimum threshold.

Conditions: - Zero CMFAS modules with status not_taken, failed, or expired - At least one CPD shortfall: total < 30 OR Core CPD < 6 OR Supplementary CPD < 24

Transition to GREEN: Log sufficient CPD hours to meet ALL three minima. The CPD total includes all CpdRecord rows for the rep in the current calendar year. Core CPD is aggregated from hoursEthics; Supplementary CPD is aggregated from hoursProduct + hoursOther.

Transition to RED: A CMFAS module status changes to not_taken or failed.

RED

Definition: At least one required CMFAS module is NOT in good standing. A module is "not in good standing" when its effective status is not_taken, failed, or expired.

Effective status computation: A CMFAS row with stored status = "passed" but expiresAt in the past is treated as "expired" -- a belt-and-suspenders check against stale analyzer data.

Transition to GREEN: Pass the missing/failed module exam AND meet all three CPD minima. Passing the exam updates the CmfasModuleStatus row to status = "passed" with a fresh expiresAt. If CPD is simultaneously below thresholds, the flag becomes AMBER instead of GREEN.

CMFAS passes do NOT expire by time alone for health flag purposes: The expiry check is against expiresAt in the CmfasModuleStatus table, not against a fixed time window since passing. The module status row controls this via its expiresAt date.

Server-Side Computation

RepProfileService.computeRepHealth(repProfileId: number) returns a RepHealthSummary with:

{
  repProfileId: number;
  userId: string;
  repNumber?: string | null;
  userName?: string | null;
  principalOrganisationId?: number | null;
  healthFlag: "red" | "amber" | "green";
  healthReasons: string[];      // human-readable explanation strings
  cmfasStatus: Array<{moduleCode, status, expiresAt}>;
  cpdHoursYtd: number;          // total CPD hours year-to-date
  cpdHoursEthicsYtd: number;    // ethics-only hours YTD
  cpdHoursProductYtd: number;   // product-only hours YTD
  cycleYear: number;            // current UTC year
  hasMissingRequired: boolean;  // true when any CMFAS module is not_taken/failed/expired
}

Decision logic (in order): 1. Fetch rep profile + user data. 2. Determine cycleYear = new Date().getUTCFullYear(). 3. Fetch all CmfasModuleStatus rows for the rep (via CmfasStatusService.findByRep()). 4. Fetch aggregated CPD totals for the rep in cycleYear (via CpdRecordService.totalHoursForRep()). 5. Check each CMFAS module for not_taken, failed, or expired (expiresAt < now). 6. If any bad module exists -> RED. 7. Else if any CPD minimum is not met -> AMBER. 8. Else -> GREEN.

Bulk computation: repProfilesWithHealth(orgId: number | null) returns RepHealthSummary[] for all reps in an org. Uses N+1 per rep (one CMFAS query + one CPD rollup query each). Acceptable for typical org sizes (tens to low hundreds of reps).

Data Sources

Table Columns Used Purpose
CmfasModuleStatus repProfileId, moduleCode, status, expiresAt Determine if any required module is not in good standing. Status values: passed, failed, expired, not_taken.
CpdRecord repProfileId, cycleYear, hoursEthics, hoursProduct, hoursOther Aggregate CPD hours for the current calendar year. Total = ethics + product + other.
RepProfile id, userId, repNumber, principalOrganisationId Link rep to user and org.

Chatbot Integration

The get_rep_register chatbot tool (chatbot_tools.py:673) calls repProfilesWithHealth(orgId) via GraphQL and surfaces each rep's server-computed healthFlag, healthReasons, CPD hours, and hasMissingRequired. The tool returns these verbatim -- no LLM re-classification. When org is omitted, the tool defaults to ctx.org_id (the authenticated user's organisation binding).

Tool return shape per rep:

{
  "rep_profile_id": 1,
  "user_id": "abc-123...",
  "rep_number": "RN-001",
  "user_name": "John Tan",
  "flag": "green",
  "reasons": ["CMFAS good; CPD meets FAA-N26 minima"],
  "cpd_hours_ytd": 35.5,
  "cpd_hours_ethics_ytd": 10.0,
  "cpd_hours_product_ytd": 6.0,
  "cycle_year": 2026,
  "has_missing_required": false
}

Demo Seed Distribution

30 rep profiles across 5 FIs, with load-bearing ordering (must stay in this sequence for distribution tests):

Bucket Count Usernames
GREEN 8 dbs_rep1, dbs_rep2, uob_rep1, uob_rep2, ge_rep1, ge_rep2, manulife_rep1, aia_rep1
AMBER 14 dbs_rep3, dbs_rep4, uob_rep3, uob_rep4, ge_rep3, ge_rep4, manulife_rep2, manulife_rep3, manulife_rep5, manulife_rep6, aia_rep2, aia_rep3, aia_rep6, demo_rep2
RED 8 dbs_rep5, dbs_rep6, uob_rep5, uob_rep6, ge_rep5, manulife_rep4, aia_rep4, aia_rep5

Distribution per FI: DBS (6), UOB (6), GE (5), Manulife (6), AIA (6), plus demo_rep2 (standalone REP_USER test account in Keycloak).

Visibility Points

Surface Endpoint / Page What is Shown
Admin Rep Register /admin/rep-register Full table with health badge (colored R/A/G), reasons, CPD hours per rep
COMP Dashboard /admin/dashboard Health summary card with GREEN/AMBER/RED counts
Rep Portal Dashboard /rep/dashboard Rep's own health badge + reasons
Chatbot get_rep_register tool Health flags + reasons for all reps in queried org

Key Architectural Decisions

  1. Server-side deterministic rollup, not LLM: The health flag uses hardcoded FAA-N26 thresholds. This is a closed specification, not a semantic judgement. The LLM _classify_rep_flag path exists only for narrative advisory follow-up, not for the admin-facing register.

  2. "Required modules" = all modules the rep carries: There is no separate "required module list" table. The Cert Analyzer upserts one CmfasModuleStatus row per required module when the rep is onboarded. A missing row is only possible before onboarding.

  3. Expired check is belt-and-suspenders: Even if status = "passed", an expiresAt in the past overrides to "expired". This protects against stale analyzer data.

  4. CMFAS status takes priority over CPD: RED (CMFAS gap) overrides AMBER (CPD shortfall). The order of checks is: CMFAS gaps first (RED), then CPD shortfalls (AMBER), then all-good (GREEN).


Previous Next

Back to Regnify Product Docs