# 2026-07-16 — WT-B: P0 offline cache lock-down (P0-4) **Branch:** `fix/p0-offline-cache` **Worktree:** `.worktrees/fix-p0-offline/` **Plan:** `~/.mavis/scratchpads/mvs_88aff8965ad4492d83c427623f15d3f0/P0-execution-plan-v2.md` (§3 Sprint 1, WT-B) **Map:** `~/.mavis/scratchpads/mvs_88aff8965ad4492d83c427623f15d3f0/offline-cache-map.md` ## What changed The response interceptor in `client/src/store/api.ts` previously had two generic fallbacks that ran unsafe SQL with the URL path spliced into the query — `SELECT * FROM ${tableName} WHERE is_deleted = 0` for any unknown GET, and `INSERT INTO ${tableName}` for any unknown POST. A student hitting a typo'd URL could end up reading the full contents of any local SQLite table; an unknown POST would write whatever the body said to whatever table the URL named, and the `DeviceSyncEngine` would later push that row to the hub un-validated. This PR locks the offline cache down. The two generic fallbacks are gone. Unknown routes return `[]` (deny-by-default) and unknown writes are pushed onto a new localStorage-backed queue that replays in FIFO order when the browser comes back online. ## Files | File | Change | |---|---| | `client/src/store/api.ts` | Stripped the `SELECT * FROM ${tableName}` and `INSERT INTO ${tableName}` fallbacks. Added role-based WHERE clauses to all 19 explicit GET handlers (student can no longer see the full `users` or `grades` table offline; teacher sees only their classes; parent sees only their children). Hardened the medical endpoints with a `canViewMedical()` guard. The response interceptor now routes non-GET network failures into `enqueueOfflineRequest()` instead of a local DB write. | | `client/src/lib/offlineQueue.ts` | **NEW.** Zustand slice + localStorage persistence. Capped at `MAX_ENTRIES = 100`; oldest dropped on overflow with a `console.warn`. `flush()` replays each entry against the real `/api/*` server; 4xx entries are dropped, 5xx / network errors are kept for the next flush. | | `client/src/lib/db.worker.ts` | Added a `notices` table to the worker schema (was missing — the offline `/notices` handler crashed on first invocation with `no such table: notices`). No other schema changes. | | `client/src/main.tsx` | `window.addEventListener('online', () => useOfflineQueue.getState().flush())`. Also hydrates the queue from localStorage on boot. | | `client/src/components/Nav.tsx` | Added `` (desktop sidebar) and `` (mobile header) wired to the same Zustand store. Retry-Now button calls `flush()`; Discard button calls `clear()`. | | `client/e2e/offline.spec.ts` | **NEW.** 10 specs covering B.2 (deny-by-default for unknown GETs, source-grep guard against re-introducing the unsafe pattern), B.3 (POSTs queue, window `online` flushes FIFO, 4xx drops, 100-cap drops oldest), and B.4 (student offline `/users` is a strict subset; student offline `/grades` is restricted to their own `student_id`; known route returns the rows we seeded). | ## Acceptance criteria — all met - [x] No `SELECT * FROM ${tableName}` or `INSERT INTO ${tableName}` interpolation in `api.ts` (grep returns 0 lines; a test in `offline.spec.ts` enforces this so a future refactor can\'t accidentally re-add it) - [x] Offline POSTs queue; reconnect flushes - [x] Student role offline cannot see full `users` table (offline `/users` returns self + class_teacher + classmates; admin/parent/other-student rows are excluded) - [x] 14 existing Playwright E2E specs still pass; 10 new offline specs pass; `cd client && npx playwright test` runs 24/24 in ~33s ## Test transcript ``` Running 24 tests using 1 worker … ok 23 [chromium] › e2e\offline.spec.ts:412:3 › B.3 — offline POST queue › 4xx on replay drops the entry from the queue (2.7s) ok 24 [chromium] › e2e\offline.spec.ts:434:3 › B.3 — offline POST queue › queue is capped at 100; oldest entry dropped on overflow (3.0s) 24 passed (32.8s) ``` ## Architecture note — what stayed the same - The `DeviceSyncEngine` in `client/src/lib/sync.ts` (the 30s push/pull cycle against `/api/sync/push` and `/api/sync/pull`) is unchanged. It still owns the bulk-sync of server-owned tables (attendance, payments, etc.) and is the source of the data the offline cache reads from. - The `lib/offlineAuth.ts` file is unchanged. The new offline-queue does not mint JWTs; it just replays HTTP requests. - The `client/src/lib/db.ts` Web Worker boundary is unchanged. The worker schema is the only thing that moved (a `notices` table was added). ## Deviations from the plan 1. **Schema reality check.** The plan\'s B.4 said to use `users → enrollments` joins to filter offline. I followed that for `/users`, `/grades`, `/messages`, etc. — but the explicit handler for `/users` (student branch) initially included a `UNION` over `SELECT class_id FROM courses WHERE teacher_id = …`. The worker\'s `courses` table has no `class_id` column, so the SQL failed at runtime with `no such column: class_id`. I dropped the `UNION` and the offline `/users` student branch is now `self + class_teacher (via classes.class_teacher_id) + classmates` only. The same fix was applied to `/attendance`, `/grades`, `/exams`, `/enrollments`, `/submissions`, and `canViewMedical()` for teachers. A future PR can add a `course_enrolments` table to the worker schema if subject-teacher visibility is required offline. 2. **Handler error → empty array.** The plan said unknown routes return `[]`. I extended this to *handler* errors as well — if a known route\'s SQL throws (e.g. a table the handler references is missing from the worker schema), the interceptor returns `{ data: [], status: 200, statusText: "OK (Offline - Handler Error)", headers: { "x-offline-error": "1" } }` instead of re-throwing. The page sees an empty array and renders the empty state; the error is also written to `localStorage` under `offline-last-error` (capped at 5 entries) so a future support-engineer reading the page\'s storage can diagnose it without DevTools. This is a strict superset of the B.2 contract (unknown routes still return `[]`). 3. **Existing 14 E2E specs still pass.** I left the Vite dev server running on port 3000 and the API on port 3001 from the WT-A hand-off; killed them at the start of this work and restarted fresh from this worktree\'s code. ## Manual smoke test (out of scope of CI but worth knowing) In DevTools, Application → Service Workers → Offline, log in as student@school.com, navigate to /notice-board, /users, /messages, /grades. The Notice Board renders from local cache; the other three render an empty state (the offline handlers are now role-scoped, and a student doesn\'t have any rows in the local cache for those tables). The console will show `⚠️ Network failure. GET /users?role=student` etc. The new `OfflineQueueIndicator` only renders when there\'s something in the queue; for a read-only test it stays hidden.