# Quizzes — Full Cycle & Frontend Integration Guide

End-to-end guide for the quiz feature: how a quiz is **authored** (trainer), **reviewed** (admin), **taken** (learner) and **reported on** (trainer results). It is written for frontend developers; every endpoint below exists in the code today.

- **Base URL:** `{{base_url}}` = `http://localhost:8000/api/v1`
- **Headers (all requests):** `Accept: application/json`, `Content-Type: application/json`, `Accept-Language: en|ar`, `Authorization: Bearer {{token}}`
- **Sources:** `app/Services/Education/QuizService.php`, `app/Http/Controllers/Api/V1/User/LearnerCourseController.php`, `app/Http/Controllers/Api/V1/Trainer/{QuizLesson,QuizQuestion,TrainerStudent}Controller.php`, `routes/api/v1/{user,trainer,admin}.php`
- Per-domain reference docs this guide builds on: [courses-user.md](./courses-user.md), [courses-trainer.md](./courses-trainer.md), [courses-admin.md](./courses-admin.md)

---

## 1. Mental model

| Concept | What it is |
|---|---|
| **Quiz** | A curriculum item: a `course_lessons` row with `type: "quiz"`. It sits in the same ordered list as video lessons; its **position (`sort_order`) is its availability rule**. There is no separate quizzes table or "quiz id" — the quiz id **is the lesson uuid**. |
| **Question** | `multiple_choice` (2–10 options) or `true_false` (exactly 2 options). Always **exactly one correct option** — so grading is fully automatic. |
| **Attempt** | One learner sitting of a quiz. `attempt_number` 1..`max_attempts`. Opened by `start`, closed by `submit`. |
| **Score** | Percentage `0–100`, **weighted** by each question's `score` field (not a flat count). `passed = score >= pass_score`. |
| **Gate** | If `must_pass_to_continue = true`, every later curriculum item stays locked until the quiz is **passed or all attempts are used up**. |

Constraints to remember:

- Quizzes exist on **recorded courses only** (live courses reject them). The admin must enable `has_quiz` on the course before a trainer can add one.
- **All ids in the API are uuids** (`lesson`, `question`, `option`, `attempt`).
- **Never send/expect `is_correct` on learner screens.** It only appears in trainer/admin payloads.
- Quizzes are **excluded from the video progress %**, but the enrollment can only become `completed` (and a certificate be issued) once every quiz is **passed**.

---

## 2. Roles & the full cycle at a glance

```
Admin       enables has_quiz on the course (course create/update)            ─┐
Trainer     creates quiz  →  adds questions  →  reorders  →  submit-for-review │  AUTHORING
Admin       reviews curriculum (read-only) → approve / request-revision       │  & REVIEW
Admin       publishes course                                                  ─┘
Learner     enrolls → curriculum (is_unlocked) → start → answer → submit      ─┐  TAKING
Learner     result screen → retry (if attempts left) or continue / certificate ─┘
Trainer     students/quizzes → per-quiz metrics + attempt results             ── REPORTING
```

| # | Actor | Step | Endpoint |
|---|---|---|---|
| 1 | Admin | Enable quizzes on a recorded course (`has_quiz`, optional `cert_required_quiz_pass_percent`) | `POST /admin/courses/recorded` · `PUT /admin/courses/recorded/{course}` *(see [courses-admin.md](./courses-admin.md))* |
| 2 | Trainer | Create quiz item | `POST /trainer/courses/{course}/lessons/quiz` |
| 3 | Trainer | Add questions | `POST /trainer/lessons/{lesson}/questions` |
| 4 | Trainer | Review / edit / reorder / delete questions | `GET/PUT/DELETE …` (§4) |
| 5 | Trainer | Submit course for review | `POST /trainer/courses/{course}/submit-for-review` |
| 6 | Admin | Read the curriculum, approve or send back | `GET /admin/lessons/{lesson}` → `POST /admin/courses/{course}/approve` \| `request-revision` |
| 7 | Learner | See quiz in curriculum with lock state | `GET /user/courses/{course}/curriculum` |
| 8 | Learner | (Optional) resume check | `GET /user/lessons/{lesson}/quiz/current-attempt` |
| 9 | Learner | Start / resume attempt | `POST /user/lessons/{lesson}/quiz/start` |
| 10 | Learner | Submit answers | `POST /user/lessons/{lesson}/quiz-attempts` |
| 11 | Trainer | View results | `GET /trainer/students/quizzes` |

