# 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.
