# Training Programs API — Trainer

Trainer self-service view of assigned training programs: browsing, attaching study material, joining/marking live sessions, the trainees roster, and the عداد التدريب training timer. Editing a program's own fields (name, pricing, schedule) stays admin-only — see [training-programs-admin.md](./training-programs-admin.md).

- **Base path:** `{{base_url}}/trainer` (e.g. `http://localhost:8000/api/v1/trainer`)
- **Auth:** `Authorization: Bearer {{trainer_token}}`, plus `user_type:trainer` middleware
- **Source:** `app/Http/Controllers/Api/V1/Trainer/{TrainingProgram,ProgramTrainingTimer}Controller.php`, `app/Services/Trainer/TrainingProgramService.php`, `app/Services/Education/ProgramTrainingTimerService.php`

## Conventions

Same envelope conventions as [books-dashboard.md](./books-dashboard.md#conventions). Headers: `Accept: application/json`, `Content-Type: application/json`, `Accept-Language: en|ar`.

**Two very different ways a program you don't own gets rejected — this is the single most important thing to know before integrating this file.**

| Endpoint group | Not your program looks like | Why |
|---|---|---|
| `index`, `show`, `materials.store`, `sessions.join`, `sessions.attendance.index`, `sessions.attendance`, `trainees` (all via `TrainingProgramService`) | **404** `{"status":"fail","message":"Data not found","data":null}` | The service's `scopedQuery()` filters to `trainer_id = <caller>` *before* resolving anything — another trainer's program simply isn't in the query, so it looks identical to a program that doesn't exist at all |
| `timer.show/start/stop/reset` (via `ResolvesTrainer::assertOwnsProgram()`) | **403** `{"status":"error","message":"This training program is not assigned to you.","data":{"error_code":"PERMISSION_DENIED"}}` | The timer controller resolves the program unscoped (plain route-model binding) and *then* explicitly checks ownership, the same pattern courses use everywhere |

**`GET /trainer/programs` runs through the real index pipeline** (unlike its course-domain counterpart, `GET /trainer/courses`, which is a fully custom query with no params at all) — see its section below for the exact filters.

## Error codes used in this domain

| HTTP | error_code | Thrown when |
|---|---|---|
| 403 | `PERMISSION_DENIED` | Caller has no trainer profile; training-timer actions on a program not assigned to this trainer |
| 404 | *(fail, `Data not found`)* | Program/session doesn't resolve **or** isn't assigned to this trainer (see table above) — the two cases are indistinguishable in the response |
| 422 | `BUSINESS_RULE_VIOLATED` | Training timer already running (`start`) or not running (`stop`) |
| 422 | *(fail, no code)* | `joinSession` on a non-`online` session; `sessionAttendance`/`markAttendance` on a non-`onsite` session, or naming a student who isn't enrolled |
| 422 | *(fail, standard validation)* | `AttachMaterialRequest`, `MarkAttendanceRequest` |

---

## Cycle

| # | When | Method & Endpoint | Notes |
|---|---|---|---|
| 1 | My programs list | `GET /trainer/programs` | Filter/search/sort per the table below |
| 2 | Open one | `GET /trainer/programs/{program}` | Full detail with sessions & materials |
| 3 | Attach a study material | `POST /trainer/programs/{program}/materials` | Optionally scoped to one session |
| 4a | Online session starts — join to open it for trainees | `POST /trainer/programs/{program}/sessions/{session}/join` | Records `trainer_joined_at`; trainees then self-join on their side |
| 4b | Onsite session — see the roster with attendance status | `GET /trainer/programs/{program}/sessions/{session}/attendance` | Every enrolled student + `attended` flag; not paginated |
| 4c | Onsite session — mark who showed up | `POST /trainer/programs/{program}/sessions/{session}/attendance` | Batch `{student_ids[], attended}`; all-or-nothing if anyone isn't enrolled |
| 5 | Deliver training hours (either mode) | `POST /trainer/programs/{program}/timer/start` → `.../stop` | Same mechanics as [courses-trainer.md](./courses-trainer.md#training-timer--عداد-التدريب-both-course-types) |
| 6 | Check who's enrolled and how they're doing | `GET /trainer/programs/{program}/trainees` | Per-trainee attendance ratio |
| 7 | *(marking attendance can auto-issue a certificate — see [training-programs-admin.md § Certificates](./training-programs-admin.md#certificates))* | — | Silent — nothing in the trainer-facing response announces it |

---

## Programs

`TrainingProgram::$searchableFields` — same list as admin (see [training-programs-admin.md](./training-programs-admin.md#programs)) — scoped here to `trainer_id = <caller>` and always carrying `sessions_count`/`participants_count` (`withCount`).

### `GET /trainer/programs` — My assigned programs

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `page`, `per_page`, `paginate=false` | — | — | max 100 |
| `search` | string | `financial` | `name`/`description` (translated, current locale) |
| `sort[created_at]` | `asc`\|`desc` | `desc` | or `sort[name]`/`sort[description]` (translation-aware), `sort[slug]`, `sort[attendance_mode]`, `sort[content_status]`, `sort[is_visible]` |
| `filters[attendance_mode]` | enum | `hybrid` | `online`\|`onsite`\|`hybrid` |
| `filters[content_status]` | enum | `approved` | — |
| `filters[is_visible]` | `1`\|`0` | `1` | — |
| ~~`filters[category_id]`~~ / ~~`filters[trainer_id]`~~ | — | — | not usable with uuids, same caveat as admin (and `trainer_id` is redundant here anyway — already fixed to the caller) |
| ~~`filters[name]`~~ / ~~`filters[description]`~~ | — | — | **500 — translated, no real column.** Use `search=` |

**200 OK** (`TrainingProgramResource`):
```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": [
    { "id": "p1a2...-uuid", "name": "Financial Leadership Program", "category": {"id": "cat-uuid", "name": "Finance"}, "attendance_mode": "hybrid", "start_date": "2026-11-02", "duration_hours": 30, "sessions_count": 8, "participants_count": 18, "content_status": "approved", "has_certificate": true, "is_visible": true }
  ]
}
```

### `GET /trainer/programs/{program}` — Detail

**Path variables:** `program`. Adds (`TrainingProgramDetailsResource`): `description`, `goals`, `content`, `demo_video`, `training_timer_max_minutes`, `is_live` (`true` if *any* loaded session is currently live), `sessions[]`, `materials[]`.

**404** if not assigned (see the table in Conventions): `{ "status": "fail", "message": "Data not found", "data": null }`.

### `GET /trainer/programs/{program}/trainees` — Enrolled trainees, paginated

**Path variables:** `program`. **Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `page` | integer | `1` | Laravel's implicit paginator param |
| `per_page` | integer | `20` | clamped `[1, 100]`, default 15 |

No `search`/`filters`/`sort` at all on this one (unlike the courses equivalent, `GET /trainer/courses/{course}/students`, which does support `search`/`status`). Always newest `enrolled_at` first. Paginated response wrapped `{"trainees": [...]}`.

**200 OK** (`TraineeResource`):
```json
{ "id": "u-uuid", "name": "Sara Ahmed", "phone": "1001234567", "status": "active", "enrolled_at": "2026-09-01 10:00:00", "attendance": { "attended": 5, "total": 8 } }
```

---

## Materials & sessions

### `POST /trainer/programs/{program}/materials` — Attach a study material

**Path variables:** `program`. **Body:** `{ "file": "{{media_hash}}", "material_type": "pdf", "session_id": "{{session_id}}", "en": {"title": "Week 1 handout"}, "ar": {"title": "..."} }`

| Field | Type | Required | Rules |
|---|---|---|---|
| `file` | media hash | yes | `application`\|`video` mime group — **note: no `image` group here**, unlike the course-materials endpoint which accepts `application`\|`image` |
| `material_type` | string | no | `nullable, max:50` — **free-form string**, not the closed `CourseMaterialType` enum courses use, and there's no cross-check against the uploaded file's actual mime type |
| `session_id` | uuid | no | `nullable` — scopes the material to one of this program's sessions; omitted keeps it program-level |
| `{en,ar}.title` | string | no | max 200 |

**201 Created** (`MaterialResource`): `{ "id": "mat-uuid", "title": "Week 1 handout", "material_type": "pdf", "file": "https://cdn.example.com/file.pdf", "session_id": "sess-uuid", "created_at": "2026-09-14T07:54:11+00:00" }`

### `POST /trainer/programs/{program}/sessions/{session}/join` — Open an online session — **Trainer**

**Path variables:** `program`, `session`. No body. Sets `trainer_joined_at = now()` — this is what unlocks the meeting link in the *trainee's* session resource (`meeting_link` there is only revealed while `is_enrolled && is_live`, independent of whether the trainer has joined — trainer-join and trainee-visibility are two separate mechanics that happen to usually coincide).

**422 — wrong mode:**
```json
{ "status": "fail", "message": "Only online sessions can be joined.", "data": null }
```
**200 OK** → `SessionResource`.

### `GET /trainer/programs/{program}/sessions/{session}/attendance` — Onsite roster with attendance status — **Trainer**

**Path variables:** `program`, `session`. No query string, no body. Requires the `manual_attendance` trainer capability (**403** `PERMISSION_DENIED` without it). Returns **every** student enrolled in the program — not only those already marked — sorted by name, **not paginated**. A student with no attendance row yet comes back as `attended: false`, `marked_by: null`. Unlike marking, this works before the session has started.

**422 — wrong mode:**
```json
{ "status": "fail", "message": "Only onsite sessions have trainer-marked attendance — online sessions are self-service via join.", "data": null }
```
**200 OK** → `SessionStudentResource[]`:
```json
[
  { "id": "u-uuid", "name": "Sara Ahmed", "phone": "1001234567", "attended": true,  "joined_at": "2026-11-02 14:05:00", "marked_by": "trainer" },
  { "id": "u2-uuid", "name": "Omar Ali",  "phone": "1007654321", "attended": false, "joined_at": null,                  "marked_by": null }
]
```
`id` is the student's user UUID — feed it straight into `student_ids` of the `POST` below. Full integration walkthrough: [program-onsite-attendance-frontend-guide.md](./program-onsite-attendance-frontend-guide.md).

### `POST /trainer/programs/{program}/sessions/{session}/attendance` — Mark onsite attendance — **Trainer**

**Path variables:** `program`, `session`. **Body:** `{ "student_ids": ["{{user_id_1}}", "{{user_id_2}}"], "attended": true }`

| Field | Type | Required | Rules |
|---|---|---|---|
| `student_ids` | array | yes | `min:1` |
| `student_ids.*` | uuid | yes | `exists:users,uuid` |
| `attended` | boolean | no | `sometimes` — default `true` when omitted; pass `false` to correct a mistaken mark |

Upserts one `ClassSessionAttendance` row per listed student (`marked_by: "trainer"`, `joined_at: now()` if `attended`, else `null`). **All-or-nothing**: if any listed student isn't enrolled in the program, **nothing** is marked and the whole call fails.

**422 — wrong mode:**
```json
{ "status": "fail", "message": "Only onsite sessions have trainer-marked attendance — online sessions are self-service via join.", "data": null }
```
**422 — someone isn't enrolled:**
```json
{ "status": "fail", "message": "One or more students are not enrolled in this program.", "data": null }
```
**200 OK** → `AttendanceResource[]`, one row per listed student:
```json
[{ "session_id": "sess-uuid", "student": {"id": "u-uuid", "name": "Sara Ahmed"}, "attended": true, "joined_at": "2026-11-02 10:05:00", "marked_by": "trainer" }]
```
Marking someone present here also silently re-checks certificate auto-issuance eligibility for each of them (no-op for programs without `certificate_attendance_threshold` configured).

---

## Training timer — عداد التدريب

Identical mechanics to [courses-trainer.md § Training timer](./courses-trainer.md#training-timer--عداد-التدريب-both-course-types) — same state shape, same error messages with "program" substituted for "course", same auto-close sweep. The one behavioral difference worth calling out is the **ownership-check shape** noted at the top of this file: unlike every other trainer-program endpoint, a not-your-program timer call is a typed **403**, not a 404.

### `GET /trainer/programs/{program}/timer` — Current state

**Path variables:** `program`. **200 OK:** `{ "is_running": false, "started_at": null, "max_minutes": 1800, "cumulative_seconds": 10800, "cumulative_hours": 3.0 }`

### `POST /trainer/programs/{program}/timer/start` — **201** on success

**422** if already running: `{ "status": "error", "message": "A training timer is already running for this program.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }`

### `POST /trainer/programs/{program}/timer/stop`

**422** if nothing is running: `{ "status": "error", "message": "No training timer is currently running for this program.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }`

### `POST /trainer/programs/{program}/timer/reset` — Discards the running interval, credits no time — never 422s
