# Courses API — Trainer

Trainer-facing curriculum authoring (video lessons, quizzes & their questions, attachments), live-session visibility, students roster, submit-for-review, and the عداد التدريب training timer.

- **Base path:** `{{base_url}}/trainer` (e.g. `http://localhost:8000/api/v1/trainer`)
- **Auth:** `Authorization: Bearer {{trainer_token}}` on every endpoint (`auth:api, active` + trainer-scoped ownership checks below)
- **Source:** `app/Http/Controllers/Api/V1/Trainer/{TrainerCourse,VideoLesson,QuizLesson,QuizQuestion,CourseMaterial,TrainerSession,TrainingTimer}Controller.php`, `app/Services/Education/{CurriculumService,CourseWorkflowService,TrainingTimerService}.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`. Media fields (`video`, `file`) take a `{{media_hash}}` from `POST {{media_base_url}}/media`.

**None of the list endpoints in this document run through the shared index pipeline** (the `page`/`search`/`sort`/`filters[...]` mechanics documented in [books-dashboard.md](./books-dashboard.md#how-list-endpoints-work-index-pipeline)) — they are all plain, hand-written Eloquent queries in the controllers. Concretely:

| Endpoint | Supports | Does **not** support |
|---|---|---|
| `GET /trainer/courses` | — | pagination, search, sort, filters — always the trainer's full list, newest first |
| `GET /trainer/courses/{course}/lessons` | — | same as admin's curriculum list — full, unpaginated, `sort_order` |
| `GET /trainer/lessons/{lesson}/questions` | — | full, unpaginated, `sort_order` |
| `GET /trainer/courses/{course}/materials` | — | full, unpaginated, newest first — no `lesson_id` filter even though materials can be scoped to a lesson |
| `GET /trainer/courses/{course}/sessions` | — | full, unpaginated, `sort_order` |
| `GET /trainer/courses/{course}/students` | `page` (Laravel's implicit param), `per_page`, `search`, `status` | `sort`, `filters[...]` wrapper — these two are **bare** query keys, not `filters[status]` |

**Ownership is re-checked on every call, not just filtered in lists** (spec §4.1) — `assertOwnsCourse()` runs on every course-scoped endpoint, so even a guessed `{course}`/`{lesson}` uuid belonging to another trainer 403s.

**The trainer's edit window is `draft` / `needs_revision` only** — once a course is `pending_review` or `approved`, curriculum writes (create/update/delete lesson, reorder) 422 until an admin sends it back or unpublishes.

**Type-specific curriculum endpoints, like the admin course split** — a video lesson (`type: lesson` in the DB) is authored via `.../lessons/video`, a quiz (`type: quiz`) via `.../lessons/quiz`. `type` is never a request field; using the wrong endpoint on an existing item 422s.

## Error codes used in this domain

| HTTP | error_code | Thrown when |
|---|---|---|
| 403 | `PERMISSION_DENIED` (`error`, typed `AuthorizationDeniedException` — **not** plain `fail`) | Course/program not assigned to this trainer; caller has no trainer profile at all; `join` on a session without an active enrollment |
| 404 | *(fail, `Data not found`)* | Course/lesson/session/material/question uuid doesn't resolve |
| 422 | `BUSINESS_RULE_VIOLATED` | Editing curriculum outside `draft`/`needs_revision`; wrong lesson-type endpoint used; adding a quiz when `has_quiz` is off or the course is live; adding a question to a non-quiz lesson; a training timer already running (on `start`) or not running (on `stop`) |
| 422 | *(fail, standard validation)* | `VideoLessonRequest`, `QuizLessonRequest`, `QuizQuestionRequest`, `ReorderLessonsRequest`, `MaterialRequest` |

---

## Cycle — authoring a recorded course's curriculum

This is the trainer half of the loop described in [courses-admin.md § Cycle A](./courses-admin.md#a-recorded-course-author-review-publish). Live courses use only the "Courses" and "Live sessions"/"Training timer" sections below — no curriculum.

| # | When | Method & Endpoint | Notes |
|---|---|---|---|
| 1 | Trainer opens their course list | `GET /trainer/courses` | Scoped to `trainer_id = <caller>` |
| 2 | Opens one course | `GET /trainer/courses/{course}` | 403 if not assigned |
| 3 | Adds a video lecture | `POST /trainer/courses/{course}/lessons/video` | `{duration_seconds?, video?, completion_threshold?, en:{title,description?}, ar:{...}}` |
| 4 | Adds a quiz | `POST /trainer/courses/{course}/lessons/quiz` | Requires admin's `has_quiz` flag on the course, and `course_type = recorded` |
| 5 | Adds each question to the quiz | `POST /trainer/lessons/{lesson}/questions` | Exactly one correct option; true/false needs exactly 2 options |
| 6 | Reviews/edits curriculum order | `GET /trainer/courses/{course}/lessons` → `POST /trainer/courses/{course}/lessons/reorder` | Full ordered list of every lesson uuid required |
| 7 | Attaches supporting files | `POST /trainer/courses/{course}/materials` | PDF/image, optionally scoped to one lesson |
| 8 | Edits a lesson/quiz later | `PUT /trainer/lessons/video/{lesson}` or `PUT /trainer/lessons/quiz/{lesson}` | Only while `draft`/`needs_revision` |
| 9 | Removes an item | `DELETE /trainer/lessons/{lesson}` | Soft-delete — keeps learner history |
| 10 | Ready for admin review | `POST /trainer/courses/{course}/submit-for-review` | `content_status: draft/needs_revision → pending_review`; recorded courses only |
| 11 | *(admin approves/returns — see courses-admin.md)* | — | On `needs_revision`, steps 3–9 reopen |
| 12 | Once live, track who's taking it | `GET /trainer/courses/{course}/students` | Paginated, with `search`/`status` filters |
| 13 | Live-course delivery hours (either type) | `POST /trainer/courses/{course}/timer/start` → `.../stop` | See **Training timer** below |

---

## Courses

### `GET /trainer/courses` — The trainer's own courses

No query parameters — always the full list for `trainer_id = <caller's trainer profile>`, newest (`id desc`) first.

**200 OK** (`TrainerCourseResource[]`):
```json
{
  "status": "success",
  "message": "Courses retrieved successfully",
  "data": [
    {
      "id": "c1a2...-uuid",
      "name": "Financial Modelling Fundamentals",
      "description": "...",
      "course_type": "recorded",
      "content_status": "draft",
      "is_visible": false,
      "has_quiz": true,
      "has_certificate": true,
      "start_date": null,
      "image": "https://cdn.example.com/course.jpg",
      "demo_video": "https://cdn.example.com/demo.mp4",
      "lessons_count": 12,
      "students_count": 0,
      "video_completion_threshold_default": 80,
      "training_timer_max_minutes": 240
    }
  ]
}
```

### `GET /trainer/courses/{course}` — Detail (same shape as one list row)

**Path variables:** `course`. **403** if not the assigned trainer — a typed `AuthorizationDeniedException`, so it's `error`+`error_code`, **not** the plain `fail`+`null` shape most other 403s in this API use:
```json
{ "status": "error", "message": "This course is not assigned to you.", "data": { "error_code": "PERMISSION_DENIED" } }
```
*(`context.course_id` is included only when `APP_DEBUG=true`; production strips it)*

### `GET /trainer/courses/{course}/students` — Enrolled students

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

| Key | Type | Example | Notes |
|---|---|---|---|
| `page` | integer | `1` | Laravel's implicit paginator page — not read explicitly in the controller, but honored by `paginate()` |
| `per_page` | integer | `20` | clamped `[1, 100]`, default 15 |
| `search` | string | `sara` | LIKE, OR'd across the enrolled user's `full_name`, `email`, `phone` — **bare key**, not `filters[search]` |
| `status` | string | `active` | exact match on `enrollments.status` — the codebase's own literal values are `active`/`completed`; there's no formal enum |

**200 OK** wrapped `{"students": [...]}` (`StudentResource`):
```json
{ "id": "u-uuid", "name": "Sara Ahmed", "phone": "1001234567", "status": "active", "progress_percent": 63.5, "enrolled_at": "2026-09-01 10:00:00", "completed_at": null }
```

### `POST /trainer/courses/{course}/submit-for-review`

**Path variables:** `course`. No body. Recorded courses only, and only from `draft`/`needs_revision`. **422** on a live course:
```json
{ "status": "error", "message": "Live courses do not use the content review workflow.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
**200 OK** → `TrainerCourseResource` with `content_status: "pending_review"`.

---

## Curriculum — video lessons

### `GET /trainer/courses/{course}/lessons` — Full curriculum, ordered

**Path variables:** `course`. No query parameters (`LessonResource[]`, same shape as the admin curriculum list — see [courses-admin.md](./courses-admin.md#curriculum-read-only-for-admin--no-paginationsearchfilter-on-either-endpoint)).

### `POST /trainer/courses/{course}/lessons/video` — Create a video lecture

**Path variables:** `course`. **Body**
```json
{
  "duration_seconds": 720,
  "video": "{{media_hash}}",
  "completion_threshold": 90,
  "en": { "title": "Building the income statement", "description": "..." },
  "ar": { "title": "بناء قائمة الدخل", "description": "..." }
}
```

| Field | Type | Required | Rules |
|---|---|---|---|
| `duration_seconds` | integer | no | `min:0` |
| `video` | media hash | no | video |
| `completion_threshold` | integer | no | `between:1,100` — overrides the course default for this lesson |
| `quiz_duration_minutes`, `max_attempts`, `pass_score`, `must_pass_to_continue`, `show_result_immediately` | — | — | **prohibited** on this endpoint |
| `{en,ar}.title` | string | yes | max 200 |
| `{en,ar}.description` | string | no | — |

**422** if the course isn't editable right now:
```json
{ "status": "error", "message": "This course cannot be edited while it is :status.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
**201 Created** → `LessonResource`.

### `PUT /trainer/lessons/video/{lesson}` — Update

**Path variables:** `lesson`. Same fields, all `sometimes`. **422** if `{lesson}` is actually a quiz:
```json
{ "status": "error", "message": "This curriculum item is a quiz — use that endpoint to update it.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```

### `DELETE /trainer/lessons/{lesson}` — Soft-delete

**Path variables:** `lesson`. No body. Keeps learner progress/attempt history.

**200 OK:** `{ "status": "success", "message": "Lesson deleted successfully", "data": [] }`

### `POST /trainer/courses/{course}/lessons/reorder` — Rewrite lesson positions

**Path variables:** `course`. **Body:** `{ "lessons": ["uuid-1", "uuid-2", "uuid-3"] }` — `lessons` required array, min 1; each entry `required, string, uuid, distinct, exists:course_lessons,uuid`, and together must list **every** non-deleted lesson in the course exactly once (checked in the service, not the FormRequest).

**422** — set mismatch:
```json
{ "status": "error", "message": "One or more lessons do not belong to this course.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
{ "status": "error", "message": "The new order must list every lesson in the course.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
**200 OK** → the reordered `LessonResource[]`.

---

## Curriculum — quizzes & questions

### `POST /trainer/courses/{course}/lessons/quiz` — Create a quiz item

**Path variables:** `course`. **Body**
```json
{
  "quiz_duration_minutes": 15,
  "max_attempts": 3,
  "pass_score": 70,
  "must_pass_to_continue": true,
  "show_result_immediately": true,
  "en": { "title": "Module 1 quiz", "description": null },
  "ar": { "title": "اختبار الوحدة الأولى", "description": null }
}
```

| Field | Type | Required | Rules |
|---|---|---|---|
| `quiz_duration_minutes` | integer | no | `nullable, min:1` |
| `max_attempts` | integer | no | `sometimes, min:1` (defaults to 1 if omitted entirely) |
| `pass_score` | number | no | `nullable, between:0,100` |
| `must_pass_to_continue` | boolean | no | when true, later curriculum items stay locked until passed or attempts are exhausted |
| `show_result_immediately` | boolean | no | — |
| `duration_seconds`, `video`, `completion_threshold` | — | — | **prohibited** on this endpoint |
| `{en,ar}.title` | string | yes | max 200 |
| `{en,ar}.description` | string | no | — |

**422 — quizzes not enabled**
```json
{ "status": "error", "message": "Quizzes are not enabled for this course.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
**422 — live course** (quizzes don't exist on live courses this phase):
```json
{ "status": "error", "message": "Live courses do not support quizzes in this phase.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
**201 Created** → `LessonResource` (`type: "quiz"`).

### `PUT /trainer/lessons/quiz/{lesson}` — Update

**Path variables:** `lesson`. Same fields, `sometimes`; 422 if `{lesson}` is actually a video lesson.

### `GET /trainer/lessons/{lesson}/questions` — List a quiz's questions, ordered

**Path variables:** `lesson`. No query parameters.

**200 OK** (`QuizQuestionResource[]` — includes `is_correct`; trainer-only view):
```json
{ "id": "q-uuid", "question_type": "multiple_choice", "question_text": "What is EBITDA?", "score": 1.0, "sort_order": 0, "options": [ { "id": "o1", "option_text": "Earnings before interest, tax, depreciation & amortization", "is_correct": true }, { "id": "o2", "option_text": "Net income", "is_correct": false } ] }
```

### `POST /trainer/lessons/{lesson}/questions` — Add a question

**Path variables:** `lesson` (must be `type: quiz`). **Body**
```json
{
  "question_type": "multiple_choice",
  "score": 2,
  "sort_order": 0,
  "en": { "question_text": "What is EBITDA?" },
  "ar": { "question_text": "ما هو EBITDA؟" },
  "options": [
    { "is_correct": true, "en": { "option_text": "Earnings before interest, tax, depreciation & amortization" }, "ar": { "option_text": "..." } },
    { "is_correct": false, "en": { "option_text": "Net income" }, "ar": { "option_text": "..." } }
  ]
}
```

| Field | Type | Required | Rules |
|---|---|---|---|
| `question_type` | enum | yes | `multiple_choice`\|`true_false` |
| `score` | number | no | `min:0.01, max:999` (default 1) |
| `sort_order` | integer | no | `min:0` (defaults to next free slot when omitted) |
| `options` | array | yes | `min:2, max:10` |
| `options.*.is_correct` | boolean | yes | exactly **one** `true` across the array, or 422 on `options` |
| `{en,ar}.question_text` | string | yes | — |
| `options.*.{en,ar}.option_text` | string | yes | max 255 |

Additional cross-field rule (not a per-field `rules()` entry — checked in `withValidator`): `true_false` must have exactly 2 options.

**422** if the target lesson isn't a quiz:
```json
{ "status": "error", "message": "Questions can only be added to a quiz.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
**422** — validation examples:
```json
{ "status": "fail", "message": "Exactly one option must be marked correct.", "data": null }
{ "status": "fail", "message": "A true/false question must have exactly two options.", "data": null }
```
**201 Created** → `QuizQuestionResource`.

### `DELETE /trainer/questions/{question}` — Remove a question

**Path variables:** `question`. No body. Options cascade at the DB level.

---

## Materials (course attachments)

### `GET /trainer/courses/{course}/materials` — List, newest first

**Path variables:** `course`. No query parameters — no pagination, no `lesson_id` filter (even though a material can itself be scoped to one lesson via `lesson_id` on write).

**200 OK** (`MaterialResource[]`):
```json
[{ "id": "mat-uuid", "title": "Week 1 slides", "material_type": "pdf", "file": "https://cdn.example.com/file.pdf", "lesson_id": "lesson-uuid", "created_at": "2026-09-14T07:54:11+00:00" }]
```

### `POST /trainer/courses/{course}/materials` — Attach a file

**Path variables:** `course`. **Body:** `{ "file": "{{media_hash}}", "material_type": "pdf", "lesson_id": "{{lesson_id}}", "en": {"title": "Week 1 slides"}, "ar": {"title": "..."} }`

| Field | Type | Required | Rules |
|---|---|---|---|
| `file` | media hash | yes | `application`\|`image` mime group |
| `material_type` | enum | yes | `CourseMaterialType` (`pdf`, `image`) — cross-checked against the uploaded file's real mime type; a mismatch 422s on `material_type` |
| `lesson_id` | uuid | no | `exists:course_lessons,uuid`, scoped to this course; omitted keeps it course-level |
| `{en,ar}.title` | string | no | max 200 |

**422 — declared type doesn't match the uploaded file**
```json
{ "status": "fail", "message": "The uploaded file is a image, not a pdf.", "data": null }
```
**201 Created** (`MaterialResource`): `{ "id": "mat-uuid", "title": "Week 1 slides", "material_type": "pdf", "file": "https://cdn.example.com/file.pdf", "lesson_id": "lesson-uuid", "created_at": "2026-09-14T07:54:11+00:00" }`

### `DELETE /trainer/materials/{material}`

**Path variables:** `material`. No body.

---

## Live sessions (read-only for trainers — scheduling is admin-only)

### `GET /trainer/courses/{course}/sessions` — List, with `attendance_count` per session

**Path variables:** `course`. No query parameters — full list, ordered by `sort_order`.

### `GET /trainer/sessions/{session}` — Detail

**Path variables:** `session`. `ClassSessionResource`:
```json
{
  "id": "sess-uuid", "mode": "online", "session_date": "2026-10-05", "start_time": "18:00:00",
  "duration_minutes": 90, "meeting_link": "https://meet.example.com/abc", "sort_order": 0,
  "is_scheduled": true, "status": "upcoming", "starts_at": "2026-10-05T18:00:00+00:00",
  "ends_at": "2026-10-05T19:30:00+00:00", "attendance_count": null, "can_join": true
}
```
`status` (`unscheduled`\|`upcoming`\|`running`\|`completed`) is derived from the clock, never stored. `attendance_count` is only populated once `status: completed` (it's `null` before then, not `0`, so the client doesn't render a moving number). **404** if `{session}` doesn't belong to a course (e.g. it's actually a training-program session): `{ "status": "fail", "message": "Session not found.", "data": null }`.

---

## Training timer — عداد التدريب (both course types)

### `GET /trainer/courses/{course}/timer` — Current state

**Path variables:** `course`. No query parameters.

**200 OK:** `{ "is_running": false, "started_at": null, "max_minutes": 240, "cumulative_seconds": 5400, "cumulative_hours": 1.5 }`

### `POST /trainer/courses/{course}/timer/start` — Open a running interval

**Path variables:** `course`. No body. **201** on success (state shape above).

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

### `POST /trainer/courses/{course}/timer/stop` — Close it

**Path variables:** `course`. No body. Credits elapsed time, clamped to `training_timer_max_minutes` if set.

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

### `POST /trainer/courses/{course}/timer/reset` — Discard the running interval

**Path variables:** `course`. No body. Credits **no** time — a UI-clock reset only, not a business no-op guard (never 422s even if nothing is running).

All three action endpoints return the same state shape as `GET`. A forgotten running timer is also auto-closed by a scheduled sweep (`TrainingTimerService::autoCloseStale()`) once it passes the course's cap, so it never runs away unattended even if the trainer never calls `stop`.
