# Courses API — Users (Learners)

Learner browse & enrollment, video-progress tracking, the gated curriculum, quizzes, course materials, and live-session join.

- **Base path:** `{{base_url}}/user` (e.g. `http://localhost:8000/api/v1/user`)
- **Auth:** browse is public; everything from **enroll** onward requires `Authorization: Bearer {{user_token}}` (`auth:api, active, user_type:user`)
- **Source:** `app/Http/Controllers/Api/V1/User/LearnerCourseController.php`, `app/Services/Education/{EnrollmentService,CourseProgressService,QuizService,LiveScheduleService,CurriculumService}.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`.

**No endpoint in this document runs through the shared index pipeline.** Every list here — `GET /user/courses`, `GET /user/my-courses`, `GET /user/courses/{course}/curriculum`, `GET /user/courses/{course}/materials` — is a plain, hand-written query with **no `page`/`per_page`/`search`/`sort`/`filters[...]` support at all**. Sending any of those query keys is silently ignored; the full result set (whatever it is — the caller's own enrollments, one course's curriculum) always comes back in one response.

**Enrollment gates everything past browsing.** Curriculum, materials, progress and quiz endpoints all resolve the caller's own `active`/`completed` `Enrollment` for the course first — no enrollment (or someone else's course) is a **403**, not a 404: `You are not enrolled in this course.`

**Curriculum is sequentially unlocked.** A video lesson unlocks once every earlier item is satisfied: earlier videos `watched`, and earlier quizzes with `must_pass_to_continue` either passed or attempts exhausted. `GET .../curriculum` returns this precomputed per-item (`is_unlocked`); a quiz's own questions are only ever served via `quiz/start`, never through curriculum, and options never carry `is_correct` here.

**Quiz attempts are resumable, not restartable.** `POST .../quiz/start` returns the existing open attempt if one exists rather than creating a new one; `GET .../quiz/current-attempt` is the same read without opening anything, for recovering from a killed app mid-quiz.

## Error codes used in this domain

| HTTP | error_code | Thrown when |
|---|---|---|
| 403 | `PERMISSION_DENIED` (`error`, typed `AuthorizationDeniedException` — **not** plain `fail`) | Course not visible/approved (browse `show`, `enroll`); no active/completed enrollment (curriculum, progress, materials, quiz); not enrolled in the session's course (`join`) |
| 404 | *(fail, `Data not found`)* | Course/lesson/session uuid doesn't resolve |
| 422 | `BUSINESS_RULE_VIOLATED` | Course is full (`enroll`); quiz locked, has no questions, attempts exhausted, or the submitted `attempt_id` doesn't match/is already completed; session not yet scheduled or has no meeting link (`join`); quiz endpoint called on a non-quiz lesson |
| 422 | *(fail, standard validation)* | `VideoProgressRequest`, `QuizAttemptRequest` |

---

## Cycle

### A. Recorded course: browse, enroll, learn, certificate

| # | When | Method & Endpoint | Notes |
|---|---|---|---|
| 1 | Browse screen | `GET /user/courses` | Only `is_visible=true, content_status=approved`; no pagination/search — always the full public catalog in one response |
| 2 | Course detail | `GET /user/courses/{course}` | 403 if not visible/approved |
| 3 | Enroll | `POST /user/courses/{course}/enroll` | Idempotent — re-enrolling returns the existing enrollment, not a duplicate |
| 4 | My courses list | `GET /user/my-courses` | Every enrollment for this user, no pagination |
| 5 | Curriculum with unlock state | `GET /user/courses/{course}/curriculum` | Drives the lesson/quiz list UI — each item says whether it's reachable yet |
| 6a | Watch a video, send heartbeats | `POST /user/lessons/{lesson}/progress` | `{watched_percent, position_seconds?}` — repeat as playback advances |
| 6b | Take a quiz | `POST /user/lessons/{lesson}/quiz/start` → `POST /user/lessons/{lesson}/quiz-attempts` | See quiz sub-flow below |
| 7 | Supporting files | `GET /user/courses/{course}/materials` | Requires enrollment |
| 8 | *(certificate auto-issued once eligible, after each progress/quiz event)* | `GET /user/certificates` | Certificates domain — not covered here |

**Quiz sub-flow** (steps 6b, repeatable per attempt up to `max_attempts`):

| # | Method & Endpoint | Notes |
|---|---|---|
| i | `GET /user/lessons/{lesson}/quiz/current-attempt` | Optional — resume check after an app restart; returns `attempt: null` if nothing is open |
| ii | `POST /user/lessons/{lesson}/quiz/start` | Opens (or resumes) an attempt; returns questions **without** correct answers; 201 if newly opened, 200 if resumed |
| iii | `POST /user/lessons/{lesson}/quiz-attempts` | Submit `{attempt_id, answers: [{question_id, option_id}]}` for auto-grading; `option_id: null` grades as incorrect (deliberate "unanswered") |

### B. Live course: browse, enroll, attend

