Skip to main content

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/sla verified 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.

SurfaceRouteWhat it rolls up
Experts/ops/expertsEvery Expert ↔ org assignment across the AM's portfolio (one row per org-scoped expert_access grant)
SLA breaches/ops/slaOpen expert-queue items past their sla_breach_at deadline, across the portfolio
Billing/ops/billingPer-Specialist Lago subscription state across the portfolio

Sidebar note: /ops/experts and /ops/billing are in the Ops sidebar for both AM and SuperAdmin (frontend/src/app/ops/layout.tsx). /ops/sla is 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 both NAV_GROUPS (SuperAdmin) and NAV_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 with deactivated_at IS NULL.
    • account_manager / am → orgs where assigned_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 throws ForbiddenException (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:

  • superadminreq.amScopedOrgIds = requestedOrgIds if the caller supplied any, else undefined (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 orgIdsgetAccessibleOrgIds(user) (the full portfolio).
  • 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

MethodPathAuthServicePurpose
GET/am/experts?orgIds=<id>AM / SA + AmOrgScopeGuardOrganizationsService.listAmExpertsRollup(scopedOrgIds, isSuperAdmin)Expert↔org assignment rollup
GET/expert-queue?slaBreached=true&orgIds=<id>AM / SA / Expert + AmOrgScopeGuardExpertQueueService.listSlaBreaches(scopedOrgIds, orgId, limit, offset, expertId)SLA-breach rollup
GET/ops/billingAM / SA + AmOrgScopeGuard + MFAOpsBillingOverviewQuery.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, listSlaBreaches always applies the dual-scope expert_access JOIN (scope='specialist' AND specialist_id=... OR scope='org') even when an explicit orgId is supplied — orgId narrows, it never bypasses the access check. The AM/SA path uses amScopedOrgIds instead.

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/billing read 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 to superadmin via method-level @PlatformRoles("superadmin") overrides.

Known cost (#1300 review): OpsBillingOverviewQuery calls subs.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 keyDefaultMeaning
sla_threshold_hours4Hours allowed before breach
sla_timezoneUTCIANA tz the business-hour window is evaluated in
sla_business_hours_enabledtrueBusiness-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 via SLA_BREACH_MONITOR_CRON.
  • Scans items past sla_breach_at with escalated_at IS NULL; sets sla_status = 'breached', escalates to the org's assigned AM via Slack DM → email fallback, then stamps escalated_at so 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 to breachedAt). getRollupHref() in the Ops layout preserves org / from / to when navigating between /ops/experts and /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

RoleExpertsSLABilling (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

ConcernPath
AM scope guardapi/src/common/am-org-scope.guard.ts
Org access resolverapi/src/common/organization-access.service.ts
Experts rollup serviceapi/src/organizations/organizations.service.ts (listAmExpertsRollup)
Experts rollup endpointapi/src/organizations/organizations.controller.ts (GET /am/experts)
SLA breach rollupapi/src/expert-queue/expert-queue.service.ts (listSlaBreaches)
SLA breach endpointapi/src/expert-queue/expert-queue.controller.ts (GET /expert-queue?slaBreached=true)
SLA deadline + monitorexpert-queue.service.ts (getSlaConfig, calculateBusinessHourDeadline, monitorSlaBreaches)
Monitor cron scheduleapi/src/scheduler/scheduler.constants.ts (SLA_BREACH_MONITOR)
Billing rollup queryapi/src/billing/application/queries/ops-billing-overview.query.ts
Billing controllerapi/src/billing/interface/ops-billing.controller.ts (@Controller("ops/billing"))
Frontend pagesfrontend/src/app/ops/{experts,sla,billing}/page.tsx
Frontend clientsfrontend/src/lib/api.ts (listAmExpertsRollup, getSlaBreaches, listAmOrgs)
Cross-org isolation E2Eapi/test/am-cross-org-isolation.e2e-spec.ts