geocrop-platform./apps/nextgen/.harness/changelogs/2026-07-09-student-medical-...

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 (/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:

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 schemaserver/src/database/init.js (3 CREATE TABLE blocks + indexes).
  2. Client SQLite WASM schemaclient/src/lib/db.worker.ts (3 CREATE TABLE blocks appended to the tables array in createSchema()).
  3. Client push listclient/src/lib/sync.ts (3 names appended to the tables array in pushLocalChanges()).
  4. Server → client pull whitelistserver/src/controllers/sync.controller.js (3 names appended to the hardcoded tables array in GET /api/sync/pull).
  5. Server → Supabase cloud syncserver/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.jstablesToSync
  • 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.