---

## 3. Response envelope & errors

Every response is `{ "status": "success" | "fail" | "error", "message": string, "data": ... }`.

| HTTP | `status` | Meaning | `data` |
|---|---|---|---|
| 200 / 201 | `success` | OK / created | payload |
| 401 | `fail` | Missing/invalid token | `null` |
| 403 | `error` | `PERMISSION_DENIED` — not enrolled, not your course, missing trainer capability | `{ "error_code": "PERMISSION_DENIED" }` |
| 404 | `fail` | uuid doesn't resolve | `null` |
| 422 | `error` | Business rule broken — `BUSINESS_RULE_VIOLATED` | `{ "error_code": "BUSINESS_RULE_VIOLATED" }` |
| 422 | `fail` | Form validation — `message` is the **first** failing rule only | `null` |

**Tip for the frontend:** for business-rule 422s, branch on `message` only for display; there is no per-rule sub-code, so use the *state you already have* (`is_unlocked`, `attempts_remaining`, `attempt` null or not) to prevent the call in the first place.

---

## 4. Learner flow (the part most screens need)

**Auth:** `Bearer {{user_token}}`, user must be **enrolled** (`active` or `completed`) in the quiz's course, otherwise **403** `You are not enrolled in this course.`

### 4.1 Screen flow

```
Curriculum list ─► tap quiz item
   │  is_unlocked = false ─► show locked state, do nothing
   │  attempts_remaining = 0 (attempts_exhausted) ─► show best_score / passed, no "Start"
   ▼
[optional on entry] GET current-attempt ── attempt != null ─► "Resume quiz" (same renderer as start)
   ▼
POST start ─► questions + attempt  (201 new | 200 resumed)
   ▼
Answer screen (timer from quiz_duration_minutes, client-side)
   ▼
POST quiz-attempts ─► result { score, passed, can_continue, attempts_remaining … }
   ▼
Result screen ─► passed / can_continue ─► back to curriculum (next item unlocks)
              └► failed & attempts_remaining > 0 ─► "Try again" ─► POST start (new attempt)
```

### 4.2 `GET /user/courses/{course}/curriculum` — list with lock state

No query params, no pagination. Quiz items look like this (video items carry `video`/`progress` instead):

```json
{
  "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
}
```

| Field | Use it for |
|---|---|
| `is_unlocked` | Render locked/greyed if `false`. **Locked items still appear** — never hide them. |
| `is_completed` | `true` only when the quiz has been **passed**. |
| `attempts_used` / `max_attempts` | "Attempt 2 of 3" label. Remaining = `max_attempts - attempts_used`. |
| `passed`, `best_score` | Badge + best result (`best_score` is `null` before any attempt). |
| `attempts_exhausted` | All attempts **completed** — disable "Start". |
| `can_continue` | `passed \|\| attempts_exhausted`. Tells you whether a `must_pass_to_continue` gate is cleared. |
| `quiz_duration_minutes` | Countdown length (may be `null` = untimed). |
| `pass_score` | Percentage needed (may be `null` → treated as 0, i.e. any attempt passes). |
| `show_result_immediately` | See §4.5. |

Questions are **never** in the curriculum payload — only `start` returns them.

**Unlock rule:** items unlock sequentially by `sort_order`. An item is locked until every earlier video is `watched` **and** every earlier quiz with `must_pass_to_continue` is passed *or* exhausted. A quiz that isn't a blocker does not lock what follows it.

### 4.3 `POST /user/lessons/{lesson}/quiz/start` — open or resume

