# Attendance Store Fixes — Courses, Programs & Consultations

Backend review and fixes for how attendance is **stored** (join + manual marking)
for live courses, training programs and consultations. All four fixes below are
**applied on the backend**. Two of them (Part 2) add new `422` responses on
endpoints that used to succeed, so the frontend **must** be ready for them.

> **Contract status:** no endpoint, route, request field, response key or
> success status code was added, removed or renamed. The only observable
> differences are (a) values that are now more correct (Part 1) and (b) new
> `422` error responses in specific situations (Part 2).

## Summary

| # | Area | Change | Frontend impact |
|---|------|--------|-----------------|
| 1 | Course | Trainer-marked attendance now updates progress + certificate | Data refresh only |
| 3 | Program | Re-join keeps the first `joined_at` | Value only |
| 2 | Consultation | Join only allowed inside the session window | **New 422 — action required** |
| 4 | Course / Program / Admin | Manual attendance blocked until the session has started | **New 422 — action required** |

---

## Part 1 — No frontend code change required

### 1.1 Course: trainer-marked attendance now updates progress and certificate

| | |
|---|---|
| Endpoint | `POST /api/v1/trainer/courses/{course}/sessions/{session}/attendance` |
| Request | unchanged — `student_ids: uuid[]` (required), `attended: boolean` (optional, default `true`) |
| Response | unchanged — list of `{ session_id, student: {id, name}, attended, joined_at, marked_by }` |

**Fixed:** marking onsite attendance only wrote attendance rows. The learner's
enrollment (`progress_percent`, `status`, `completed_at`) and certificate were
recomputed only when a learner *joined an online session*, so an onsite-only
course never advanced.

**Now:** after the attendance is saved, each listed student's enrollment is
recalculated and the certificate is issued if they are eligible — exactly what
the learner's own join already did. Only students in the request are refreshed.

| Screen | Endpoint | What may change |
|---|---|---|
| Learner "My Courses" | `GET /api/v1/user/my-courses` | `progress` (`completed`/`total`/`percent`), `status` (`active` → `completed`), `completed_at` |
| Learner certificates | `GET /api/v1/user/certificates` | a certificate can appear right after the trainer marks the student present |
| Trainer attendance screen | same endpoint as above | nothing |

**Guidance**
- Trainer app: no change.
- Learner app: don't cache `my-courses` / `certificates` aggressively. These
  values can now change without any learner action (the trainer causes them).
  Refetch on screen focus / pull-to-refresh.
- Marking `attended: false` never issues a certificate and never lowers or
  revokes progress that was already completed.

### 1.2 Program: re-joining a live session keeps the first `joined_at`

| | |
|---|---|
| Endpoint | `POST /api/v1/user/programs/{program}/sessions/{session}/join` |
| Request / response | unchanged — `data.meeting_link` is still returned, HTTP 200 |

**Fixed:** every re-join overwrote `joined_at` with the new time. Courses
already kept the first arrival; programs now match.

**Now:** `joined_at` is set on the first join and never moved forward. Re-joining
is still allowed under the same time-window rules.

**Guidance:** none. A "Joined at …" label now shows the first arrival.

Edge case: if a trainer/admin had marked the row, a later self-join no longer
resets `marked_by` to `system` (same as courses). Only reachable if a session's
mode was changed from onsite to online after marking.

---

## Part 2 — New `422` responses (frontend action required)

