T11.2: capture usage events (CR-019)

POST /api/usage/ping writes one page_open UsageEvent per authenticated
page load, identity from the session (get_current_user), never from the
client. Wired from exactly one place - auth-guard.js's proceed(), after
wp-auth-ready - so this can't drift into six separate per-page copies.

okta_callback now also writes one login event per sign-in.

Verified locally: unauthenticated ping -> 401; a real fake-Okta sign-in
writes exactly one login row and one page_open row, no duplicates.
This commit is contained in:
2026-09-23 11:24:49 -07:00
parent 0652fa732d
commit 850b78972b
2 changed files with 56 additions and 0 deletions

View File

@@ -125,12 +125,40 @@
// Nothing replaces it. Every signed-in page mounts the drawer, so there is no page // Nothing replaces it. Every signed-in page mounts the drawer, so there is no page
// left that would need a floating fallback pill. // left that would need a floating fallback pill.
// ── CR-019: usage ping ───────────────────────────────────────────────────
// One page_open event per authenticated load, sent from exactly ONE place
// (here) rather than from each page's own script - the shared-chrome lesson
// S4 and the token-drift lesson S5 both taught this codebase the hard way.
// Fire-and-forget: never blocks reveal(), never retries, never surfaces an
// error to the person using the app - a missed usage ping is not something
// anyone here should notice happening.
var TOOL_BY_PAGE = {
'index.html': 'launcher',
'work-package-suite.html': 'wizard',
'wp-creation-index.html': 'creator',
'field.html': 'field_view',
'admin.html': 'admin',
'users.html': 'directory'
};
function pingUsage() {
var page = (location.pathname.split('/').pop() || 'index.html');
var tool = TOOL_BY_PAGE[page] || page.replace(/\.html$/, '');
try {
fetch('/api/usage/ping', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool: tool })
}).catch(function () {});
} catch (e) {}
}
function proceed(user) { function proceed(user) {
clearTimeout(safety); clearTimeout(safety);
window.WP_USER = user; window.WP_USER = user;
reveal(); reveal();
if (window.WP_USER) { if (window.WP_USER) {
window.wpFlags(); // start the feature-flag fetch; pages await it as needed window.wpFlags(); // start the feature-flag fetch; pages await it as needed
pingUsage();
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {} try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
} }
} }

View File

@@ -756,6 +756,11 @@ async def okta_callback(request: Request, db: Session = Depends(get_db)):
user.failed_attempts = 0 user.failed_attempts = 0
user.locked_until = None user.locked_until = None
user.last_login_at = models.utcnow() user.last_login_at = models.utcnow()
# CR-019: one login event per Okta sign-in, written here rather than
# inferred from session creation elsewhere, so there is exactly one
# source of truth for "did this person sign in" - not one per page load
# afterward (T11.2 covers that separately, as page_open events).
db.add(models.UsageEvent(id=gen_id("uev"), username=user.username, tool="", event="login"))
db.commit() db.commit()
db.refresh(user) db.refresh(user)
@@ -3374,6 +3379,29 @@ def create_feedback(body: CommentIn, user: models.User = Depends(auth.get_curren
return _save_comment(body, db, user) return _save_comment(body, db, user)
# ── CR-019: usage/activity metrics ──────────────────────────────────────────
class UsageIn(BaseModel):
tool: str = ""
project_id: Optional[str] = None
@app.post("/api/usage/ping")
def usage_ping(body: UsageIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""One usage_events row per authenticated page load. Called exactly once,
from auth-guard.js after wp-auth-ready fires (T11.2) - never duplicated
per page's own script, the same lesson S4's per-page nav already taught
this codebase. `user` comes from the session via get_current_user, never
from anything the client claims - identity here is a server-enforced
fact, matching every other write in this file, not a client-reported one."""
tool = (body.tool or "").strip()[:40]
db.add(models.UsageEvent(
id=gen_id("uev"), username=user.username, project_id=body.project_id,
tool=tool, event="page_open",
))
db.commit()
return {"ok": True}
@app.get("/api/comments") @app.get("/api/comments")
def list_comments( def list_comments(
source: Optional[str] = Query(None), source: Optional[str] = Query(None),