No body. `{lesson}` = quiz uuid. **Idempotent**: if an attempt is already open it is returned again (200) instead of creating another; a new attempt returns **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" }
        ]
      }
    ]
  }
}
```

- `questions` are ordered by `sort_order`; text is in the request's `Accept-Language` locale.
- **Keep `attempt.id`** — it is required on submit.
- The counters are read **after** the attempt is opened, so an open attempt already counts as used: on a first start of a 3-attempt quiz you get `attempts_used: 1`, `attempts_remaining: 2`. Use `attempt.attempt_number` for the "Attempt N of M" label. (The example above is illustrative; older docs show `0`/`3`.)

**422 cases** (`BUSINESS_RULE_VIOLATED`):

| `message` | Cause |
|---|---|
| `This lesson is not a quiz.` | `{lesson}` is a video item |
| `This quiz is locked.` | An earlier blocking item isn't satisfied — prevent with `is_unlocked` |
| `This quiz has no questions yet.` | Trainer hasn't added questions |
| `You have used all N attempts for this quiz.` | No attempts left and none open |

### 4.4 `GET /user/lessons/{lesson}/quiz/current-attempt` — read-only resume check

Same shape as `start`, but **never opens an attempt** and never fails on a locked/empty quiz. Use it when the screen mounts (or the app restarts mid-quiz) to decide between "Start" and "Resume".

```json
{ "status": "success", "message": "Current quiz attempt retrieved successfully",
  "data": { "attempt": null, "max_attempts": 3, "attempts_used": 1, "attempts_remaining": 2, "questions": [] } }
```

`attempt: null` ⇒ nothing to resume. Otherwise `questions` is filled exactly like `start`.

### 4.5 `POST /user/lessons/{lesson}/quiz-attempts` — submit for grading

```json
{
  "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 | must exist in `quiz_attempts` **and** belong to this user + this quiz |
| `answers` | array | yes | `min:1` |
| `answers.*.question_id` | uuid | yes | must exist |
| `answers.*.option_id` | uuid \| `null` | key must be **present** | `null` = deliberately unanswered → graded incorrect |

Behaviour worth knowing:

- Send **one entry per question**. Questions you omit are simply graded incorrect (same as `null`); unknown/extra question ids are ignored.
- An `option_id` that doesn't belong to that question is graded incorrect (no error).
- Submitting is **final** for that attempt (`This quiz attempt has already been submitted.` on a second try) — disable the button after the first tap.
- The timer is **client-side only**: the server does not reject a late submit. On timeout, auto-submit whatever is answered (use `null` for the rest).

**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
  }
}
```

| Field | Meaning / UI |
|---|---|
| `score` | Percent `0–100` (2 decimals) |
| `passed` | `score >= pass_score` |
| `show_result` | Mirrors the quiz's `show_result_immediately`. **The server always returns `score`/`passed`**; when `show_result` is `false` the client must **hide the score/pass state** and show a neutral "Submitted" screen. There is no per-question review endpoint — correct answers are never revealed to learners. |
| `attempts_remaining` | `> 0 && !passed` → offer "Try again" (→ `start`) |
| `attempts_exhausted` | Attempts all used |
| `can_continue` | `passed \|\| attempts_exhausted` → gate cleared; refetch the curriculum to see the next item unlock |

**Side effects after grading:** enrollment progress is recalculated and a **certificate is auto-issued** if the learner is now eligible (see §7). Refetch `GET /user/my-courses` / `GET /user/certificates` after a pass if you show those.

**422 cases:** `This quiz attempt is invalid.` (wrong/foreign/unknown attempt), `This quiz attempt has already been submitted.`, `This quiz has no questions yet.`, plus form validation on the body above.

### 4.6 Attempt & pass rules (cheat sheet)

| Rule | Detail |
|---|---|
| Attempts | `max_attempts` (default **1** if the trainer didn't set it). Only **one open attempt** at a time. |
| Passing | Weighted %: `earned_score / total_score × 100`, rounded to 2 dp. `pass_score` null ⇒ 0 ⇒ always passes. |
| Best result | Curriculum's `best_score` = max over attempts; `passed` = ever passed. Once passed, the quiz stays passed. |
| Retry after pass | Allowed while attempts remain (server doesn't block it) — hide "Try again" once `passed` if you want a one-shot UX. |
| Unlock gate | `must_pass_to_continue` ⇒ later items unlock on `passed` **or** `attempts_exhausted`. |
| Enrollment | `progress_percent` counts videos only; enrollment `status → completed` additionally needs **every quiz passed**. A learner can sit at 100% `active` until quizzes are done. |

---

## 5. Trainer flow — authoring

**Auth:** `Bearer {{trainer_token}}`. Ownership of the course is checked on every call (someone else's uuid ⇒ **403**). Some actions also need a trainer capability (below).

**Edit window:** quiz-item create/update and lesson delete/reorder work only while the course is `draft` or `needs_revision` — otherwise **422**.

### 5.1 Endpoint map

| Method & endpoint | Purpose | Extra permission |
|---|---|---|
| `POST /trainer/courses/{course}/lessons/quiz` | Create quiz item | capability `edit_exam_settings` |
| `PUT /trainer/lessons/quiz/{lesson}` | Update quiz settings/title | capability `edit_exam_settings` |
| `GET /trainer/courses/{course}/lessons` | Whole curriculum (videos + quizzes, ordered) | `view_courses` |
| `POST /trainer/courses/{course}/lessons/reorder` | Reorder curriculum (full ordered list of lesson uuids) | — |
| `DELETE /trainer/lessons/{lesson}` | Soft-delete a quiz/lesson (keeps learner history) | — |
| `GET /trainer/lessons/{lesson}/questions` | List questions **with `is_correct`** | `view_courses` |
| `POST /trainer/lessons/{lesson}/questions` | Add question | capability `manage_exam_questions` |
| `PUT /trainer/questions/{question}` | Edit question / replace options | capability `manage_exam_questions` |
| `POST /trainer/lessons/{lesson}/questions/reorder` | Reorder questions | capability `manage_exam_questions` |
| `DELETE /trainer/questions/{question}` | Delete question (options cascade) | capability `manage_exam_questions` |
| `POST /trainer/courses/{course}/submit-for-review` | Send course (incl. quizzes) to admin | — |

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

```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 / default |
|---|---|---|---|
| `quiz_duration_minutes` | int | no | `nullable, min:1` — `null` = untimed |
| `max_attempts` | int | no | `min:1` — default **1** |
| `pass_score` | number | no | `0–100`, nullable |
| `must_pass_to_continue` | bool | no | default `false` |
| `show_result_immediately` | bool | no | default `true` |
| `{en,ar}.title` | string | yes | max 200 |
| `{en,ar}.description` | string | no | — |
| `duration_seconds`, `video`, `completion_threshold` | — | — | **prohibited** (422) |

