geocrop-platform./apps/nextgen/.harness/changelogs/2026-06-12-attendance-and-f...

13 KiB
Raw Permalink Blame History

Changelog — 2026-06-12 — Attendance & Front-Office API

Branch: feature/front-office-and-attendance-api Worktree: .worktrees/feature-front-office-and-attendance-api/ Owner: backend-expert Date: 2026-06-12

This change activates two previously-dead front-office tables (phone_call_logs, postal_dispatch) and adds proper controllers for the three extended roll-call tables (hostel_attendance, transport_attendance, club_attendance) that had no HTTP surface. It also adds GET /api/attendance/today to the class-attendance controller.

No schema changes. No new tables. No new SyncEngine entries (all five tables were already in tablesToSync — verified at server/src/services/SyncEngine.js lines 89-99).


Routes added

phone-call-logs (mounted under existing /api/front-office)

Mount: front-officeController at app.use('/api/front-office', ...) in server/src/index.js:79 (unchanged; new routes added to the same router).

Method Path Auth Role guard (writes) Notes
GET /api/front-office/phone-calls?from=&to=&handled_by= yes read — all roles Filters by call_time range and handler.
POST /api/front-office/phone-calls yes staffWrite (school_admin / principal / teacher) Required: caller_name. Sets sync_status='pending'.
PUT /api/front-office/phone-calls/:id yes staffWrite Partial update; sync_status='pending'.
DELETE /api/front-office/phone-calls/:id yes staffWrite Soft delete (is_deleted=1).
PUT /api/front-office/phone-calls/:id/follow-up yes staffWrite Sets follow_up_required=0, optional notes.

caller_type CHECK: parent | student | vendor | government | other. direction CHECK: incoming | outgoing.

postal-dispatch (mounted under existing /api/front-office)