Error responses use the same envelope as the other 422s on these endpoints
(e.g. "Only online sessions can be joined", "This consultation is no longer
upcoming") — read `message` for display.

### 2.1 Consultation join is limited to the session window

| | |
|---|---|
| Endpoints | `POST /api/v1/user/consultation-bookings/{booking}/join` and `POST /api/v1/trainer/consultations/{booking}/join` |
| Before | succeeded any time while `status === "upcoming"`, even days early |
| Now | allowed only from `scheduled_at − 10 min` until `scheduled_at + duration_minutes` (lead = `EDUCATION_JOIN_LEAD_MINUTES`, default **10**; the end is exclusive) |
| Success response | unchanged |

Rejections (HTTP **422**):

| Situation | `message` |
|---|---|
| Before the window opens | `This consultation is not open for joining yet.` |
| After `scheduled_at + duration_minutes` | `This consultation has already ended.` |
| Status not `upcoming` (existing rule) | `This consultation is no longer upcoming.` |

A rejected join sets **no** attendance flag and writes **no** `consultation_join_logs` row.

Why: an early join used to set `user_attended = true`, and the scheduled
`consultations:finalize-status` job then marked the booking `completed` because
both parties were flagged as attended.

**Frontend must do**
1. Enable the "Join Session" button **only** inside the window, computed from
   `scheduled_at` and `duration_minutes` already on the booking resource
   (`opens = scheduled_at − 10 min`, `closes = scheduled_at + duration_minutes`).
   Disable/hide it before and after. Prefer exposing the lead as a constant or
   reading it from bootstrap config if available; the default is 10 minutes.
2. Never call `/join` early just to fetch the booking or the meeting link — use
   `GET …/consultation-bookings/{booking}` (user) or the trainer show endpoint.
3. On a 422 from `/join`, display the returned `message` and refetch the booking.
4. Use the server clock as the source of truth: if the device clock is skewed
   the button may enable slightly early/late and the server will decide.

### 2.2 Manual attendance cannot be marked before the session starts

| | |
|---|---|
| Endpoints | `POST /api/v1/trainer/courses/{course}/sessions/{session}/attendance`<br>`POST /api/v1/trainer/programs/{program}/sessions/{session}/attendance`<br>`POST /api/v1/admin/programs/{program}/sessions/{session}/attendance` |
| Before | accepted for any onsite session, including future and unscheduled ones |
| Now | rejected with HTTP **422** until the session's start time has passed |
| Success response | unchanged |

| Situation | `message` |
|---|---|
| Session not started yet, or unscheduled (no date/time) | `Attendance can only be recorded once the session has started.` |

Nothing is written when this happens (the whole request is rejected).
Existing rules still apply: onsite sessions only and the trainer's
manual-attendance capability are checked before this guard; the "all students
enrolled" check runs after it.

How "started" is evaluated:
- **Course sessions** — `session_date` + `start_time` interpreted in the business timezone (`education.timezone`, default `Asia/Riyadh`).
- **Program sessions** (trainer and admin) — interpreted in the request's country timezone from the `X-Country` header (`EG` Africa/Cairo, `SA` Asia/Riyadh, `AE` Asia/Dubai; default `EG`), the same rule program joins already use. **Send the same `X-Country` header you send elsewhere**, otherwise the default (Cairo) is applied.
- Only the start matters — marking after the session has *ended* is still allowed.

**Frontend must do**
1. Make the attendance-marking UI (trainer and admin) actionable only for
   sessions that have started (`session_date` + `start_time` ≤ now, in the zone above).
2. On a 422, show `message` and keep the form state so the user can retry.

---

## Tests

New / updated tests (all passing):

- `CourseSessionJoinTest` — marking updates progress and issues certificate; marking absent does not; marking before start / unscheduled → 422.
- `TrainingProgramCrudTest` (user) — re-join keeps first `joined_at`.
- `TrainingProgramCrudTest` (trainer) — marking before start → 422.
- `ProgramSessionCrudTest` (admin) — marking before start → 422.
- `MyConsultationsTest` — join too early → 422, join inside lead window → 200, join after end → 422.
- `TrainerConsultationsTest` — trainer join early / after end → 422, no flag, no log row.
- Existing tests whose fixtures relied on the old behavior (consultations booked 2 days ahead, sessions on a fixed `2026-10-05`) now use in-window / past times.

Full suite: 1236 passed, 6 failed. The 6 failures also fail on the untouched
tree and are unrelated: `DashboardTest` (top sellers), `Admin/ProgramCrudTest`
(2× `QueryException`), `Books/CheckoutTest`, `Client/GlobalSearchTest`,
`User/TrainingProgramCrudTest` (program details location).

## Still open (backend-only, not changed)

- Program trainer join does not check that the session is scheduled (course trainer join does).
- `trainer_joined_at` is overwritten on each trainer re-join (course and program).
- Program learner join uses the request timezone and no lead window; course join uses `education.timezone` plus a lead window.
- Admin can mark program attendance, but there is no admin endpoint for course attendance (would be a new route).

---

## Checklist for the frontend agent

- [ ] Learner `my-courses` / `certificates` screens refetch rather than trust a stale cache (1.1).
- [ ] Consultation "Join Session" button is enabled only from `scheduled_at − 10 min` to `scheduled_at + duration_minutes` (2.1).
- [ ] `/join` is never used to prefetch the booking or meeting link (2.1).
- [ ] 422 from consultation `/join` shows `message` and refetches the booking (2.1).
- [ ] Attendance-marking UI (trainer + admin) is only available once the session has started (2.2).
- [ ] Program attendance requests carry the correct `X-Country` header (2.2).
- [ ] 422 from `/attendance` shows `message` and preserves form state (2.2).