| # | When | Method & Endpoint | Notes |
|---|---|---|---|
| 1–3 | Same browse/enroll as above | — | `enroll` 422s (`BUSINESS_RULE_VIOLATED`) once `max_seats` is reached |
| 4 | Sessions list | `GET /user/courses/{course}/` (`sessions[]` on the detail response, plus `sessions_count`/`seats_left` on both card and detail) | Public — anyone can see the schedule; `meeting_link` is `null` unless the caller is enrolled |
| 5 | Join a session | `POST /user/sessions/{session}/join` | No join-time window in V1 — joinable any time once scheduled; records attendance and returns the meeting link |
| 6 | *(attendance feeds completion & certificate eligibility automatically)* | — | Re-evaluated after every join |

---

## Browse & enrollment

### `GET /user/courses` — Public browse list

No query parameters — always `is_visible=true, content_status=approved`, newest (`id desc`) first, in one response (no pagination).

**200 OK** (`CourseCardResource[]`):
```json
{
  "status": "success",
  "message": "Courses retrieved successfully",
  "data": [
    {
      "id": "c1a2...-uuid",
      "slug": "financial-modelling-fundamentals",
      "name": "Financial Modelling Fundamentals",
      "description": "...",
      "course_type": "recorded",
      "category": "Finance",
      "trainer": { "id": "trn-uuid", "name": "Dr. Farooq Al-Bateel", "bio": "...", "photo": "https://cdn.example.com/trainer.jpg" },
      "has_certificate": true,
      "has_free_sample": true,
      "image": "https://cdn.example.com/course.jpg",
      "demo_video": "https://cdn.example.com/demo.mp4",
      "start_date": null,
      "lessons_count": 12,
      "total_duration_seconds": 8640
    }
  ]
}
```
For `course_type: live`, `lessons_count`/`total_duration_seconds` are replaced by `sessions_count` and `seats_left` (`null` when uncapped, otherwise `max_seats - (active + completed enrollments)`).

### `GET /user/courses/{course}` — Detail

**Path variables:** `course`. Same card shape as above, plus `lessons[]` (with nested materials) and, for `course_type: live`, `sessions[]` — the full lecture schedule (BRD "Lecture schedule"): `{ "id", "mode", "session_date", "start_time", "duration_minutes", "location", "meeting_link", "is_live", "sort_order" }`. `meeting_link` is `null` unless the caller is enrolled — course sessions have no live-time-window gate on join (unlike training-program sessions), so it's revealed any time once enrolled. **403** if not visible/approved — a typed `AuthorizationDeniedException` (`error`+`error_code`, not plain `fail`):
```json
{ "status": "error", "message": "This course is not available.", "data": { "error_code": "PERMISSION_DENIED" } }
```

### `POST /user/courses/{course}/enroll` — **User**

**Path variables:** `course`. No body. Idempotent (an existing enrollment is returned, not duplicated). Seat capacity is checked and reserved under a row lock so two concurrent enrollments on the last seat can't both succeed.