Method Path Auth Role guard (writes) Notes
GET /api/front-office/postal?type=&status= yes read — all roles type: `dispatch
POST /api/front-office/postal yes adminOnly (school_admin) Required: sender, receiver.
PUT /api/front-office/postal/:id yes adminOnly Partial update.
PUT /api/front-office/postal/:id/mark-sent yes adminOnly Sets status='sent'.
PUT /api/front-office/postal/:id/mark-delivered yes adminOnly Sets status='delivered', received_date=NOW().
DELETE /api/front-office/postal/:id yes adminOnly Soft delete.

hostel-attendance (new controller, new mount)

Mount added at server/src/index.js: app.use('/api/hostel-attendance', hostelAttendanceController);

Method Path Auth Role guard (writes) Notes
GET /api/hostel-attendance?hostel_id=&room_id=&date=&from=&to= yes read — scoped Student → own rows; parent → linked students via parent_students; others → all.
GET /api/hostel-attendance/roster?hostel_id=&room_id=&date= yes read — all roles Default date = today. Roster from active room_assignments left-joined with hostel_attendance for the date.
POST /api/hostel-attendance/bulk yes staffWrite Body { hostel_id, room_id?, date, records: [{student_id, status}] }. Single db.transaction. Upsert pattern (SELECT then UPDATE/INSERT) — mirrors clubs.controller.js:218.
GET /api/hostel-attendance/history?student_id=&from=&to= yes read — scoped Per-student history with { summary, records }.

Valid statuses: present | absent | excused.

transport-attendance (new controller, new mount)

Mount added at server/src/index.js: app.use('/api/transport-attendance', transportAttendanceController);

Method Path Auth Role guard (writes) Notes
GET /api/transport-attendance?route_id=&date=&from=&to= yes read — scoped Same scoping rule as hostel-attendance.
GET /api/transport-attendance/roster?route_id=&date= yes read — all roles Roster from transport_allocations (active) joined with attendance for the date.
POST /api/transport-attendance/bulk yes staffWrite Body { route_id, date, records: [{student_id, status}] }. Single db.transaction, upsert.
GET /api/transport-attendance/history?student_id=&from=&to= yes read — scoped Per-student history with summary.

club-attendance (new controller, new mount)

Mount added at server/src/index.js: app.use('/api/club-attendance', clubAttendanceController);

Note: The original clubs.controller.js already exposes a minimal GET /api/clubs/attendance and POST /api/clubs/attendance/bulk — those endpoints are preserved (no deletions). This new controller provides a consistent module URL (/api/club-attendance/...) with the full list / roster / bulk / history shape used by hostel and transport.

Method Path Auth Role guard (writes) Notes
GET /api/club-attendance?club_id=&date=&from=&to= yes read — scoped
GET /api/club-attendance/roster?club_id=&date= yes read — all roles Roster from active club_memberships joined with attendance.
POST /api/club-attendance/bulk yes staffWrite Body { club_id, date, records: [...] }.
GET /api/club-attendance/history?student_id=&from=&to= yes read — scoped

Class attendance — GET /today added

POST /api/attendance/bulk already existed (in attendance.controller.js — line 265 of the previous file, no inline duplicate in index.js). We only added GET /today per the task spec.

Method Path Auth Role guard Notes
GET /api/attendance/today?class_id= yes read — scoped Returns { date, total, records: [...] } for today. Student → own; parent → linked; teacher → own classes/subjects; admin/principal → all.

Files changed

File Change
server/src/controllers/front-office.controller.js + 2 new resource blocks (phone-calls × 5 routes, postal × 6 routes). New staffWrite middleware.
server/src/controllers/hostel-attendance.controller.js NEW — 4 routes, joins hostels/rooms/room_assignments/users.
server/src/controllers/transport-attendance.controller.js NEW — 4 routes, joins routes/transport_allocations/users.
server/src/controllers/club-attendance.controller.js NEW — 4 routes, joins clubs/club_memberships/users.
server/src/controllers/attendance.controller.js + GET /today route. No other changes.
server/src/index.js + 3 require(...) lines and + 3 app.use(...) lines.

Conventions enforced

  • All writes set sync_status = 'pending'. Verified across all new routes.
  • All reads include WHERE … is_deleted = 0 unless explicitly looking at the trash.
  • Every prepared statement uses ? placeholders. No string-concatenated SQL.
  • Auth middleware is inline per-controller (matches the front-office.controller.js canonical pattern).
  • Role checks: writes gated by staffWrite (admin / school_admin / principal / teacher) for the 3 attendance controllers and the phone-calls block. Postal-dispatch writes are tighter (admin / school_admin only) because it's an audit trail.
  • Students see only their own attendance records. Parents see only rows for students linked via parent_students (returns 403 if student_id is requested but not linked).
  • Soft delete: UPDATE … SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP, sync_status = 'pending' WHERE id = ? AND is_deleted = 0. Returns { success: true }.
  • All bulk endpoints wrap multi-write upserts in a single db.transaction(() => { … })(), matching the clubs.controller.js:218 pattern.

Schema sanity check (no schema change required)

All five tables already existed and already had the full SQL contract (uid, sync_status, last_synced_at, is_deleted, created_at, updated_at):

  • phone_call_logsserver/src/database/init.js:1150
  • postal_dispatchserver/src/database/init.js:1175
  • hostel_attendanceserver/src/database/init.js:1595
  • transport_attendanceserver/src/database/init.js:1611
  • club_attendanceserver/src/database/init.js:1577

All five are already in SyncEngine.tablesToSync at server/src/services/SyncEngine.js lines 89-99. No sync-expert handoff required.


Verify (run yourself)

The server was not booted in this session (the previous attempt timed out hanging on a long-lived dev process; the verifier will boot the server on their own). All module-load smoke tests passed:

cd .worktrees/feature-front-office-and-attendance-api/server
npm run db:init          # idempotent — re-runs cleanly

# Module-load smoke test (passes):
node -e "
const m1 = require('./src/controllers/hostel-attendance.controller');
const m2 = require('./src/controllers/transport-attendance.controller');
const m3 = require('./src/controllers/club-attendance.controller');
const m4 = require('./src/controllers/attendance.controller');
const m5 = require('./src/controllers/front-office.controller');
console.log({ hostel: m1.stack.length, transport: m2.stack.length,
              club: m3.stack.length, attendance: m4.stack.length,
              frontOffice: m5.stack.length });
"
# → { hostel: 4, transport: 4, club: 4, attendance: 10, frontOffice: 20 }

Curl recipes for the verifier (boot with cd server && JWT_SECRET=africa-alert-secret-key-2024 PORT=3001 node src/index.js then):

TOKEN=$(curl -s -X POST http://localhost:3001/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@school.com","password":"admin123"}' \
  | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(JSON.parse(d).token))")

# Sanity
curl -s http://localhost:3001/api/dashboard/stats -H "Authorization: Bearer $TOKEN" | head -c 200; echo

# Front-office additions
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/api/front-office/phone-calls -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/api/front-office/postal     -H "Authorization: Bearer $TOKEN"

# Attendance additions
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/api/hostel-attendance   -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/api/hostel-attendance/roster -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/api/transport-attendance -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/api/transport-attendance/roster -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/api/club-attendance -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/api/club-attendance/roster -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/api/attendance/today -H "Authorization: Bearer $TOKEN"

# Regression checks
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/api/users     -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/api/classes   -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/api/attendance -H "Authorization: Bearer $TOKEN"

All endpoints should return 200. The two POST /bulk and two POST /api/front-office/phone-calls|postal require a JSON body and will be covered by the integration tests run by tester / code-reviewer.


Notes for the verifier

  • The repo has inconsistent role naming (admin vs school_admin in different files). I accepted both in staffWrite for the three new attendance controllers; the front-office additions keep the existing school_admin-only adminOnly plus a new staffWrite that also accepts principal and teacher (the front-desk staff).
  • The 4 student-scoped list endpoints in the 3 attendance controllers all return a 200 with the student's own rows (no 403). The history endpoint accepts a missing student_id and forces it to req.user.id for students; parents get a 403 if they ask for an unlinked student.
  • The hostel_attendance and transport_attendance tables are populated indirectly: rosters are derived from room_assignments and transport_allocations respectively. No seed data was added — the verifier should expect empty roster and empty list results until upstream tables are populated.
  • All endpoints are 7-day-JWT authenticated with the dev default secret africa-alert-secret-key-2024 (per conventions.md). Set JWT_SECRET in production.
  • This change touches the auth layer indirectly (inline middleware repeated in each new controller). Per the §13.2 audit recommendation, an Architect review is appropriate for the shared-middleware extraction; out of scope for this PR.