11 KiB
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 (
/medicalfor student role). - Parent view (
/medicalfor 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 theusers.roleCHECK 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, andis_visible_to_teachersflag.
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:
- Runs the 3 CREATE TABLE IF NOT EXISTS blocks + indexes.
- Renames
userstousers_old, recreates with the extendedusers.roleCHECK (adds'dining_staff'), copies rows, drops the old table.
Existing DBs must run this once manually:
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:
- Server SQLite schema —
server/src/database/init.js(3 CREATE TABLE blocks + indexes). - Client SQLite WASM schema —
client/src/lib/db.worker.ts(3 CREATE TABLE blocks appended to thetablesarray increateSchema()). - Client push list —
client/src/lib/sync.ts(3 names appended to thetablesarray inpushLocalChanges()). - Server → client pull whitelist —
server/src/controllers/sync.controller.js(3 names appended to the hardcodedtablesarray inGET /api/sync/pull). - Server → Supabase cloud sync —
server/src/services/SyncEngine.js(3 names appended totablesToSyncafter the exams block). - 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. Importsapifrom./api(not./auth, which is the existing drift in exams.ts).pages/medical/MedicalViews.tsx— multi-component library exportingSanatoriumPortal,DiningHall,ClassMedicalView,StudentMedicalSelfView,ParentMedicalView. This is the cross-role component pattern from conventions.md.pages/dashboard/NurseDashboard.tsxpages/dashboard/DiningDashboard.tsxpages/student/Medical.tsx(thin wrapper — passes own uid)pages/parent/Medical.tsx(child picker using/api/users/children)pages/teacher/ClassMedicalViewis the export ofMedicalViews.tsx; reached at/medical/class/:classId(route param picked up viauseParams).
App.tsx wiring:
roleRoutesandgetDashboardRouteupdated fornurseanddining_staff.case 'nurse'andcase 'dining_staff'added togetRoutes().- Medical routes added to
school_admin,systems_admin,principal,teacher,student, andparentcases.
Nav.tsx:
NAV_CONFIG.nurseandNAV_CONFIG.dining_staffblocks.ROLE_COLORSentries for both (rose-red for nurse, amber for dining).Usertype extended with'nurse' | 'dining_staff'.- Medical links added to school_admin / systems_admin / principal / teacher / student / parent blocks.
lucide-reacticons added:HeartPulse,Stethoscope,UtensilsCrossed.
Demo accounts (server/src/database/init.js)
nurse@school.com / nurse123dining@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/logsPOST returns{ ...row, warning: '...' }when the medication matches a documented contraindication.
Files (new + modified)
New:
server/src/controllers/medical.controller.jsserver/src/database/migration_medical_records.jsclient/src/store/medical.tsclient/src/pages/medical/MedicalViews.tsxclient/src/pages/dashboard/NurseDashboard.tsxclient/src/pages/dashboard/DiningDashboard.tsxclient/src/pages/student/Medical.tsxclient/src/pages/parent/Medical.tsx
Modified:
server/src/database/init.js— 3 tables + indexes, role CHECK, demo usersserver/src/controllers/sync.controller.js— pull whitelist + ROLE_WEIGHTSserver/src/services/SyncEngine.js—tablesToSyncserver/src/index.js— controller mountclient/src/lib/db.worker.ts— SQLite WASM schemaclient/src/lib/sync.ts— push listclient/src/App.tsx— routes, roleRoutes, getDashboardRoute, importsclient/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/logsendpoint currently doesn't write an audit row.server/src/services/AuditService.jsexists; 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).
- student_medical_profiles (matches the online query in
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-cachefor/api/medical/(dining|class)/*— kitchen-tablet reads.medical-record-cachefor/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.