**422 — full**
```json
{ "status": "error", "message": "This course is fully booked.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
**201 Created** (`EnrollmentResource`):
```json
{ "id": "enr-uuid", "status": "active", "progress_percent": 0, "enrolled_at": "2026-09-14T07:54:11+00:00", "completed_at": null, "course": { "...": "CourseCardResource" } }
```

### `GET /user/my-courses` — Every enrollment for the caller

No query parameters. `EnrollmentResource[]`, newest `enrolled_at` first.

---

## Curriculum & progress (recorded courses)

### `GET /user/courses/{course}/curriculum` — **User**, must be enrolled

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

**200 OK:**
```json
{
  "status": "success",
  "message": "Curriculum retrieved successfully",
  "data": {
    "enrollment": { "id": "enr-uuid", "status": "active", "progress_percent": 25.0, "enrolled_at": "2026-09-01T10:00:00+00:00", "completed_at": null },
    "items": [
      {
        "id": "lesson-uuid", "type": "lesson", "title": "Building the income statement", "description": "...", "sort_order": 0,
        "is_unlocked": true, "is_completed": true,
        "duration_seconds": 720, "completion_threshold": 90,
        "video": "https://cdn.example.com/lesson.mp4",
        "progress": { "watched_percent": 100, "watched": true, "last_position_seconds": 720 }
      },
      {
        "id": "quiz-uuid", "type": "quiz", "title": "Module 1 quiz", "description": null, "sort_order": 1,
        "is_unlocked": true, "is_completed": false,
        "quiz_duration_minutes": 15, "max_attempts": 3, "attempts_used": 1, "pass_score": 70,
        "must_pass_to_continue": true, "show_result_immediately": true,
        "passed": false, "best_score": 40.0, "attempts_exhausted": false, "can_continue": false
      }
    ]
  }
}
```
A locked item still appears (so the UI can render it greyed out) but `video` is `null` and quiz questions are absent — content is only ever released through `is_unlocked`. **403** if not enrolled — typed `AuthorizationDeniedException` (`error`+`error_code`, not plain `fail`): `{ "status": "error", "message": "You are not enrolled in this course.", "data": { "error_code": "PERMISSION_DENIED" } }`.

### `POST /user/lessons/{lesson}/progress` — Watch heartbeat — **User**, must be enrolled

**Path variables:** `lesson`. **Body:** `{ "watched_percent": 87.5, "position_seconds": 630 }`

| Field | Type | Required | Rules |
|---|---|---|---|
| `watched_percent` | number | yes | `between:0,100` |
| `position_seconds` | integer | no | `sometimes, nullable, min:0` — last playback position, for resuming mid-video |

**200 OK:**
```json
{
  "status": "success",
  "message": "Progress recorded successfully",
  "data": { "lesson_id": "lesson-uuid", "watched_percent": 87.5, "watched": false, "progress_percent": 25.0, "status": "active" }
}
```
`watched` flips to `true` once `watched_percent` crosses the lesson's (or course default's) `completion_threshold`; crossing it can also complete the enrollment and auto-issue a certificate. `progress_percent`/`status` in the response are the **enrollment's** overall figures (re-fetched after this update), not this single lesson's.

### `GET /user/courses/{course}/materials` — **User**, must be enrolled

**Path variables:** `course`. No query parameters — full list, newest first (`MaterialResource[]`, same shape as the trainer's material list; no `is_correct`-style leakage since materials carry none).

---

## Quizzes

### `POST /user/lessons/{lesson}/quiz/start` — Open or resume an attempt — **User**, must be enrolled

**Path variables:** `lesson` (must be `type: quiz`). No body.

**422 examples**
```json
{ "status": "error", "message": "This quiz is locked.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
{ "status": "error", "message": "This quiz has no questions yet.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
{ "status": "error", "message": "You have used all :count attempts for this quiz.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
**200/201:**
```json
{
  "status": "success",
  "message": "Quiz started successfully",
  "data": {
    "attempt": { "id": "attempt-uuid", "attempt_number": 1, "started_at": "2026-09-14T07:54:11+00:00", "quiz_duration_minutes": 15 },
    "max_attempts": 3, "attempts_used": 0, "attempts_remaining": 3,
    "questions": [
      { "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" }, { "id": "o2", "option_text": "Net income" } ] }
    ]
  }
}
```
Note `options[]` never carries `is_correct` here. **201** if a new attempt was opened, **200** if an in-progress one was resumed.

### `GET /user/lessons/{lesson}/quiz/current-attempt` — Read-only resume check — **User**, must be enrolled

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

Same payload shape as `start`, but `attempt`/`questions` are `null`/`[]` when nothing is open — **never** opens a new attempt:
```json
{ "status": "success", "message": "Current quiz attempt retrieved successfully", "data": { "attempt": null, "max_attempts": 3, "attempts_used": 1, "attempts_remaining": 2, "questions": [] } }
```

### `POST /user/lessons/{lesson}/quiz-attempts` — Submit for grading — **User**, must be enrolled

**Path variables:** `lesson`. **Body:** `{ "attempt_id": "attempt-uuid", "answers": [ { "question_id": "q-uuid", "option_id": "o1" }, { "question_id": "q2-uuid", "option_id": null } ] }`

| Field | Type | Required | Rules |
|---|---|---|---|
| `attempt_id` | uuid | yes | `exists:quiz_attempts,uuid` |
| `answers` | array | yes | `min:1` |
| `answers.*.question_id` | uuid | yes | `exists:quiz_questions,uuid` |
| `answers.*.option_id` | uuid or null | yes (key must be **present**) | `nullable` — an explicit unanswered question; graded incorrect, not skipped |

**422 examples**
```json
{ "status": "error", "message": "This quiz attempt is invalid.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
{ "status": "error", "message": "This quiz attempt has already been submitted.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
**201 Created:**
```json
{
  "status": "success",
  "message": "Quiz submitted successfully",
  "data": {
    "attempt_id": "attempt-uuid", "attempt_number": 1, "score": 85.5, "passed": true,
    "show_result": true, "max_attempts": 3, "attempts_used": 1, "attempts_remaining": 2,
    "attempts_exhausted": false, "can_continue": true
  }
}
```
Grading is score-weighted (each question's `score` field, not a flat per-question count): `percent = earned / total * 100`, `passed = percent >= pass_score`. A pass can complete the enrollment and issue a certificate, re-evaluated right after grading.

---

## Live sessions

### `POST /user/sessions/{session}/join` — **User**

**Path variables:** `session`. No body. No join-time window in V1 — callable any time once the session is scheduled; records (or updates, keeping the *first* arrival time) attendance and always returns the meeting link.

**403 — not enrolled** (typed `AuthorizationDeniedException` — `error`+`error_code`, not plain `fail`)
```json
{ "status": "error", "message": "You are not enrolled in this course.", "data": { "error_code": "PERMISSION_DENIED" } }
```
**422 — not ready**
```json
{ "status": "error", "message": "This session has not been scheduled yet.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
{ "status": "error", "message": "This session has no meeting link yet.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
**200 OK:** `{ "status": "success", "message": "Attendance recorded successfully", "data": { "meeting_link": "https://meet.example.com/abc" } }`
