# Training Program Quiz — Frontend Guide

A training program's `has_quiz` flag used to be decorative — there was no way to actually configure, author, take, or grade a program quiz. This guide covers the new feature end to end: the trainer screens for quiz settings and question authoring, and the learner screens for taking the quiz, plus how it feeds the program certificate.

- **Audience:** frontend developers (trainer dashboard, learner app/website)
- **Base path:** `{{base_url}}` = `/api/v1`
- **Related docs:** [quiz-timing-guide.md](./quiz-timing-guide.md) (the equivalent course-quiz timer feature — read the differences noted in §5.5), [quizzes.md](./quizzes.md), [training-programs-trainer.md](./training-programs-trainer.md), [training-programs-user.md](./training-programs-user.md), [trainer.md](./trainer.md) / [user.md](./user.md) (auto-generated endpoint reference)

Common headers on every call: `Accept: application/json`, `Content-Type: application/json`, `Accept-Language: en|ar`, `Authorization: Bearer {{token}}`. Responses use the standard envelope `{ "status", "message", "data" }`.

---

## 1. Concepts

- **One quiz per program.** Unlike a course (which can have many quiz lessons scattered through its curriculum), a training program has at most one quiz. Its settings — duration, max attempts, pass score, availability — live directly on the program record; there's no separate "quiz" entity to create first.
- **`has_quiz` is an admin-owned capability flag**, set when the admin creates/edits the program (`POST/PUT /admin/programs`, not covered here). It means "this program **may** have a quiz" — it does **not** guarantee questions have actually been authored yet. Every trainer/learner quiz endpoint checks it and returns a `422` if it's off.
- **Certificate gate.** If `has_certificate` is on and the program's attendance threshold is met, the certificate is normally issued automatically. When `has_quiz` is also on **and the quiz has at least one question**, the learner must additionally have passed it at least once. A `has_quiz=true` program with zero questions does **not** block the certificate — see §6.
- **Roles:** the **admin** turns `has_quiz` on/off and can author the exam in any status. The **trainer** can author it only in `draft` or `needs_revision`, gated by `edit_program_exam_settings` and `manage_program_exam_questions`. See [program exam review](./program-exam-review-frontend-guide.md). The **learner** takes it once enrolled.
- **Question/option shapes are shared with course quizzes** — the same `multiple_choice` (exactly one correct option, 2–10 options) / `true_false` (exactly 2 options) rules, the same auto-grading, the same `QuizQuestionResource`/`CurriculumItemResource::questionsWithoutAnswers()` shapes. If you've already built the course-quiz screens, the payloads below will look very familiar.

---

## 2. Endpoint map

| Audience | Method & path | Purpose | Capability needed |
|---|---|---|---|
| Trainer | `GET /trainer/programs/{program}/quiz` | Read current quiz settings | `view_programs` |
| Trainer | `PUT /trainer/programs/{program}/quiz` | Create/update quiz settings | `edit_program_exam_settings` |
| Trainer | `GET /trainer/programs/{program}/questions` | List questions | `view_programs` |
| Trainer | `POST /trainer/programs/{program}/questions` | Add a question | `manage_program_exam_questions` |
| Trainer | `POST /trainer/programs/{program}/questions/reorder` | Reorder all questions | `manage_program_exam_questions` |
| Trainer | `PUT /trainer/questions/{question}` | Update a question (shared with course quizzes) | `manage_program_exam_questions` |
| Trainer | `DELETE /trainer/questions/{question}` | Delete a question (shared with course quizzes) | `manage_program_exam_questions` |
| Learner | `GET /user/programs/{program}/quiz/current-attempt` | Read the open attempt, if any, without starting one | enrolled in the program |
| Learner | `POST /user/programs/{program}/quiz/start` | Open (or resume) an attempt | enrolled in the program |
| Learner | `POST /user/programs/{program}/quiz-attempts` | Submit an attempt for grading | enrolled in the program |

`{program}` is the program's **UUID** everywhere. `{question}` is a question's UUID — the update/delete routes are shared with the course-quiz authoring screen and resolve to whichever owner (course lesson or program) the question actually belongs to; you don't need to know or send which.

