# 2026-07-09 — Student Medical Records Branch: `feature/student-medical-records` (worktree at `.worktrees/student-medical/`) ## Scope Adds a Student Medical Information System covering: - **Sanatorium portal** for the school doctor / nurse (`/sanatorium`): full read+write of profile, dispensation log, illness/injury history. - **Dining-hall roster** for the canteen operator (`/dining-hall`): slim DTO showing only allergens + dietary restrictions + emergency instructions. - **Class roster flags** for teachers (`/medical/class/:classId`): emergency-only visibility — `allergies + chronic_conditions + active meds (30d) + open history`. - **Student self view** (`/medical` for student role). - **Parent view** (`/medical` for parent role) with direct edit (per user decision). Privacy is enforced both at the controller (RBAC stripping) and at the schema (per-row `is_visible_to_teachers` flag on `student_medical_history`). ## New roles - `nurse` — was already in the `users.role` CHECK but had no UI; now has a Sanatorium Portal + a dashboard at `/dashboard/nurse`. - `dining_staff` — new. Adds a dining-hall dashboard at `/dashboard/dining`. ## Data model (3 new tables, server/src/database/init.js) - `student_medical_profiles` — one row per student (`uid`, blood group, allergies, dietary_restrictions, contraindications, chronic_conditions, emergency_instructions, updated_by, plus the 5 sync columns). - `student_medication_logs` — school-administered medications (`student_id`, `medication_name`, `dosage`, `administered_at`, `administered_by`, etc.). - `student_medical_history` — illness / injury / hospitalization events with severity, dates, doctor_notes, and `is_visible_to_teachers` flag. Indexes for `student_id`, `sync_status`, `administered_at`, and `is_visible_to_teachers` are added in the same file. ## Standalone migration (server/src/database/migration_medical_records.js) Idempotent. On existing DBs: 1. Runs the 3 CREATE TABLE IF NOT EXISTS blocks + indexes. 2. Renames `users` to `users_old`, recreates with the extended `users.role` CHECK (adds `'dining_staff'`), copies rows, drops the old table. Existing DBs must run this once manually: ```bash node server/src/database/migration_medical_records.js ``` Fresh DBs get everything via `npm run db:init`. ## API (server/src/controllers/medical.controller.js) Mounted at `app.use('/api/medical', medicalController)`. | Method | Path | Roles | Notes | |---|---|---|---| | `GET` | `/api/medical/profiles/:student_uid` | nurse / school_admin / systems_admin / principal / parent (own) / student (self) | RBAC-stripped fields | | `POST` | `/api/medical/profiles` | nurse + admins + parent (own) | | | `PUT` | `/api/medical/profiles/:uid` | nurse + admins + parent (own) | COALESCE-style partial | | `GET` | `/api/medical/logs/:student_uid` | nurse + admins + parent (own) + student (self) | | | `POST` | `/api/medical/logs` | nurse + admins | **Returns `warning` field if the medication matches a documented contraindication.** | | `GET` | `/api/medical/history/:student_uid` | nurse + admins + parent (own) + student (self) + **teacher (filtered: `is_visible_to_teachers = 1`, `doctor_notes` stripped)** | | | `POST` | `/api/medical/history` | nurse + admins | | | `GET` | `/api/medical/dining/allergies` | dining_staff + nurse + school_admin + systems_admin + principal | slim DTO, grouped by class | | `GET` | `/api/medical/logs/count-recent` | nurse + admins + parent + student | dashboard counter (last 30d); declared BEFORE `/logs/:student_uid` to avoid route shadowing | | `GET` | `/api/medical/history/count-recent` | nurse + admins + parent + student | dashboard counter (open cases) | | `GET` | `/api/medical/class/:classId/flags` | teacher + staff | slim DTO per class | ## Sync wiring (6 layers, all updated) The medical tables are registered everywhere they need to be: 1. **Server SQLite schema** — `server/src/database/init.js` (3 CREATE TABLE blocks + indexes). 2. **Client SQLite WASM schema** — `client/src/lib/db.worker.ts` (3 CREATE TABLE blocks appended to the `tables` array in `createSchema()`). 3. **Client push list** — `client/src/lib/sync.ts` (3 names appended to the `tables` array in `pushLocalChanges()`). 4. **Server → client pull whitelist** — `server/src/controllers/sync.controller.js` (3 names appended to the hardcoded `tables` array in `GET /api/sync/pull`). 5. **Server → Supabase cloud sync** — `server/src/services/SyncEngine.js` (3 names appended to `tablesToSync` after the exams block). 6. **Server `/api/sync/push`** — generic by design (no change needed; `INSERT INTO ${tableName}` is dynamic). Plus `ROLE_WEIGHTS` in `sync.controller.js` extended with `nurse: 95` and `dining_staff: 60` so medical conflict resolution has the right role authority. ## Frontend (client/src/) - `store/medical.ts` — single Zustand slice. **Imports `api` from `./api`** (not `./auth`, which is the existing drift in exams.ts). - `pages/medical/MedicalViews.tsx` — multi-component library exporting `SanatoriumPortal`, `DiningHall`, `ClassMedicalView`, `StudentMedicalSelfView`, `ParentMedicalView`. This is the cross-role component pattern from conventions.md. - `pages/dashboard/NurseDashboard.tsx` - `pages/dashboard/DiningDashboard.tsx` - `pages/student/Medical.tsx` (thin wrapper — passes own uid) - `pages/parent/Medical.tsx` (child picker using `/api/users/children`) - `pages/teacher/ClassMedicalView` is the export of `MedicalViews.tsx`; reached at `/medical/class/:classId` (route param picked up via `useParams`). App.tsx wiring: - `roleRoutes` and `getDashboardRoute` updated for `nurse` and `dining_staff`. - `case 'nurse'` and `case 'dining_staff'` added to `getRoutes()`. - Medical routes added to `school_admin`, `systems_admin`, `principal`, `teacher`, `student`, and `parent` cases. Nav.tsx: - `NAV_CONFIG.nurse` and `NAV_CONFIG.dining_staff` blocks. - `ROLE_COLORS` entries for both (rose-red for nurse, amber for dining). - `User` type extended with `'nurse' | 'dining_staff'`. - Medical links added to school_admin / systems_admin / principal / teacher / student / parent blocks. - `lucide-react` icons added: `HeartPulse`, `Stethoscope`, `UtensilsCrossed`. ## Demo accounts (server/src/database/init.js) - `nurse@school.com / nurse123` - `dining@school.com / dining123` ## Verification `npm run db:init` runs cleanly. A 13-test HTTP smoke test (`/api/medical/*`) was run against a live server with JWTs for nurse / dining_staff / parent / teacher / student roles. All passed including: - RBAC: teacher GET profile → 403; dining_staff POST → 403; no token → 401. - Parent write to own child works; foreign child would 403. - Doctor-note field stripped from teacher views of `/medical/history/:uid`. - `/api/medical/logs` POST returns `{ ...row, warning: '...' }` when the medication matches a documented contraindication. ## Files (new + modified) **New:** - `server/src/controllers/medical.controller.js` - `server/src/database/migration_medical_records.js` - `client/src/store/medical.ts` - `client/src/pages/medical/MedicalViews.tsx` - `client/src/pages/dashboard/NurseDashboard.tsx` - `client/src/pages/dashboard/DiningDashboard.tsx` - `client/src/pages/student/Medical.tsx` - `client/src/pages/parent/Medical.tsx` **Modified:** - `server/src/database/init.js` — 3 tables + indexes, role CHECK, demo users - `server/src/controllers/sync.controller.js` — pull whitelist + ROLE_WEIGHTS - `server/src/services/SyncEngine.js` — `tablesToSync` - `server/src/index.js` — controller mount - `client/src/lib/db.worker.ts` — SQLite WASM schema - `client/src/lib/sync.ts` — push list - `client/src/App.tsx` — routes, roleRoutes, getDashboardRoute, imports - `client/src/components/Nav.tsx` — NAV_CONFIG + ROLE_COLORS + icon imports + User type ## Routes by role | Route | Roles | |---|---| | `/dashboard/nurse` | `nurse` | | `/dashboard/dining` | `dining_staff` | | `/sanatorium` | `nurse`, `school_admin`, `systems_admin`, `principal` | | `/dining-hall` | `dining_staff`, `nurse`, `school_admin`, `systems_admin`, `principal` | | `/medical/class/:classId` | `teacher` | | `/medical` | `student`, `parent` | ## Deferred to a future sprint (not blockers) - **AuditService binding on medication log writes.** The `POST /api/medical/logs` endpoint currently doesn't write an audit row. `server/src/services/AuditService.js` exists; wiring it for med-log writes is a one-screen change. The exams controller *also* doesn't do this — so we'd be setting a precedent rather than fixing a defect. Worth doing as a standalone refactor across both controllers. --- ## Self-review follow-ups (all DONE in `fix/medical-offline-pwa`) See the "Follow-up patch" section below. --- ## Follow-up patch: fix/medical-offline-pwa Three issues identified during self-review, all addressed in a follow-up branch off `dev`: ### 1. `client/src/store/api.ts` — explicit offline handlers The generic fallback at the bottom of `handleOfflineRequest()` uses the URL path segments as the table name, so multi-segment medical endpoints like `/medical/dining/allergies` and `/medical/profiles/:uid` returned `[]` when the network was down — defeating the kitchen-tablet offline-first promise. Added 4 explicit handlers alongside the existing `/attendance`, `/messages` etc. handlers: - `GET /medical/dining/allergies` — JOIN across users + enrollments + classes + student_medical_profiles (matches the online query in `medical.controller.js`). - `GET /medical/profiles/:student_uid` — resolves uid or numeric id, returns the matching profile. - `GET /medical/logs/:student_uid` — returns logs for the student, ordered by administered_at DESC. - `GET /medical/history/:student_uid` — returns history for the student, ordered by onset_date DESC, created_at DESC. Edge cases (nonexistent uid, no rows) all return `[]`, matching the controller's 200-with-null behaviour. ### 2. `client/vite.config.ts` — PWA runtime caching `vite.config.ts` only registered Google Fonts in `runtimeCaching`. The medical endpoints were not in any Workbox strategy, so even when offline the service worker would have done nothing useful for the dining-hall roster. Added two `NetworkFirst` rules: - `medical-roster-cache` for `/api/medical/(dining|class)/*` — kitchen-tablet reads. - `medical-record-cache` for `/api/medical/(profiles|logs|history)/*` — nurse reads. Both have a 5s `networkTimeoutSeconds` so the cache kicks in when the hub is slow, and `api.ts`'s response interceptor still routes true network errors to the local SQLite WASM DB. ### 3. `server/src/database/migration_medical_records.js` — demo seed Existing prod DBs running the migration get the schema right but no demo dining-staff user — the seed only runs via `init.js` on fresh DBs. Added an idempotent seed (email-check based, so re-runs are a no-op) at the end of the migration transaction, regardless of which branch (`users.role` extended or already accepted) executed.