# 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.