A trainer who doesn't own the program gets `403`. A learner who isn't enrolled gets `403` on every learner endpoint — check enrollment/purchase state before showing the quiz tab at all, but don't rely on the frontend check alone.

---

## 3. Trainer: quiz settings

### 3.1 Read — `GET /trainer/programs/{program}/quiz`

```http
GET {{base_url}}/trainer/programs/0199…uuid/quiz
Authorization: Bearer {{trainer_token}}
```

```json
{
  "status": "success",
  "message": "Quiz settings retrieved successfully",
  "data": {
    "has_quiz": true,
    "quiz_duration_minutes": 30,
    "quiz_max_attempts": 2,
    "quiz_pass_score": 70,
    "quiz_show_result_immediately": true,
    "quiz_available_after": "session",
    "quiz_available_after_session_id": "0199…session-uuid",
    "quiz_available_at": null
  }
}
```

If `has_quiz` is `false`, the authoring endpoints return `422`; hide the settings and questions screens.

### 3.2 Update — `PUT /trainer/programs/{program}/quiz`

**Partial update** — send only the fields that changed; everything else keeps its stored value (this matters for the availability fields, see §3.4).

```json
{
  "quiz_max_attempts": 2,
  "quiz_pass_score": 70,
  "quiz_show_result_immediately": true
}
```

Returns `200` with the same shape as §3.1 (the fresh, saved state).

### 3.3 Field reference

| Field | Type | Rule | Notes |
|---|---|---|---|
| `quiz_duration_minutes` | integer, nullable | `min:1` | `null` = untimed. Once set, an open attempt auto-expires (scored 0) after this many minutes — see §5.5. |
| `quiz_max_attempts` | integer | `min:1`, default `1` | How many attempts a learner gets in total. |
| `quiz_pass_score` | number, nullable | `0`–`100` | Percentage needed to pass. **`null` means a 0% threshold — any submitted attempt "passes."** Don't leave this unset if the certificate should actually require effort. |
| `quiz_show_result_immediately` | boolean | default `true` | Advisory only — see the warning in §5.4; the submit response always includes `score`/`passed` regardless of this flag. |
| `quiz_available_after` | enum, nullable | `session` \| `program_end` \| `datetime` | `null`/omitted = available immediately once the program has at least one question. See §3.4. |
| `quiz_available_after_session_id` | **UUID**, nullable | must be one of the program's own sessions | **Required when `quiz_available_after = "session"`.** Send the session's `id`/`uuid` exactly as returned by any session resource (`GET /trainer/programs/{program}/sessions`) — never an internal numeric id. |
| `quiz_available_at` | ISO-8601 datetime, nullable | valid date | **Required when `quiz_available_after = "datetime"`.** Send an offset (`2026-10-01T10:00:00+03:00`) or a bare `2026-10-01T10:00:00` — either way it's stored as the same absolute instant; a bare value with no offset is interpreted in the business timezone (Asia/Riyadh by default), not the browser's local timezone. |

Validation errors carry the **first** error only, with no per-field map:

```json
{ "status": "fail", "message": "A session is required when availability is set to \"after a session\".", "data": null }
```

### 3.4 Availability rules, in detail

| `quiz_available_after` | Learner can start once… |
|---|---|
| `null` / omitted | …the program has at least one question. (Immediately.) |
| `session` | …the referenced session's scheduled end time (`session_date` + `start_time` + `duration_minutes`) has passed. Unavailable while that session is still a placeholder (no `session_date`/`start_time` set yet) or hasn't ended. |
| `program_end` | …**every** real (fully scheduled) session on the program has ended. A program with zero sessions, or only unscheduled placeholder sessions, is never "ended" under this rule. |
| `datetime` | …`now() >= quiz_available_at`. |

Two things worth building UI around:

- **A partial update that would leave the rule half-configured is rejected**, even if it doesn't touch `quiz_available_after` itself. E.g. if the stored rule is `session` and you `PUT { "quiz_available_after_session_id": null }` alone, you get a `422` — you can't silently break a stored rule by only sending half of it. To switch away from `session`/`datetime`, send `quiz_available_after` explicitly (e.g. `"quiz_available_after": null`).
- **If the session behind a `session` rule is later deleted**, the quiz becomes available immediately rather than permanently stuck — there's no future point at which a deleted session's "ends at" could ever resolve. If your settings screen shows a picked session, re-fetch it defensively; don't assume a previously-selected session still exists.

