Feature: AM Cross-Client Rollups (Experts / SLA / Billing)
Last updated: 2026-06-26
Status: Live. Shipped in #1300 (re-implementation of #1274 onto
dev), closes #1244./ops/slaverified live.
What this is
An Account Manager (AM) is assigned a portfolio of client orgs (organizations.assigned_am_id). The cross-client rollups give the AM three portfolio-wide views — Experts, SLA breaches, and Billing — each aggregating across all the AM's assigned clients in one table, with a per-client filter for drill-down. A SuperAdmin sees the same surfaces unscoped (every active org).
These are read surfaces for portfolio monitoring; they do not replace the per-client detail page (/ops/clients/[id]) where actual queue/billing actions happen.
| Surface | Route | What it rolls up |
|---|---|---|
| Experts | /ops/experts | Every Expert ↔ org assignment across the AM's portfolio (one row per org-scoped expert_access grant) |
| SLA breaches | /ops/sla | Open expert-queue items past their sla_breach_at deadline, across the portfolio |
| Billing | /ops/billing | Per-Specialist Lago subscription state across the portfolio |
Sidebar note:
/ops/expertsand/ops/billingare in the Ops sidebar for both AM and SuperAdmin (frontend/src/app/ops/layout.tsx)./ops/slais a live, role-gated page but is not currently surfaced as a sidebar item — it is reached by direct URL (and is the natural landing place after an SLA-breach escalation). #1300 added an SLA nav item; subsequent sidebar churn dropped it while keeping the page. If you re-add it, add it to bothNAV_GROUPS(SuperAdmin) andNAV_ITEMS(AM).
Tenant scoping — the load-bearing primitive
All three rollups share one isolation mechanism. Do not add a new cross-client AM surface without routing it through this.
OrganizationAccessService (api/src/common/organization-access.service.ts)
getAccessibleOrgIds(user)— resolves the org IDs a caller may see:superadmin→ every org withdeactivated_at IS NULL.account_manager/am→ orgs whereassigned_am_id = user.userId(and not deactivated).- any other role, or no
userId→[](fail-closed).
assertOrgIdsAccessible(user, requestedOrgIds)— SuperAdmin passes through; otherwise any requested org not in the caller's accessible set throwsForbiddenException(403).
AmOrgScopeGuard (api/src/common/am-org-scope.guard.ts)
Wired as a controller/method guard alongside PlatformRolesGuard. It reads orgIds / orgId from query or route params and populates req.amScopedOrgIds:
superadmin→req.amScopedOrgIds = requestedOrgIdsif the caller supplied any, elseundefined(meaning "all orgs", no filter).account_manager/am:- with explicit
?orgIds=→assertOrgIdsAccessible(403 on any out-of-portfolio id), then scoped to exactly those. - with no
orgIds→getAccessibleOrgIds(user)(the full portfolio).
- with explicit
- non-AM/SA roles → guard passes through without setting the field (the role guard rejects them first).
Service queries then filter on req.amScopedOrgIds. An empty amScopedOrgIds (AM with zero assigned orgs) returns an empty result, never an unscoped one — this is the fail-closed contract verified by api/test/am-cross-org-isolation.e2e-spec.ts (AM-A cannot read Org-2 data via a direct ?orgIds= injection).
This is the org_id-only leak class from ADR-020 applied to the AM portfolio boundary: the guard is what stops one AM from reading another AM's clients.
API surface
| Method | Path | Auth | Service | Purpose |
|---|---|---|---|---|
| GET | /am/experts?orgIds=<id> | AM / SA + AmOrgScopeGuard | OrganizationsService.listAmExpertsRollup(scopedOrgIds, isSuperAdmin) | Expert↔org assignment rollup |
| GET | /expert-queue?slaBreached=true&orgIds=<id> | AM / SA / Expert + AmOrgScopeGuard | ExpertQueueService.listSlaBreaches(scopedOrgIds, orgId, limit, offset, expertId) | SLA-breach rollup |
| GET | /ops/billing | AM / SA + AmOrgScopeGuard + MFA | OpsBillingOverviewQuery.execute(scopedOrgIds) | Per-Specialist subscription rollup |
Frontend clients (frontend/src/lib/api.ts): listAmExpertsRollup({ orgId }), getSlaBreaches({ orgId }), listAmOrgs() (populates the per-client filter dropdown). Each maps orgId → the orgIds query param.
Experts rollup — listAmExpertsRollup
Joins expert_access with scope = 'org' (the AM portfolio-level grant), filtering revoked_at IS NULL and excluding suspended Experts, scoped to scopedOrgIds. Returns one row per (Expert, org):
{ expertId, expertName, expertEmail, expertStatus, orgId, orgName, assignmentId, assignedAt }
SLA-breach rollup — listSlaBreaches
(api/src/expert-queue/expert-queue.service.ts) Selects open queue items where:
item.sla_breach_at IS NOT NULL
AND item.sla_breach_at <= NOW()
AND item.status != 'resolved'
ordered by sla_breach_at ASC (oldest breach first), then created_at ASC. Default limit 50, hard cap 200; offset for pagination. Org filter: explicit orgId narrows to one client; otherwise scopedOrgIds bounds the portfolio (empty array → []). Returns:
{ queueItemId, expertId, orgId, orgName, expertName, breachedAt, ageMinutes, deepLink }
ageMinutes is now - breachedAt in whole minutes; deepLink is the drill-down target (below).
Expert-caller scoping (#2163): when the caller is an Expert,
listSlaBreachesalways applies the dual-scopeexpert_accessJOIN (scope='specialist' AND specialist_id=...ORscope='org') even when an explicitorgIdis supplied —orgIdnarrows, it never bypasses the access check. The AM/SA path usesamScopedOrgIdsinstead.
Billing rollup — OpsBillingOverviewQuery
(api/src/billing/application/queries/ops-billing-overview.query.ts) Returns totalSubscriptions, activeCount, syncFailedCount, and one subscriptions[] row per Specialist assignment (org name, Specialist name, status, currentRateCents, currency, timestamps), filtered to scopedOrgIds. Orphaned sync_failed rows whose assignment no longer exists are hidden (#1436).
Mutations stay SuperAdmin-only. The
/ops/billingread is AM-accessible, but the sensitive endpoints on the same controller —sync-customer,retry-sync,customer/:orgId,orgs/:orgId/expert-workload,orgs/:orgId/llm-cost,payment-failures— are re-locked tosuperadminvia method-level@PlatformRoles("superadmin")overrides.Known cost (#1300 review):
OpsBillingOverviewQuerycallssubs.listAll()and filters in memory. Fine at current scale; push the filter down to the repo (listByOrgIds) if the subscription table grows.
SLA breach definition & refresh cadence
The breach deadline is computed once, at queue-item creation, and stored on expert_queue_items.sla_breach_at. A cron then flips status and escalates. The rollup read just compares sla_breach_at to now.
Deadline calculation (getSlaConfig + calculateBusinessHourDeadline)
Per-org config from organizations.metadata (with defaults):
| Metadata key | Default | Meaning |
|---|---|---|
sla_threshold_hours | 4 | Hours allowed before breach |
sla_timezone | UTC | IANA tz the business-hour window is evaluated in |
sla_business_hours_enabled | true | Business-hours math vs plain wall-clock |
When business hours are enabled, the clock only runs Mon–Fri 09:00–18:00 in the org's timezone (BUSINESS_START = 9, BUSINESS_END = 18, fixed). Items created outside the window start at the next 09:00; weekend items start Monday 09:00. With business hours disabled it is plain createdAt + thresholdHours. (See #1492 / #1491.)
sla_status (ok | at_risk | breached, default ok): at_risk is reserved for future use; breached is set by the monitor cron.
Breach monitor cron
- Job
sla-breach-monitor(api/src/scheduler/scheduler.constants.ts), schedule*/2 * * * *(every 2 minutes), overridable viaSLA_BREACH_MONITOR_CRON. - Scans items past
sla_breach_atwithescalated_at IS NULL; setssla_status = 'breached', escalates to the org's assigned AM via Slack DM → email fallback, then stampsescalated_atso each item escalates exactly once (idempotent — multi-pod safe). - BullMQ repeatable job with a stable
jobId, leader-gated per the active-active cron contract.
Read-path freshness
The rollup pages fetch on mount and re-fetch when the client filter (?org=) changes — there is no client-side polling timer on these surfaces. So the SLA list reflects DB state at load: sla_breach_at (creation-time) compared to now, independent of whether the 2-minute cron has yet stamped escalated_at. Billing reflects the latest Lago-webhook-mirrored state (see billing.md). Reload (or change the filter) to refresh.
Drill-down semantics
- SLA → conversation: each row's Open link uses the server-provided
deepLink=/ops/clients/{orgId}?tab=experts&queueItemId={queueItemId}, landing the AM on that client's Experts/queue tab pre-pointed at the breached item. The Client column links to/ops/clients/{orgId}. - Per-client filter & URL persistence: the org dropdown writes
?org=<id>(plus optional?from=/?to=date filters on the SLA page, applied client-side tobreachedAt).getRollupHref()in the Ops layout preservesorg/from/towhen navigating between/ops/expertsand/ops/billing, so the selected client carries across rollup tabs. - Experts/Billing → client: the org column links into
/ops/clients/[id]for the full client detail surface.
Access control summary
| Role | Experts | SLA | Billing (read) | Billing mutations |
|---|---|---|---|---|
superadmin | ✅ all orgs | ✅ all orgs | ✅ all orgs | ✅ |
account_manager / am | ✅ portfolio only | ✅ portfolio only | ✅ portfolio only | ❌ (403) |
expert | ❌ → /ops/clients | ❌ → /ops/clients (queue endpoint applies expert_access) | ❌ | ❌ |
| client roles | ❌ → /ops/clients | ❌ | ❌ | ❌ |
Frontend pages redirect non-AM/SA roles to /ops/clients; the backend guards (PlatformRolesGuard + AmOrgScopeGuard) are the authoritative gate.
Key files
| Concern | Path |
|---|---|
| AM scope guard | api/src/common/am-org-scope.guard.ts |
| Org access resolver | api/src/common/organization-access.service.ts |
| Experts rollup service | api/src/organizations/organizations.service.ts (listAmExpertsRollup) |
| Experts rollup endpoint | api/src/organizations/organizations.controller.ts (GET /am/experts) |
| SLA breach rollup | api/src/expert-queue/expert-queue.service.ts (listSlaBreaches) |
| SLA breach endpoint | api/src/expert-queue/expert-queue.controller.ts (GET /expert-queue?slaBreached=true) |
| SLA deadline + monitor | expert-queue.service.ts (getSlaConfig, calculateBusinessHourDeadline, monitorSlaBreaches) |
| Monitor cron schedule | api/src/scheduler/scheduler.constants.ts (SLA_BREACH_MONITOR) |
| Billing rollup query | api/src/billing/application/queries/ops-billing-overview.query.ts |
| Billing controller | api/src/billing/interface/ops-billing.controller.ts (@Controller("ops/billing")) |
| Frontend pages | frontend/src/app/ops/{experts,sla,billing}/page.tsx |
| Frontend clients | frontend/src/lib/api.ts (listAmExpertsRollup, getSlaBreaches, listAmOrgs) |
| Cross-org isolation E2E | api/test/am-cross-org-isolation.e2e-spec.ts |
Related
- ADR-020 — isolation classification — the org_id-only leak class this guard closes for the AM portfolio boundary.
- ADR-007 — expert access scope — the dual-scope
expert_accessgrant the Experts/SLA rollups read. - docs/features/billing.md — the Lago billing rail the billing rollup reads.
- docs/features/expert-queue.md — the queue whose items the SLA rollup surfaces.
- docs/USER_OPERATIONS_MANUAL.md §6 — the AM role workflow chapter.