13 KiB
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 = 0unless 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.jscanonical 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 ifstudent_idis 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 theclubs.controller.js:218pattern.
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_logs—server/src/database/init.js:1150✓postal_dispatch—server/src/database/init.js:1175✓hostel_attendance—server/src/database/init.js:1595✓transport_attendance—server/src/database/init.js:1611✓club_attendance—server/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 (
adminvsschool_adminin different files). I accepted both instaffWritefor the three new attendance controllers; the front-office additions keep the existingschool_admin-onlyadminOnlyplus a newstaffWritethat also acceptsprincipalandteacher(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 missingstudent_idand forces it toreq.user.idfor students; parents get a 403 if they ask for an unlinked student. - The
hostel_attendanceandtransport_attendancetables are populated indirectly: rosters are derived fromroom_assignmentsandtransport_allocationsrespectively. No seed data was added — the verifier should expect emptyrosterand 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(perconventions.md). SetJWT_SECRETin 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.