### 3.5 Question authoring

Shared with the course-quiz authoring screen (`QuizQuestionRequest`/`QuizQuestionResource`) — only the owner in the URL changes.

**List** — `GET /trainer/programs/{program}/questions`

```json
{
  "status": "success",
  "message": "Questions retrieved successfully",
  "data": [
    {
      "id": "0199…q1",
      "question_type": "multiple_choice",
      "question_text": "What is EBITDA?",
      "score": 2.0,
      "sort_order": 0,
      "options": [
        { "id": "0199…o1", "option_text": "Earnings before interest, tax, depreciation & amortization", "is_correct": true },
        { "id": "0199…o2", "option_text": "Net profit", "is_correct": false }
      ]
    }
  ]
}
```

**Create** — `POST /trainer/programs/{program}/questions`

```json
{
  "question_type": "multiple_choice",
  "question_text": "What is EBITDA?",
  "score": 2,
  "options": [
    { "option_text": "Earnings before interest, tax, depreciation & amortization", "is_correct": true },
    { "option_text": "Net profit", "is_correct": false }
  ]
}
```

Returns `201` with the created question in the same shape as the list above. `sort_order` is optional (appended to the end if omitted). Rules: `question_type` is `multiple_choice` or `true_false`; `true_false` must have exactly 2 options; exactly **one** option must be `is_correct: true` regardless of type; 2–10 options.

**Update** — `PUT /trainer/questions/{question}` (all fields optional; send only what changed; sending `options` replaces the full set).

**Delete** — `DELETE /trainer/questions/{question}` — options cascade automatically.

**Reorder** — `POST /trainer/programs/{program}/questions/reorder`

```json
{ "questions": ["0199…q2", "0199…q1", "0199…q3"] }
```

Send the **complete, ordered** list of every question's UUID — a partial list (missing or unknown ids) is rejected with `422`. Returns `200` with the full re-ordered question list.

All four writes are additionally gated: `has_quiz` must be `true` on the program, or you get `422` — same as the settings endpoint. Disable/hide the "Add question" UI the same way you disable the settings screen.

---

## 4. Learner: taking the quiz

### 4.1 Current attempt — `GET /user/programs/{program}/quiz/current-attempt`

Call this when the learner opens the quiz tab, **before** showing a "Start" button — it never creates an attempt.

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

`attempt: null` means nothing is in progress — show "Start Quiz". Once an attempt is open:

```json
{
  "data": {
    "attempt": {
      "id": "0199…attempt-uuid",
      "attempt_number": 1,
      "started_at": "2026-09-29T10:00:00+00:00",
      "quiz_duration_minutes": 30
    },
    "max_attempts": 2,
    "attempts_used": 1,
    "attempts_remaining": 1,
    "questions": [ "…see §4.4 shape…" ]
  }
}
```

> This endpoint does **not** tell you whether the learner has previously passed — there is no `passed` field here. To know pass/fail, read it off the response of the `submit` call that graded the attempt (§4.3), or track it client-side after a successful submit.

### 4.2 Start (or resume) — `POST /user/programs/{program}/quiz/start`

Empty body (`{}`). Idempotent: if an attempt is already open, this **resumes** it (same `attempt.id`) instead of creating a new one — safe to call every time the learner taps "Start Quiz".

```json
{
  "status": "success",
  "message": "Quiz started successfully",
  "data": {
    "attempt": {
      "id": "0199…attempt-uuid",
      "attempt_number": 1,
      "started_at": "2026-09-29T10:00:00+00:00",
      "quiz_duration_minutes": 30
    },
    "max_attempts": 2,
    "attempts_used": 1,
    "attempts_remaining": 1,
    "questions": [
      {
        "id": "0199…q1",
        "question_type": "multiple_choice",
        "question_text": "What is EBITDA?",
        "score": 2.0,
        "sort_order": 0,
        "options": [
          { "id": "0199…o1", "option_text": "Earnings before interest, tax, depreciation & amortization" },
          { "id": "0199…o2", "option_text": "Net profit" }
        ]
      }
    ]
  }
}
```

