Skip to content

Library Views Specification

F9 Phase 2 surface. See index.md for the architecture; this doc defines the lightweight engagement ingest for library-overlay watches.

The Patient Portal's exercise library lets a patient browse the catalog and watch individual exercises (or audio files) through a lightweight overlay player — pure curiosity, not part of any session run. These watches have no session_runs row, no clinical record, and no QoS rollup. They feed exactly one product surface: F9's library curiosity tile on the clinic patient-detail stats tab ("what library content does this patient engage with").

Why a separate surface

/v1/media/events is a streaming session-scoped surface: many events per run, aggregation in memory, terminal flush on session_end, run identity in the JWT, per-(run_id, media_id) rows. None of that fits a library overlay:

  • No run — no session_runs to attach to.
  • No QoS analytics need — the tile only asks "what / how completely / when."
  • Single overlay open → single record; streaming aggregation is overkill.
  • Different read pattern — per-patient over a 30-day window, not per-run.
  • Different retention horizon — library views aren't clinical history; lifecycle policy lives independently.

So library views ride their own narrow endpoint with their own table and their own JWT audience.

Endpoint

EndpointPurposeAuth
POST /v1/library/viewsOne library-overlay close → one rowLibrary token (audience: restartix-telemetry-library); no consent claim — engagement is GDPR Art. 6(1)(f) legitimate interest

Fire once per overlay close, with the totals accumulated client-side over the watch. The server validates, computes the authoritative completion_pct, and writes one row.

Request

json
POST /v1/library/views
Authorization: Bearer <library-jwt>
Content-Type: application/json

{
  "media_id": "content-file-uuid",
  "media_kind": "video",
  "started_at": "2026-05-21T10:00:00.000Z",
  "ended_at":   "2026-05-21T10:01:30.000Z",
  "watched_seconds":        85.4,
  "total_duration_seconds": 120.0
}
FieldRequiredNotes
media_idyescontent_files.id per F9 Phase 1. Opaque to telemetry.
media_kindno"video" (default) or "audio". Missing / unknown → "video".
started_atyesRFC3339.
ended_atyesRFC3339; must be ≥ started_at.
watched_secondsnoDefault 0. Must be ≥ 0.
total_duration_secondsnoWhen > 0, server derives completion_pct = clamp(watched / total * 100, 0, 100). When 0 / missing, completion_pct stored as 0.

Identity (org_id, patient_id) comes from the verified JWT — payload-supplied identity fields, if any, are ignored.

Response

202 Accepted
Content-Type: application/json

{ "status": "accepted", "media_id": "content-file-uuid" }

Error envelope

400 Bad Request for malformed JSON, missing media_id / timestamps, ended_at before started_at, or negative durations. 401 Unauthorized for missing / invalid / wrong-audience tokens. 500 Internal Server Error for storage failures. No 403 on this route — engagement runs under legitimate interest, not consent.

JWT

Library tokens share the session token's signing material (same kid, same HS256 secret at restartix/{env}/telemetry-jwt-signing-key) but carry a distinct audience and omit run_id:

{
  "iss":        "restartix-api",
  "aud":        "restartix-telemetry-library",
  "sub":        "<patient-uuid>",
  "iat":        1747816800,
  "exp":        1747822200,
  "org_id":     "<org-uuid>",
  "patient_id": "<patient-uuid>",
  "consents":   { "pose": false }
}

Minted by API on portal load when a patient enters the exercise library (telemetrytoken.Signer.MintLibrary). Same 90-min TTL as the session token.

The Telemetry service verifies signature + issuer + expiry; the audience is enforced by the per-route auth.RequireAudience middleware (the verifier itself is audience-agnostic so one shared key serves both surfaces). A session token replayed against /v1/library/views (or vice versa) is rejected with 401.

Storage

Single table, range-partitioned monthly per P41:

sql
CREATE TABLE media_library_views (
    id                       UUID        NOT NULL DEFAULT gen_random_uuid(),
    organization_id          UUID        NOT NULL,
    patient_id               UUID        NOT NULL,
    media_id                 TEXT        NOT NULL,
    media_kind               TEXT        NOT NULL DEFAULT 'video'
                                         CHECK (media_kind IN ('video','audio')),

    started_at               TIMESTAMPTZ NOT NULL,
    ended_at                 TIMESTAMPTZ NOT NULL,
    watched_seconds          REAL        NOT NULL DEFAULT 0,
    total_duration_seconds   REAL,
    completion_pct           REAL        NOT NULL DEFAULT 0,

    created_at               TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    PRIMARY KEY (id, started_at)
) PARTITION BY RANGE (started_at);

CREATE INDEX idx_media_library_views_patient    ON media_library_views (patient_id, started_at DESC);
CREATE INDEX idx_media_library_views_org_started ON media_library_views (organization_id, started_at);
CREATE INDEX idx_media_library_views_media       ON media_library_views (media_id);

Append-only (no ON CONFLICT clause) — a rapid open-close-open-close is signal, not noise. Partition runway is extended by cmd/telemetry-partition-roll, which covers both media_buffering_events and media_library_views in one pass.

The (patient_id, started_at DESC) index is the curiosity-tile access pattern. The (organization_id, started_at) index serves clinic-level rollups (top library content per clinic).

Reads

All reads go through API. Telemetry exposes no per-tenant authenticated query path — the post-launch read endpoint (API → telemetry PG, filtered by (organization_id, patient_id)) is owned by F9's stats lane.

What this surface intentionally excludes

  • Buffering / quality / dropped-frame events. Library overlays are short, low-stakes watches; QoS noise doesn't earn its keep here.
  • Watch threshold events (watch_pct_25 / _50 / etc.). The single completion_pct value is enough for the curiosity tile.
  • Pose / biometric. Pose ingest is session-only. The library player runs without pose tracking.
  • Idempotency / dedup. Each overlay close is one row. Duplicate POSTs from a flaky client appear as duplicate rows — harmless to the curiosity tile (which counts engagements, not unique opens).