Reports

Reporting is a spec-driven query engine over inbox data. A client never sends SQL — it sends a ReportSpec whose every field is a key into a server-side catalog, never a column name. The server validates the spec against the catalog's whitelist and compiles it to a parameterised, tenant-scoped query. The result is "any report the data supports" with no injection surface.

Scope: stats, not inbox

Reporting is read-only analytics, and it is deliberately kept off the write-capable inbox scope. A credential that can produce a revenue chart should not thereby be able to reply to a customer.

This has a consequence worth knowing about: stats is granted to every user session, because reading your own workspace's aggregates is an ordinary member action and withholding the scope would break workspace reporting for everyone. That is exactly why stats alone is not sufficient for the cross-tenant operator surface — one scope guards two trust levels, so operator access needs user.isOperator on top.

The tenant clause is not yours to set

if (ctx.mode === 'workspace') {
  const tid = assertString(ctx.tenantId, 'tenantId');
  if (spec.tenantFilter !== undefined) {
    throw new ReportValidationError('tenantFilter is not allowed on the workspace route');
  }
  return Prisma.sql`${col} = ${tid}`;   // bound param
}

On /v1/reports/* the compiler always injects the caller's tenant clause, and rejects any client-supplied tenantFilter outright rather than ignoring it. Rejecting is the safer failure: a silently-dropped filter leaves a caller believing they scoped a query that they did not.

The tenant column comes from the catalog — a server constant — so no request can name it.

Building a spec

{
  "source": "tickets",
  "measures": [{ "fn": "count", "as": "tickets" }],
  "dimensions": [{ "dateTrunc": { "field": "createdAt", "grain": "day" }, "as": "day" }],
  "filters": [{ "field": "status", "op": "eq", "value": "closed" }],
  "dateRange": { "field": "createdAt", "start": "2026-01-01", "end": "2026-01-31" },
  "limit": 500
}
Field Notes
source tickets, messages, threads, campaign_recipients, campaigns
measures At least one. fn is count, sum, avg or count_distinct; field is omitted only for count
dimensions Zero or more; each is a catalog field or a dateTrunc (day/week/month), never both. At most one dateTrunc
filters eq, in, gte, lte, between
dateRange Required — this is the bounded-cost guarantee
limit Capped server-side: default 500, max 5000
tenantFilter Operator route only

as is sanitised to [A-Za-z0-9_]{1,40} and used only as a JavaScript key.

Routes

Route Scope Notes
POST /v1/reports/query stats @ReadOnlyPostno Idempotency-Key needed
POST /v1/reports/export stats @ReadOnlyPost; streams CSV
GET /v1/reports/catalog stats The safe catalog projection, for building specs in a UI
GET /v1/reports/presets stats The curated report set
GET /v1/reports/inbox stats Pre-shaped dashboard; start and end required
GET /v1/reports/campaigns stats Pre-shaped dashboard; start and end required
GET /v1/reports/agents stats Pre-shaped dashboard; start and end required
GET /v1/reports/top-performing-agents stats Leaderboard; start and end required

query and export are POSTs only because a spec is too large for a query string. They are marked @ReadOnlyPost(), which opts them out of the verb-driven idempotency requirement — unlike most POSTs, they need no key. See Idempotency.

Presets carry a placeholder date range

GET /v1/reports/presets returns the axis-api Engage reports expressed as saved specs, so consumers stop re-declaring the same shapes client-side (the dashboard's hardcoded copies had drifted and covered fewer reports). Each preset's dateRange carries only { field } — you inject the real window before POSTing it to query:

const [preset] = await inbox.request('GET', '/v1/reports/presets');
await inbox.reports.query({
  ...preset.spec,
  dateRange: { ...preset.spec.dateRange, start, end },
});

Presets on the tenant route never carry a tenantFilter, because the compiler would reject it.

The four dashboards

inbox, campaigns, agents and top-performing-agents are pre-shaped rather than spec-driven — ports of axis-api's EngageReportsService, kept so an existing consumer does not have to rebuild them out of specs. All four require start and end; omitting either is validation_failed (422). They inherit the engine's bounded-cost guarantee through that required window.

When the caller is a user session, the raw bearer is forwarded to Accounts so it resolves the caller's own groups and agent UUIDs come back as names. A machine key has no session, so it gets raw UUIDs.

AI dashboards are deliberately absent — they need intelligence-track data (AI users, GPT-per-inbox) that Inbox does not hold.

CSV export

Two ways out, and both produce CSV.

POST /v1/reports/export streams the same spec, the same tenant clause and the same numbers as query, written through @~lyre/dataport so the report engine and the file share one writer. Columns are dimensions-then-measures in spec order.

The dashboards export too, via ?export=true (or any format), flattened to section,label,value — the dashboard flattened, not a second computation.

format is accepted but changes nothing. Inbox ships no XLSX encoder, so the bytes are always CSV and the file is always named .csv. It previously honoured format=xlsx in the filename while streaming CSV, which is exactly the combination that makes Excel refuse the file with "the file format or extension is not valid". The parameter is still accepted so older clients do not break; the extension was the lie, and it is gone.

Prisma-only

ReportQueryService runs $queryRaw against Postgres and has no in-memory equivalent. The whole module is registered only when Prisma is on, so in the hermetic in-memory test environment these routes do not exist — a request to /v1/reports/query there is a 404, not an empty result. Do not read that as a regression; check backends.database on /v1/health first.

The operator mirror

/v1/operator/reports/{catalog,presets,query} is the cross-tenant mirror, gated by @Operator() (stats, no tenant header). In operator mode the compiler injects no tenant clause, so a query spans every tenant of the app — and here spec.tenantFilter is honoured, narrowing to one workspace.

No session token is available on a machine key, so agent-name enrichment is skipped and raw UUIDs are returned; the dashboard resolves names against Accounts itself.