**201** → `LessonResource`:
`{ id, type: "quiz", title, description, sort_order, quiz_duration_minutes, max_attempts, pass_score, must_pass_to_continue, show_result_immediately, questions_count }`.

**422:** `Quizzes are not enabled for this course.` (admin's `has_quiz` off) · `Live courses do not support quizzes in this phase.` · edit window closed.

`PUT /trainer/lessons/quiz/{lesson}` accepts the same fields (all optional); it 422s if `{lesson}` is actually a video (use `PUT /trainer/lessons/video/{lesson}` for those).

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

```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 (on create) | `multiple_choice` \| `true_false` |
| `score` | number | no | `0.01–999`, default `1` — this is the question's **weight** |
| `sort_order` | int | no | `≥0`; omitted ⇒ appended at the end |
| `options` | array | yes (on create) | `2–10` items |
| `options.*.is_correct` | bool | yes | **exactly one** `true` |
| `{en,ar}.question_text` | string | yes (on create) | — |
| `options.*.{en,ar}.option_text` | string | yes | max 255 |

Cross-field rules: exactly one correct option; `true_false` must have **exactly 2** options. Failures return `422 fail` with e.g. `Exactly one option must be marked correct.` / `A true/false question must have exactly two options.`
Adding to a non-quiz lesson ⇒ `422 error` `Questions can only be added to a quiz.`

**201** → question (trainer view **includes `is_correct`**):

```json
{ "id": "q-uuid", "question_type": "multiple_choice", "question_text": "What is EBITDA?", "score": 2.0, "sort_order": 0,
  "options": [ { "id": "o1", "option_text": "…", "is_correct": true }, { "id": "o2", "option_text": "Net income", "is_correct": false } ] }
```

`question_text`/`option_text` in responses are in the request locale; send both `en` and `ar` blocks when writing.

### 5.4 Edit / reorder / delete questions

- **`PUT /trainer/questions/{question}`** — same body, every field optional. **If you send `options`, the whole option set is replaced** (old options are deleted, new ones created) — always send the complete list, never a partial one. The one-correct rule still applies.
- **`POST /trainer/lessons/{lesson}/questions/reorder`** — body `{ "questions": ["q1-uuid", "q2-uuid", …] }`. Must list **every** question of the quiz exactly once (no duplicates); otherwise 422 (`The new order must list every question in the lesson.` / `One or more questions do not belong to this lesson.`). Returns the reordered list.
- **`DELETE /trainer/questions/{question}`** → `200`, `data: []`.

> Question endpoints check ownership + the `manage_exam_questions` capability, but (unlike lesson create/update) they do **not** re-check the course's edit window. Don't rely on the server to block question edits on a course under review — disable them in the UI based on the course's `content_status`.

### 5.5 Submit for review

`POST /trainer/courses/{course}/submit-for-review` moves `draft/needs_revision → pending_review`. The publish/approve checks treat a quiz as valid only if it has **≥1 question** and every question is well-formed (MCQ: ≥2 options & one correct; true/false: 2 options & one correct). Surface this to trainers **before** submitting: a quiz with no questions is the most common blocker (`quiz_without_questions`).

---

## 6. Admin flow — review & course setup

**Auth:** `Bearer {{admin_token}}`; permissions follow the RBAC route-name convention.

| Method & endpoint | Purpose |
|---|---|
| `POST /admin/courses/recorded` · `PUT /admin/courses/recorded/{course}` | Set `has_quiz` (bool) and `cert_required_quiz_pass_percent` (int 0–100, nullable) on the course |
| `GET /admin/courses/{course}/lessons` | Ordered curriculum incl. quiz items (settings only) |
| `GET /admin/lessons/{lesson}` | Quiz details **with all questions, options, `is_correct` and `en`/`ar` blocks** — use this for the review screen |
| `GET /admin/courses/{course}/publish-check` | Lists blockers, e.g. `quiz_without_questions`, `quiz_rule_without_quizzes` |
| `POST /admin/courses/{course}/approve` / `request-revision` | Course-level decision |
| `POST /admin/courses/{course}/publish` / `unpublish` | Visibility |

Notes:

- Review is **course-level only** today. There is **no per-quiz approve/reject endpoint** (a design exists in `docs/superpowers/specs/2026-09-15-quiz-approval-design.md` but is not implemented — don't build UI for it yet).
- Admins **read** quizzes; authoring is trainer-only.
- Setting `cert_required_quiz_pass_percent` on a course with no quizzes fails publish (`quiz_rule_without_quizzes`).
- Live courses reject `has_quiz` and the quiz certificate rule.

---

## 7. Certificates & quizzes

| Course setting | Effect |
|---|---|
| `cert_required_quiz_pass_percent = N` | Certificate needs **N% of the course's quizzes passed** (by count, not by score). |
| `cert_required_quiz_pass_percent = null` | Quizzes don't affect the certificate rule (but every quiz must still be passed for the enrollment to be `completed`). |

A certificate is evaluated/issued automatically right after each quiz submit; poll `GET /user/certificates` after a passing result.

---

## 8. Trainer results screen — `GET /trainer/students/quizzes`

Also available as `GET /trainer/trainees/quizzes` (alias) and, for a specific quiz, `GET /trainer/students/quizzes/{quiz}`. Requires capability `view_exam_results`. Covers all quizzes across the trainer's courses.

| Query | Type | Notes |
|---|---|---|
| `quiz_id` | uuid | Selected quiz; defaults to the first quiz (or use the `/{quiz}` path form) |
| `search` | string | Matches learner name / email |
| `per_page` | int | default 15, max 100 (attempts are paginated; `page` implicit) |

```json
{
  "status": "success",
  "message": "Quizzes and results retrieved successfully",
  "data": {
    "quizzes": [
      { "id": "quiz-uuid", "title": "Module 1 quiz", "course_id": "course-uuid", "course_name": "Financial Analysis",
        "pass_score": 70.0, "pass_percentage": "70.0%", "is_final": false, "type": "quiz", "type_label": "Quiz",
        "status": "available", "status_label": "Available", "total_attempts": 12 }
    ],
    "selected_quiz": { "…": "same shape as a quizzes[] item" },
    "metrics": { "average_score": 74, "average_score_formatted": "74%", "pass_rate": 58, "pass_rate_formatted": "58%", "trainees_count": 9 },
    "results": {
      "data": [
        { "id": "attempt-uuid",
          "user": { "id": "user-uuid", "name": "Sara Ali", "email": "sara@example.com", "phone": "+9665…", "avatar": "https://…" },
          "score": 85.5, "score_percent": "85.5%", "passed": true, "status": "passed", "status_label": "Passed",
          "attempt_number": 1, "attempt_date": "2026-09-14", "attempt_date_human": "14 September 2026" }
      ],
      "links": {}, "meta": {}
    }
  }
}
```

- `metrics` are computed over **all attempts** of the selected quiz (`pass_rate` = passed attempts ÷ attempts, not per learner).
- `status: "available"` when the course is visible **and** approved, else `"closed"`.
- `results` is newest-first; `links`/`meta` are standard Laravel pagination blocks. `results` is `{data: [], links: [], meta: []}` when nothing is selected.
- The quiz must belong to the trainer's courses, otherwise **403** `This quiz does not belong to your courses.`

---

## 9. Frontend implementation checklist

**Learner app**
1. Build the list from `GET /user/courses/{course}/curriculum`; render `is_unlocked=false` items greyed, never hidden.
2. On quiz entry call `current-attempt` → if `attempt` is present show **Resume**, else **Start** (disabled when `attempts_exhausted` or `attempts_remaining = 0`).
3. `start` → keep `attempt.id`; render questions in order; run the timer locally from `quiz_duration_minutes`.
4. Always send `answers` with **every** question (use `option_id: null` for unanswered).
5. Disable the submit button after tap; treat "already submitted" 422 as success-ish and refetch state.
6. Respect `show_result`: if `false`, don't show score or pass/fail.
7. After submit, refetch the curriculum (next item may have unlocked) and, on pass, certificates/my-courses.
8. Persist `attempt.id` locally so a crash mid-quiz is recoverable (server also recovers it via `current-attempt`).

**Trainer dashboard**
1. Only offer "Add quiz" when the course is recorded **and** `has_quiz` is true; hide edit controls when `content_status` is not `draft`/`needs_revision`.
2. Validate client-side to mirror the server: one correct option, `true_false` = 2 options, 2–10 options for MCQ.
3. When editing a question, send the **full** options array.
4. Warn before submit-for-review if any quiz has `questions_count = 0`.

**Admin dashboard**
1. Review screen: `GET /admin/lessons/{lesson}` for full questions/answers; run `publish-check` before approve/publish.

---

## 10. Quick error reference

| Where | HTTP | `message` |
|---|---|---|
| Learner, any quiz call | 403 | `You are not enrolled in this course.` |
| Learner `start` | 422 | `This quiz is locked.` · `This quiz has no questions yet.` · `You have used all N attempts for this quiz.` · `This lesson is not a quiz.` |
| Learner `submit` | 422 | `This quiz attempt is invalid.` · `This quiz attempt has already been submitted.` · validation (`attempt_id`, `answers.*`) |
| Trainer create quiz | 422 | `Quizzes are not enabled for this course.` · `Live courses do not support quizzes in this phase.` · edit window closed |
| Trainer question | 422 | `Questions can only be added to a quiz.` · `Exactly one option must be marked correct.` · `A true/false question must have exactly two options.` |
| Trainer reorder questions | 422 | `The new order must list every question in the lesson.` · `One or more questions do not belong to this lesson.` |
| Trainer results | 403 | `This quiz does not belong to your courses.` |
| Any | 403 | `PERMISSION_DENIED` — missing trainer capability or someone else's course |
