230 lines
13 KiB
Markdown
230 lines
13 KiB
Markdown
# 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 | receive`. `status`: `pending | sent | delivered | returned`. |
|
||
| 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_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:
|
||
|
||
```bash
|
||
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):
|
||
|
||
```bash
|
||
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.
|