Files
Project-SDE-WP-Suite/docs/waves/wave-11.md
Matt Mabrey 75ac930d0c waves 11-13: planning docs for CR-019, CR-020, D18
CR-019 - usage/activity metrics (admin console), wave 11
CR-020 - bulk editing of users, wave 12
D18    - Okta/AD deprovisioning detection and auto-disable, wave 13

Raised by Matt Mabrey 2026-09-17. Decision record and task breakdowns
only in this commit - no feature code yet.
2026-09-18 09:21:29 -07:00

224 lines
8.8 KiB
Markdown

# Wave 11 — Usage and activity metrics
**Items:** `CR-019`
**Depends on:** wave 10 merged (it is; this wave does not wait on anything else)
**Decision record:** `docs/waves/decisions-2026-09-17.md`
Seven tasks, one concern each, in build order. Do not start a task whose
dependency is not merged. `CR-020` (bulk user editing) is reserved but not
scoped — it does not belong in this wave.
---
### T11.1 — CR-019: `usage_events` table + migration
- **Items:** `CR-019`
- **Depends on:** nothing (first task)
- **Blocks:** T11.2
- **Surface:** `server/`
- **Files:** `server/models.py`, `server/alembic/versions/`
**Do:** Add a `UsageEvent` model — append-only, same spirit as `AuditLog` but for
navigation/feature-open events rather than business mutations. Suggested shape:
`id`, `at` (indexed), `user_id` (or username — match whatever `AuditLog.actor`
does today for consistency), `project_id` (nullable — not every event is
project-scoped, e.g. opening the admin console), `tool` (e.g. `creator`,
`wizard`, `field_view`, `dashboard`, `admin`, `directory`), `event` (e.g.
`page_open`, `login`), `detail` (JSON, optional). Write the migration. Do not
touch `AuditLog` — this is a new table, not an extension of it (see the
decision record's reasoning).
**Do not:** fold this into `AuditLog`. They serve different questions and mixing
them makes the existing audit trail noisier for its existing readers.
**Done when:**
- [ ] `UsageEvent` exists with an indexed `at` column (this table will be scanned
by date range constantly)
- [ ] migration applies cleanly against both SQLite (dev) and Postgres (prod) —
render `alembic upgrade --sql` for postgresql and read it before calling
this done, per the class of defect `BL-027` logged
- [ ] no change to `AuditLog`'s shape or behavior
---
### T11.2 — CR-019: capture the events
- **Items:** `CR-019`
- **Depends on:** T11.1
- **Blocks:** T11.3
- **Surface:** `server/` + `html/`
- **Files:** `server/app.py` (new endpoint), `html/auth-guard.js`
**Problem:** Six pages need this and none should implement it separately — that
is exactly how `S4`'s "no global nav on two pages" and the four parallel token
systems (`S5`) happened. `auth-guard.js` is already loaded first, in the `<head>`,
on all six protected pages (`index.html`, `field.html`, `users.html`,
`wp-creation-index.html`, `work-package-suite.html`, `admin.html`) and already
knows the verified user once the `wp-auth-ready` event fires. That is the one
place this belongs.
**Do:** Add a small `POST /api/usage/ping`-style endpoint that writes one
`UsageEvent` row per call, keyed to the session (server trusts the session, not
anything the client claims about identity). Call it once from `auth-guard.js`
after `wp-auth-ready`, tagging `tool` from the page's own path. Also write a
`login` event at the point a session is actually established (reuse whatever
`okta_callback` already does at sign-in — do not add a second source of truth
for "did this person log in").
**Do not:** build a per-page capture call. If a page needs this and
`auth-guard.js` does not cover it, fix `auth-guard.js`, not the page.
**Done when:**
- [ ] one `page_open` event is recorded for a real sign-in on each of the six
pages, verified per page
- [ ] exactly one `login` event per Okta sign-in, not one per page load after
it
- [ ] the endpoint rejects a request with no valid session (this is server-
enforced identity, not client-reported)
- [ ] no page other than `auth-guard.js` calls this endpoint directly
---
### T11.3 — CR-019: aggregation endpoint with filters
- **Items:** `CR-019`
- **Depends on:** T11.2
- **Blocks:** T11.4, T11.5
- **Surface:** `server/`
- **Files:** `server/app.py`
**Do:** Build the read side: active-user counts by day/week/month, per-user
last-active timestamp (derived from `UsageEvent`, not `User.last_login_at`,
which only ever holds one value), and a per-tool usage breakdown. Accept query
filters: date range, project, user, tool — combinable, per the decision record.
This is server aggregation, the same principle `B4` established for the
pipeline strip: the browser asks for a number, the server computes it from real
rows, nothing is derived client-side from a partial cache.
**Done when:**
- [ ] active-user counts are correct against a seeded fixture with known dates
- [ ] filters combine correctly (verified: user + date range + tool together
narrows correctly, not just each alone)
- [ ] a project filter that matches nothing returns an empty result, not an
error or the unfiltered total
---
### T11.4 — CR-019: export, raw and sanitized
- **Items:** `CR-019`
- **Depends on:** T11.3
- **Blocks:** T11.5
- **Surface:** `server/`
- **Files:** `server/app.py`
**Do:** A CSV export endpoint over the same filtered query T11.3 exposes.
Two modes: raw (real usernames, the console's default) and sanitized. Sanitized
mode replaces the actor field with a stable pseudonymous id — a per-user hash,
consistent across rows in the same export and across separate export runs —
so an external system (Power BI or similar) can still group and trend "by
user" without ever receiving a real name. Do not simply drop the identity
column; that breaks per-user grouping downstream, which defeats the point of
an activity export.
**Done when:**
- [ ] raw export contains real usernames
- [ ] sanitized export never contains a real username or email anywhere in the
file, including in a `detail` blob if one is included
- [ ] the same real user maps to the same pseudonymous id within one export AND
across two separate export runs (a hash of something stable, not a
per-request random id)
- [ ] both modes otherwise contain identical rows for the same filter
---
### T11.5 — CR-019: admin console Activity tab
- **Items:** `CR-019`
- **Depends on:** T11.3, T11.4
- **Blocks:** T11.7
- **Surface:** `html/`
- **Files:** `html/admin.html`, `html/admin.js`
**Do:** New tab, same role gate as the User Directory. Filters (date range,
project, user, tool) driving the tables from T11.3; export buttons (raw and
sanitized) calling T11.4. Build accessible from the start per `C1` — this is a
new component, not a legacy one carrying an old defect forward: real
`<button>`/`<select>` controls, keyboard-reachable, `aria-live` on any
count that updates without a page reload, focus visible throughout.
**Done when:**
- [ ] the tab is reachable only by an admin (verify: a non-admin session gets
no tab and the route itself refuses the request server-side, not just a
hidden tab)
- [ ] every filter is a real form control, keyboard-operable
- [ ] both export buttons produce the files T11.4 defines
- [ ] works at 390px and 1440px
---
### T11.6 — CR-019: retire the per-browser Usage report
- **Items:** `CR-019`
- **Depends on:** T11.5
- **Blocks:** T11.7
- **Surface:** `html/`
- **Files:** `html/admin.js` (the `usage-admin` panel, `admin.js:666-699`),
`html/wp-usage.js` and its two call sites
**Do:** Remove the old per-browser `usage-admin` panel from `admin.js` now that
the real one exists, per the 2026-09-17 decision. Decide what happens to
`wp-usage.js`'s recording calls (wizard dwell-tracking, the creator's
equivalent): they were never reliably tied to a real identity, so they are not
a data source the new report can adopt. Default to removing the recorder too
unless it is still doing something useful on its own (re-read what it actually
records before deciding — do not assume from this file alone).
**Do not:** leave the old panel in place "just in case." Two activity reports
showing two different numbers is worse than one.
**Done when:**
- [ ] the old `usage-admin` panel and its markup are gone from `admin.html`/
`admin.js`
- [ ] a decision on `wp-usage.js` itself is recorded (removed, or kept with a
stated reason) — not left ambiguous
- [ ] nothing else in the app references the removed code; grep confirms
---
### T11.7 — CR-019: verification
- **Items:** `CR-019`
- **Depends on:** T11.6
- **Blocks:** nothing
- **Surface:** `html/` + `server/`
- **Files:** as touched above
**Do:** Full verification per `CLAUDE.md`: run the app locally, exercise the
new tab at 390px and 1440px, before/after screenshots, run the existing smoke
test and `seed_demo.py`, run the full suite.
**Done when:**
- [ ] all `CR-019` acceptance criteria in `decisions-2026-09-17.md` are met or
a failure is stated with a reason
- [ ] screenshots committed
- [ ] smoke test and `seed_demo.py` both still pass
- [ ] full test suite passes
---
## Wave 11 exit criteria
- [ ] real, server-side activity data exists per user, indefinitely retained
- [ ] the admin console shows it, filterable by date/project/user/tool
- [ ] export works in both raw and sanitized form
- [ ] the old per-browser report is gone, not duplicated
- [ ] `CR-019` fully accounted for, no open acceptance criteria