**Status code tells you whether it's brand-new or resumed:** `201` = a new attempt was just created, `200` = an already-open attempt was resumed. Either way the body shape is identical.

Note `options` here has **no `is_correct`** — this is the learner-facing shape, unlike the trainer's authoring payload in §3.5.

### 4.3 Submit — `POST /user/programs/{program}/quiz-attempts`

```json
{
  "attempt_id": "0199…attempt-uuid",
  "answers": [
    { "question_id": "0199…q1", "option_id": "0199…o1" },
    { "question_id": "0199…q2", "option_id": null }
  ]
}
```

- `attempt_id` must be the currently-open attempt's id (from §4.1/§4.2).
- Send **one entry per question**, even unanswered ones — `option_id: null` is a deliberate "left blank," graded as incorrect. `option_id` must be present as a key even when `null` (don't omit it).

```json
{
  "status": "success",
  "message": "Quiz submitted successfully",
  "data": {
    "attempt_id": "0199…attempt-uuid",
    "attempt_number": 1,
    "score": 50.0,
    "passed": false,
    "show_result": true,
    "max_attempts": 2,
    "attempts_used": 1,
    "attempts_remaining": 1
  }
}
```

> **`score`/`passed` are always present, regardless of `quiz_show_result_immediately`.** That flag is advisory only — if it's `false`, the frontend is expected to hide the score/pass state in the UI (e.g. show "Submitted — results available later" instead), not rely on the backend withholding it.

### 4.4 A full sequence

```
GET  .../quiz/current-attempt   → attempt: null                    (show "Start Quiz")
POST .../quiz/start             → 201, attempt #1, questions[]      (render the quiz)
POST .../quiz-attempts          → passed: false, attempts_remaining: 1
GET  .../quiz/current-attempt   → attempt: null (attempt #1 is completed, nothing open)
POST .../quiz/start             → 201, attempt #2 (attempts_used now 2)
POST .../quiz-attempts          → passed: true
```

Once `attempts_remaining` reaches `0` (and the last attempt didn't pass), a further `POST .../quiz/start` returns `422` ("You have used all N attempts for this quiz.") — check `attempts_remaining` before showing "Start Quiz"/"Retake" and disable it at zero.

### 4.5 Timing — how this differs from the course quiz

If you've built the **course**-quiz screens per [quiz-timing-guide.md](./quiz-timing-guide.md), note one gap: the program quiz's `start`/`current-attempt` responses do **not** include an `expires_at` field yet, even though `quiz_duration_minutes` is returned. The server still enforces the deadline (an attempt open past `started_at + quiz_duration_minutes` auto-completes as a forfeited zero score, exactly like the course quiz), but the client has to compute the countdown target itself:

```ts
const expiresAt = quiz_duration_minutes
  ? new Date(new Date(started_at).getTime() + quiz_duration_minutes * 60_000)
  : null; // untimed
```

The rest of the course-quiz timing guidance applies unchanged: re-fetch `current-attempt` on app resume rather than trusting a client-side timer alone, and a late submit comes back as the same "already submitted" `422` rather than a score.

---

## 5. Certificate interaction

If the program has `has_certificate: true` and a `certificate_attendance_threshold`, the certificate auto-issues once:

1. Attendance meets the threshold, **and**
2. If `has_quiz: true` **and the quiz has at least one question**, the learner has passed it at least once (any past attempt with `passed: true`, not necessarily the most recent one).

If `has_quiz` is on but no question has been authored yet, condition 2 is skipped — the certificate still issues on attendance alone. There's no separate endpoint to check "is the quiz blocking my certificate" — if a learner's certificate hasn't appeared and attendance looks sufficient, check whether they have a passed attempt.

---

## 6. Error reference

| HTTP | `status` | When |
|---|---|---|
| 401 | `fail` | Missing/expired token |
| 403 | `error` | Trainer doesn't own the program; learner isn't enrolled |
| 404 | `fail` | Unknown program/question UUID |
| 422 | `fail` | FormRequest validation failure (§3.3, §3.5) — message is the **first** error only, no per-field map |
| 422 | `error` | Business-rule violation: `has_quiz` is off, quiz has no questions yet, quiz not available yet (§3.4), attempts exhausted, attempt already submitted, attempt/uuid mismatch |

Both 422 rows return the same HTTP status, but the `status` field and shape of `data` differ slightly:

```json
// validation failure (ApiMasterRequest)
{ "status": "fail", "message": "A session is required when availability is set to \"after a session\".", "data": null }

// business-rule violation (ProgramQuizService)
{ "status": "error", "message": "This quiz is not available yet.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```

Either way, **show `message` to the user** — it's already localized by `Accept-Language` — and branch on HTTP status for control flow, not on `status`/`error_code`, unless you specifically need to distinguish "fix your input" from "this action isn't allowed right now."

---

## 7. QA checklist

**Trainer**
- [ ] Settings screen is hidden/disabled when `has_quiz` is `false`.
- [ ] Picking "after a session" without picking a session shows the validation error; picking a session and saving round-trips correctly (re-`GET` shows the same session).
- [ ] Switching a stored `session`/`datetime` rule to something else works; trying to null out just the session/date half of a stored rule (without changing `quiz_available_after`) is rejected.
- [ ] A `datetime` rule entered in the trainer's local timezone opens the quiz at the intended wall-clock time for a learner in a different timezone.
- [ ] Creating a `true_false` question with anything other than exactly 2 options, or a question with 0 or 2+ correct options, shows the validation error.
- [ ] Reordering with a stale/incomplete question list is rejected; a full valid reorder persists.
- [ ] A trainer without `edit_program_exam_settings` can still view settings (if they have `view_programs`) but gets `403` on save; same split for `manage_program_exam_questions` vs question authoring.

**Learner**
- [ ] Quiz tab/CTA reflects `has_quiz` and the availability rule (§3.4) — don't show "Start Quiz" before the condition is met.
- [ ] `current-attempt` on first visit shows `attempt: null` and a "Start" CTA.
- [ ] Starting twice in a row resumes the same attempt (same `id`, same questions) rather than creating a new one.
- [ ] Submitting with an unanswered question (`option_id: null`) is accepted and graded as incorrect for that question.
- [ ] `attempts_remaining` reaching 0 disables further attempts, matching the server's `422` if bypassed.
- [ ] `show_result: false` hides score/pass state in the UI even though the API still returns them.
- [ ] A non-enrolled user hitting any of the three endpoints directly gets `403`, handled gracefully.
- [ ] Passing the quiz (with attendance already sufficient) results in the certificate appearing.

---

## 8. Backend reference

| Concern | Location |
|---|---|
| Schema (program quiz-settings columns, `quiz_questions`/`quiz_attempts.program_id`) | `database/migrations/2026_09_29_000001_add_quiz_settings_to_training_programs_table.php`, `database/migrations/2026_09_29_000002_add_program_id_to_quiz_questions_and_quiz_attempts_tables.php` |
| Attempt lifecycle, grading, availability | `app/Services/Education/ProgramQuizService.php` (mirrors `app/Services/Education/QuizService.php` for courses) |
| Certificate gate | `app/Services/Shared/CertificateService.php::issueIfEligible()` |
| Trainer settings endpoint | `app/Http/Controllers/Api/V1/Trainer/ProgramQuizController.php`, `app/Http/Requests/Trainer/Program/ProgramQuizSettingsRequest.php` |
| Trainer question authoring | `app/Http/Controllers/Api/V1/Trainer/QuizQuestionController.php` (`indexForProgram`/`storeForProgram`/`reorderForProgram`, plus the shared `update`/`destroy`) |
| Learner endpoints | `app/Http/Controllers/Api/V1/Client/TrainingProgramController.php` (`startQuiz`/`currentQuizAttempt`/`submitQuiz`) |
| Routes | `routes/api/v1/trainer.php` (`trainer.programs.quiz.*`, `trainer.programs.questions.*`), `routes/api/v1/user.php` (`user.programs.quiz.*`) |
| Tests | `tests/Feature/Education/ProgramQuizModelTest.php`, `ProgramQuizTest.php`, `ProgramCertificateQuizGateTest.php`, `ProgramQuizSettingsApiTest.php`, `ProgramQuizAuthoringApiTest.php`, `ProgramQuizLearnerApiTest.php` |
