# v1 Session Analytics API Design — Filters, Headers & Clickable KPIs

Addendum to [prd.md](prd.md) / [trd.md](trd.md). Scope: versions the two live session-analytics
endpoints as `v1`, brings them to parity with the `registration-list-view` filter/KPI UX, and
resolves how RM fits into both scoping and filtering. Does not change the attendance
resolution engine ([trd.md §3](trd.md#3-architecture-overview)) — this is the read/query
surface on top of it.

## 1. Why

Today:

- `GET /analytics/sessions/:id/kpis` — no query params at all.
- `GET /analytics/sessions/:id/attendees?page&limit&search` — only free-text search.

Reference (not to be copied 1:1, but the UX bar to hit): `GET /registration/registration-list-view`
supports `filters` (JSON), `parentFilter`, `view`, and returns `kpis[]` where each tile
carries its own `kpiCategory`/`kpiFilter` — clicking a tile re-issues the list query with that
tile's category/filter, narrowing the table to exactly the rows behind the number
(`registration.service.ts` `extractKPIFilters`/`extractDataFilters`, ~line 3388-3448).

Session analytics needs the same three things — **filters, headers, clickable KPIs** — plus a
clean answer for **RM**, which today only exists in this module as an actor-scoping side effect
(`resolveRmContactId` in `zoom-analytics-facade.service.ts:124-127`), not as something a user
can filter *by*.

## 2. Proposed endpoints

```
GET v1/analytics/sessions/:sessionId/kpis
GET v1/analytics/sessions/:sessionId/attendees
```

Follows the repo's existing bare-`v1/` controller-prefix convention (`v1/master-sections`,
`v1/master-questions`, `v1/program-templates`, etc. — no `/api` prefix precedent exists).
New controller/module, e.g. `src/analytics/v1/analytics-v1.controller.ts`, reusing the existing
`ZoomAnalyticsFacadeService` — this is a new query surface, not a rewrite of the resolution
engine or the write paths (`mark`, `undo`, `join`, webhook are untouched).

The unversioned routes stay as-is for existing callers; v1 is additive. (Deprecation timeline
for the old routes = open question, see §8.)

## 3. Query parameters

Both endpoints accept:

| Param | Type | Notes |
|---|---|---|
| `filters` | JSON string, URL-encoded | See §4 — **DTO-validated**, not `JSON.parse`'d raw like the registration module does today |
| `kpiCategory` | string enum | The clicked tile's category; `all` = no KPI-derived narrowing |
| `kpiFilter` | string enum | The clicked tile's filter; `all` = no KPI-derived narrowing |
| `search` | string | Existing behavior, unchanged (name/email/mobile) |
| `rmId` | number, optional | Explicit RM scope override — see §6 |

`attendees` only, additionally:

| Param | Type | Notes |
|---|---|---|
| `page` / `limit` | number | Keep `page/limit` (matches `AnalyticsAttendeeQueryDto` today and `online-attendance` reports) rather than switching to `limit/offset`. This *codifies* the answer to TRD's open pagination question — see §8, item 1, for why it's called out as a decision rather than assumed. |
| `sortKey` / `sortOrder` | string / `ASC`\|`DESC` | New — not in today's DTO; needed once the table is filterable/clickable, so users can sort e.g. by `lastDropoffAt` |

`kpis` has no `page`/`limit` — it's always the full tile set for the session.

### 3a. `filters` — validated, not ad-hoc

The registration module parses `filters` with a bare `JSON.parse(decodeURIComponent(filters))`
and no shape validation (`registration.controller.ts:683`, repeated at 3 other call sites — a
repo-wide pattern, not a one-off). **For v1, don't repeat that gap**: add a
`ParseJsonPipe`-style transform + a `SessionAttendeeFiltersDto` (`class-validator`) so malformed
or unknown filter keys 400 immediately instead of silently no-op'ing or reaching the repository
layer. Matches `coding-standards.md`'s "DTOs for all input/output" rule, which the reference
implementation itself doesn't fully satisfy.

`SessionAttendeeFiltersDto` fields (all optional):

```ts
class SessionAttendeeFiltersDto {
  @IsOptional() @IsEnum(AttendanceEffectiveStatus) attendanceStatus?: 'present' | 'absent' | 'unknown';
  @IsOptional() @IsEnum(JoinModeEnum) modeOfJoining?: string;
  @IsOptional() @IsEnum(DeviceTypeEnum) deviceType?: string;
  @IsOptional() @IsBoolean() hasDropoff?: boolean;
  @IsOptional() @IsInt() rmContactId?: number;   // same knob as top-level `rmId`; kept here too
                                                   // so it composes with kpiCategory/kpiFilter
                                                   // the same way registration's filters object does
}
```

## 4. KPI catalog & clickable mapping

Mirrors `getKPIFilterCondition` / `extractKPIFilters` / `extractDataFilters`
(`registration.service.ts:5917`+, `:3388`, `:3408`) but scoped to session attendance. One
category, `attendance`, over the existing `ZoomSessionKpis` fields
(`zoom-analytics.interface.ts:11-22`):

| KPI label | `kpiCategory` | `kpiFilter` | Filter condition applied to `attendees` |
|---|---|---|---|
| Total seekers joined | `attendance` | `joined` | `attendance.system.state = 'present'` |
| Not joined | `attendance` | `notJoined` | roster row with no join/webhook event |
| Joined late | `attendance` | `joinedLate` | `joinedAt > session.startTime + graceMinutes` |
| Dropped | `attendance` | `dropped` | `dropoffCount > 0 AND lastRejoinedAt IS NULL` |
| Rejoined | `attendance` | `rejoined` | `rejoinCount > 0` |
| All | `attendance` | `all` | no narrowing (default) |

Each tile in the `kpis` response carries its own `kpiCategory`/`kpiFilter`
(same shape as registration's `kpis[]: { label, value, kpiCategory, kpiFilter }`), so the
frontend can pass them straight back on the `attendees` call — that round-trip *is* "KPIs
clickable." The mapping function (`getSessionKpiFilterCondition(category, filter)`) is a small,
isolated switch — same shape as `getKPIFilterCondition` — so adding a KPI later is a one-place
change, consistent with the coding-principles.md DRY/KISS rule.

`totalPanelists`, `durationMinutes`, `startTime`, `reconciledAt` are **not clickable** — they
describe the session, not a seeker subset, so there's no attendee-row filter behind them
(shown as plain stat tiles, no `kpiCategory`/`kpiFilter`, same as registration's non-clickable
summary fields).

## 5. RM handling

Two distinct things are both called "RM" in this flow and the doc needs to keep them separate:

1. **RM as an actor being scoped** (existing behavior, keep as-is): when the caller's active
   role is `relational_manager`, both endpoints already auto-scope to
   `registration.rm_contact = actorUserId` via `resolveRmContactId`
   (`zoom-analytics-facade.service.ts:124-127`, applied in
   `zoom-analytics-roster.repository.ts:70-71,99-100` and
   `zoom-analytics-attendee-summary.repository.ts:100-113,203-212`). No change needed here for
   v1 — this keeps working exactly as it does today, KPIs and attendee rows both come back
   pre-scoped to "my seekers" for an RM caller.

2. **RM as a filter an admin/coordinator applies** (new, this is the gap): today an admin
   viewing session KPIs/attendees has no way to slice by "just this RM's seekers." Add the
   `rmId` param (§3) / `filters.rmContactId` (§3a) — admin/coordinator only (an RM caller can't
   use it to see outside their own scope; if both the actor-scope and an explicit `rmId` are
   present and disagree, actor-scope wins and the request 400s rather than silently picking
   one — avoids a privilege-escalation-by-query-param bug).

**Reconciling with `trd.md`'s open RM item:** `trd.md §9`/`risk-r3`/`oq-06` flags that
`assertRmScope` in `online-attendance.service.ts` is **log-and-allow** behind
`ATTENDANCE_STRICT_RM_SCOPE` because, per that TRD, "registration→RM is only an `rmName` string
today." That's stale relative to `common/entities/program-registration.entity.ts:161-162`,
which already has a real `rm_contact bigint` FK to `User` — and the zoom module already uses it
for hard scoping (item 1 above), not just logging. Worth closing `oq-06` explicitly: either the
TRD's premise was written before `rm_contact` landed, or there's a reason
`online-attendance.service.ts` deliberately doesn't trust it yet that should be written down.
Either way, this v1 analytics work should scope RM the same way the zoom module already does
(`rm_contact`, hard filter) rather than inventing a third convention — see open question in §8.

No new **KPI category** for RM is proposed (i.e. no "seen by RM" tile) — RM/Coordinator marking
state is a per-row column (`attendance.rm`, `attendance.coordinator` in `ZoomAttendeeRow`,
already returned today), not a session-level count anyone asked for. If that's wanted later, it
composes cleanly as a second `kpiCategory` (e.g. `markerStatus` / `rmMarked` / `rmUnmarked` /
`coordinatorMarked`) without touching the `attendance` category above.

## 6. Headers

`attendees` response gains `tableHeaders[]`, same shape as the registration reference
(`{ key, label, sortable, filterable, type }`), generated server-side from the fixed
`ZoomAttendeeRow` shape (not user-configurable in v1 — no evidence any caller needs
column-picking yet; add only if requested). This lets the frontend render the table without
hardcoding column defs, and lines up `sortable`/`filterable` flags with the `sortKey` (§3) and
`filters` (§3a) params so the table's own header UI can drive both.

## 7. Response envelope

Per `error-handling.md` (`{ success, data, error }`), and matching the registration reference's
`data` shape:

```jsonc
// GET v1/analytics/sessions/:id/kpis
{
  "success": true,
  "data": {
    "sessionId": 1578,
    "startTime": "...", "durationMinutes": 60, "reconciledAt": "...",
    "kpis": [
      { "label": "Total panelists", "value": 4 },
      { "label": "Seekers joined", "value": 812, "kpiCategory": "attendance", "kpiFilter": "joined" },
      { "label": "Not joined", "value": 188, "kpiCategory": "attendance", "kpiFilter": "notJoined" },
      { "label": "Joined late", "value": 40, "kpiCategory": "attendance", "kpiFilter": "joinedLate" },
      { "label": "Dropped", "value": 22, "kpiCategory": "attendance", "kpiFilter": "dropped" },
      { "label": "Rejoined", "value": 15, "kpiCategory": "attendance", "kpiFilter": "rejoined" }
    ]
  },
  "error": null
}

// GET v1/analytics/sessions/:id/attendees?kpiCategory=attendance&kpiFilter=dropped&page=1&limit=10
{
  "success": true,
  "data": {
    "data": [ /* ZoomAttendeeRow[] — unchanged shape */ ],
    "tableHeaders": [ { "key": "fullName", "label": "Name", "sortable": true, "filterable": true, "type": "string" }, "..." ],
    "pagination": { "page": 1, "limit": 10, "total": 22 },
    "appliedKpi": { "kpiCategory": "attendance", "kpiFilter": "dropped" }
  },
  "error": null
}
```

`appliedKpi` echoes back what was applied (mirrors registration's pattern of the response
confirming which KPI is "active" so the frontend can highlight the matching tile) — small but
matters for the "clickable" UX to feel connected, not just two independently-queried screens.

## 8. Open questions (need your call before implementation starts)

1. **Pagination convention** — this doc assumes `page/limit` for `attendees` (matches what
   exists today + `online-attendance` reports). `trd.md §Data Requirements` already flags the
   repo has two conventions (`limit/offset` for registration/QR/session-registration lists).
   Confirm `page/limit` is fine for v1, or you'd rather unify on `limit/offset` repo-wide.
2. **RM scoping method** — confirm using `registration.rm_contact` (hard FK, same as the zoom
   module already does) as the source of truth for both actor-scoping and the new `rmId`
   filter, and treat `trd.md`'s `oq-06`/`ATTENDANCE_STRICT_RM_SCOPE` flag as resolved/stale for
   this surface — or tell me if there's a reason `online-attendance.service.ts` intentionally
   doesn't trust `rm_contact` yet that I should preserve here too.
3. **KPI catalog completeness** — is the 5-tile `attendance` category (joined/notJoined/
   joinedLate/dropped/rejoined) the full v1 set, or do you also want the marker-status
   (`rmMarked`/`coordinatorMarked`) category from §5 in v1 rather than deferred?
4. **Deprecation timeline** for the unversioned `/analytics/sessions/:id/kpis|attendees` routes
   once v1 ships — keep both indefinitely, or sunset the old ones on a date?
5. **`sortKey` allowed values** for `attendees` — which `ZoomAttendeeRow` fields need to be
   sortable in v1 (name, joinedAt, durationSeconds, dropoffCount are the obvious candidates;
   confirm or adjust)?

## 9. Non-goals for this v1 pass

- No change to the attendance resolution engine, event log, or write paths (`mark`/`undo`/
  `join`/webhook) — this doc is the read/query surface only.
- No `parentFilter`/`view` equivalent — session analytics has one shape (there's no
  goodies/travel-style alternate view here), so that part of the registration reference doesn't
  transfer.
- No user-configurable column set for `tableHeaders` (fixed server-defined set for now, §6).
