> ## Documentation Index
> Fetch the complete documentation index at: https://theteam-13fc7f49.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# User Progress & Gamification (v2)

###### ***Training / chapter / lesson completion + quiz/exam progress + score leaderboard***

This file documents `UserProgressAnalyticsController` — a **read-only** analytics layer that derives a student's learning progress and a gamification score from data that already exists in the system.

> **It does NOT modify the existing quiz/exam progress-tracking endpoints.** Those (`start_student_quiz`, `finish_student_quiz`, `save_student_quiz_result`, `show_quiz_student_answers`, `reset_student_attempts`) remain the single writer of attempts/answers and are documented in [Quizzes & Exams progress tracking](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-progress-tracking-api.md). This controller only **reads** their tables.

***

> **Boot payload (19/10/2026):** `GET /auth/user` already carries, for a student, the per-training summary
> (`my_trainings[].progress`: lessons / quizzes done and total, `overall_progress_percentage`, `is_completed`,
> `next_item`) and the per-exam attempt summary (`my_exams[].attempts`) — the same rules as §6.2 / §6.4,
> computed set-based in `App\Services\AuthUserPayloadService`. The `/progress/*` endpoints stay the
> detailed / staff view (per-chapter, per-question breakdowns, leaderboard).

## **Table of Contents**

1. **Overview**
2. **Base URL and Headers**
3. **How progress is computed (sources of truth)**
4. **How the gamification score is computed**
5. **Authorization & company scoping**
6. **Endpoints**
   * 6.1 Training Progress (detailed, one training)
   * 6.2 Trainings Progress (summary list)
   * 6.3 Quiz/Exam Progress (per-question breakdown)
   * 6.4 Quizzes/Exams Progress (summary list)
   * 6.5 Gamification Score (recompute + rank)
   * 6.6 Leaderboard (top-N)
   * 6.7 Recompute Scores (bulk, staff)
   * 6.8 Overview (single trimmed snapshot)
7. **Real-time score updates (completion hooks)**
8. **`gamification_score` in the auth-user payload**
9. **Per-question correctness model**
10. **Configuration**
11. **History Tracking Actions**
12. **Related Files**
13. **Future Suggestions**

***

## **1. Overview**

The system already records two facts elsewhere:

* **Lesson completion** — a row in the `student_lesson` pivot (written by `LessonsController@toggle_lesson_validation`).
* **Quiz/exam attempts & scores** — rows in `quiz_student_attempts` / `quiz_student_answers` (written by the progress-tracking controller).

This controller joins those, plus the content hierarchy (`trainings → chapters → lessons` and `quizzes`), to produce:

* **Per-training progress** — which lessons are completed per chapter, how many quizzes are passed, and overall progress %.
* **Per-quiz/exam progress** — best score, pass state, and a **per-question right/wrong breakdown** for any attempt.
* **A gamification score** per user (`users.gamification_score`) and a **top-N leaderboard** for the company.

Everything is **derived and idempotent** — recomputing never double-counts.

***

## **2. Base URL and Headers**

```json theme={null}
{{base_url}}/api
```

```shellscript theme={null}
Accept: application/json
Authorization: Bearer {{token}}
```

All endpoints live under the `progress` prefix and the tenant-scoped middleware stack (`auth:sanctum`, `check_if_user_is_blocked_middleware`, `company_policy_middleware`).

***

## **3. How progress is computed (sources of truth)**

| Concept | Source of truth | Rule |
| - | - | - |
| Lesson completed | `student_lesson` pivot (`student_id`, `lesson_id`) | Row exists & not soft-deleted, and the lesson still exists |
| Quiz/exam **passed** | `quiz_student_attempts` joined to `quizzes` | A **finished** attempt (`finished_at` not null) whose `score_percentage >= quizzes.passing_percentage` |
| Training lessons total | `lessons` via `chapters.training_id` | Non-deleted lessons |
| Training quizzes total | `quizzes` where `training_id = T`**or**`chapter_id ∈ chapters(T)` | Both training-level and chapter-level quizzes |

> ⚠️ **`passed_at` is NOT used as the pass signal.** In the existing tracking controller `passed_at` is set unconditionally whenever a result is saved, so it does not by itself mean "passed". The reliable signal is `score_percentage >= passing_percentage`, which is what this controller uses everywhere.

**Training progress percentages** (each lesson and each quiz counts as one "item"):

```shellscript theme={null}
lessons_progress_%  = completed_lessons / total_lessons * 100
quizzes_progress_%  = passed_quizzes   / total_quizzes  * 100
overall_progress_%  = (completed_lessons + passed_quizzes) / (total_lessons + total_quizzes) * 100
```

A denominator of 0 yields `0.0` (never divide-by-zero).

***

## **4. How the gamification score is computed**

```text theme={null}
gamification_score = (distinct completed lessons      × POINTS_PER_LESSON)        // default 10
                   + (distinct passed quizzes/exams    × POINTS_PER_QUIZ_PASSED)   // default 50
```

* **Idempotent / no double-counting** — each completed lesson and each *distinct* passed quiz/exam is counted exactly once, no matter how many attempts or re-toggles happened.
* Stored denormalized in `users.gamification_score` (an `unsignedInteger`, indexed) purely for **fast leaderboard reads**. It is rebuilt from the sources of truth by `GamificationService`, never hand-edited.
* It is **separate from `loyalty_points`** (which is monetary / convertible to coupons). Gamification score is a learning/achievement metric only.

The score is refreshed:

* **On demand** for one user via `GET /api/progress/score` (recomputes that user before returning).
* **In bulk** for a whole company via `POST /api/progress/recompute-scores` (run before showing a fresh leaderboard).

***

## **5. Authorization & company scoping**

* **Company scoping is manual.** Every endpoint requires `company_id`; `CompanyPolicyMiddleware` guarantees it matches the caller's company (super-admin bypasses).
* **Who can view a student's progress:**
  * A **student** may view **only their own** progress (`student_id` defaults to the caller).
  * **Staff** (super-admin, admin, content-manager, instructor) may view **any** student in their company by passing `student_id`.
  * Otherwise → `403`.
* **Recompute (bulk)** is **staff-only** → `403` for students.

***

## **6. Endpoints**

| # | Method | Path | Purpose |
| - | - | - | - |
| 6.1 | GET | `/progress/training/{training_id}` | Detailed progress for one training |
| 6.2 | GET | `/progress/trainings` | Progress summary per training (paginated) |
| 6.3 | GET | `/progress/quiz/{quiz_id}` | Quiz/exam progress + per-question breakdown |
| 6.4 | GET | `/progress/quizzes` | Quiz/exam progress summary (paginated) |
| 6.5 | GET | `/progress/score` | Recompute + return a user's score, breakdown, rank |
| 6.6 | GET | `/progress/leaderboard` | Top-N users by score (students by default; `role_id` to override) + caller's own rank |
| 6.7 | POST | `/progress/recompute-scores` | Rebuild every user's score in the company (staff) |
| 6.8 | GET | `/progress/overview` | One trimmed snapshot: gamification + trainings + quizzes/exams |

***

### **6.1 Training Progress (detailed)**

```shellscript theme={null}
GET /api/progress/training/{training_id}
```

**Query params**

| Param | Type | Required | Notes |
| - | - | - | - |
| `company_id` | int | ✅ | Tenant |
| `student_id` | int | ❌ | Defaults to the authenticated user; staff only for others |

**Response `200`**

```json theme={null}
{
  "training_id": 12,
  "training_label": "Intro to Laravel",
  "student_id": 87,
  "summary": {
    "lessons_total": 20,
    "lessons_completed": 13,
    "lessons_progress_percentage": 65.0,
    "quizzes_total": 4,
    "quizzes_passed": 2,
    "quizzes_progress_percentage": 50.0,
    "overall_progress_percentage": 62.5,
    "is_completed": false
  },
  "chapters": [
    {
      "chapter_id": 31,
      "label": "Getting Started",
      "position": 1,
      "lessons_total": 5,
      "lessons_completed": 5,
      "lessons_progress_percentage": 100.0,
      "lessons": [
        { "lesson_id": 101, "label": "Install", "type": "document", "position": 1, "completed": true }
      ],
      "quizzes": [
        { "quiz_id": 9, "label": "Chapter quiz", "passing_percentage": 75.0, "passed": true }
      ]
    }
  ]
}
```

`chapters[].quizzes` lists the chapter quizzes only; the training's final quiz counts in `summary.quizzes_total` / `quizzes_passed`.

**Errors**

| Status | When | Body |
| - | - | - |
| `403` | A student asks for another student's progress | `{ "message": "You are not authorized to view this student's progress." }` |
| `404` | The student or the training is not in this company | `{ "message": "Student not found in this company." }` / `{ "message": "Training not found in this company." }` |
| `422` | `company_id` missing or unknown, `student_id` unknown | Laravel validation error (`message` + `errors`). It was a `500` before 05/11/2026. |
| `500` | Unexpected error | `{ "message": "<exception text>" }` |

**Fixed on 05/11/2026.** Until then this endpoint answered `500` for **every training that has a
quiz**, with `{ "message": "The attribute [instructor_id] either does not exist or was not retrieved for model [App\\Models\\Quiz]." }`
(the admin learner preview showed it as "The chapter tree could not be loaded"). The quiz query
selected five columns without `instructor_id`, while `Quiz::$with = ['instructor']` eager-loads
the instructor, and `Model::shouldBeStrict()` refuses to read a column that was not selected. The
query now runs `->without('instructor')` (the instructor is not used here). Guarded by
`tests/Feature/TrainingProgressQuizzesTest.php`.

***

### **6.2 Trainings Progress (summary list)**

```text theme={null}
GET /api/progress/trainings
```

**Query params**

| Param | Type | Required | Notes |
| - | - | - | - |
| `company_id` | int | ✅ | Tenant |
| `student_id` | int | ❌ | Defaults to caller; staff only for others |
| `scope` | string | ❌ | `enrolled` (default) = trainings the student owns; `all` = every training in the company (staff only — students are always forced to `enrolled`) |
| `page`, `per_page` | int | ❌ | Pagination; `per_page` default 15, max 200 |

**Response `200`** — standard paginator shape:

```json theme={null}
{
  "student_id": 87,
  "data": [
    {
      "training_id": 12,
      "training_label": "Intro to Laravel",
      "slug": "intro-to-laravel",
      "lessons_total": 20,
      "lessons_completed": 13,
      "quizzes_total": 4,
      "quizzes_passed": 2,
      "overall_progress_percentage": 62.5,
      "is_completed": false
    }
  ],
  "current_page": 1, "per_page": 15, "total": 3, "last_page": 1
}
```

***

### **6.3 Quiz/Exam Progress (per-question breakdown)**

```shellscript theme={null}
GET /api/progress/quiz/{quiz_id}
```

Works for both **quizzes** (training/chapter-linked) and **exams** (standalone) — they are the same `Quiz` model.

**Query params**

| Param | Type | Required | Notes |
| - | - | - | - |
| `company_id` | int | ✅ | Tenant |
| `student_id` | int | ❌ | Defaults to caller; staff only for others |
| `attempt_id` | int | ❌ | Which attempt to break down. Default: **best finished** attempt (highest score), else the latest |

**Response `200`**

```json theme={null}
{
  "quiz_id": 9,
  "quiz_label": "Final Exam",
  "is_exam": true,
  "student_id": 87,
  "passing_percentage": 75.0,
  "summary": {
    "attempts_count": 3,
    "finished_attempts_count": 3,
    "best_score_percentage": 90.0,
    "passed": true,
    "questions_total": 10
  },
  "attempts": [
    {
      "attempt_id": 555, "attempt": 1, "finished_at": "2026-06-01 10:00:00",
      "correct_answers": 6, "uncorrect_answers": 4, "score_percentage": 60.0,
      "passed": false, "passed_at": "2026-06-01 10:10:00"
    }
  ],
  "reviewed_attempt": {
    "attempt_id": 557,
    "attempt": 3,
    "questions_correct": 9,
    "questions_total": 10,
    "questions": [
      {
        "question_id": 401,
        "label": "What is Eloquent?",
        "type_id": 2,
        "is_correct": true,
        "explanation": "Laravel's ORM.",
        "selected_option_ids": [12],
        "selected_option_item_ids": [],
        "options": [
          { "option_id": 12, "label": "An ORM", "is_correct": true,  "selected": true  },
          { "option_id": 13, "label": "A queue",  "is_correct": false, "selected": false }
        ]
      }
    ]
  }
}
```

> `reviewed_attempt` is `null` if the student has no attempts on this quiz. `passed` (in `summary`/`attempts`) is derived from `score_percentage >= passing_percentage`.

***

### **6.4 Quizzes/Exams Progress (summary list)**

```shellscript theme={null}
GET /api/progress/quizzes
```

Lists every quiz/exam the student **has attempted**, within the company.

**Query params**

| Param | Type | Required | Notes |
| - | - | - | - |
| `company_id` | int | ✅ | Tenant |
| `student_id` | int | ❌ | Defaults to caller; staff only for others |
| `is_exam` | bool | ❌ | `true` = standalone exams only; `false` = training/chapter quizzes only |
| `training_id` | int | ❌ | Restrict to quizzes of that training (training-level + chapter-level) |
| `page`, `per_page` | int | ❌ | Pagination; `per_page` default 15, max 200 |

**Response `200`** — paginator with rows:

```json theme={null}
{
  "quiz_id": 9,
  "quiz_label": "Final Exam",
  "is_exam": true,
  "training_id": null,
  "chapter_id": null,
  "passing_percentage": 75.0,
  "attempts_count": 3,
  "best_score_percentage": 90.0,
  "passed": true
}
```

***

### **6.5 Gamification Score (recompute + rank)**

```shellscript theme={null}
GET /api/progress/score
```

Recomputes the target student's score from the sources of truth, persists it, and returns the breakdown + the student's company rank.

**Query params:** `company_id` (✅), `student_id` (❌, staff only for others).

**Response `200`**

```json theme={null}
{
  "student_id": 87,
  "company_id": 1,
  "rank": 4,
  "breakdown": {
    "lessons_completed": 13,
    "quizzes_passed": 2,
    "points_per_lesson": 10,
    "points_per_quiz_passed": 50,
    "lessons_score": 130,
    "quizzes_score": 100,
    "gamification_score": 230
  }
}
```

`rank` is dense (1 = highest score in the company).

***

### **6.6 Leaderboard (top-N)**

```shellscript theme={null}
GET /api/progress/leaderboard
```

Reads the **stored** `gamification_score` (fast). To guarantee freshness, call `recompute-scores` first.

**Query params**

| Param | Type | Required | Notes |
| - | - | - | - |
| `company_id` | int | ✅ | Tenant |
| `role_id` | int | ❌ | Restrict the board to a single role. **Defaults to `1` (Student)** when omitted, so the leaderboard shows students only by default. Must exist in `roles`. |
| `limit` | int | ❌ | Top-N size; default `config('custom_config.gamification.leaderboard_size')` = 10; max 100 |

**Response `200`**

```json theme={null}
{
  "company_id": 1,
  "role_id": 1,
  "limit": 10,
  "points_per_lesson": 10,
  "points_per_quiz_passed": 50,
  "leaderboard": [
    { "rank": 1, "user_id": 5,  "first_name": "Sara",  "last_name": "B", "image_id": 22, "gamification_score": 540 },
    { "rank": 2, "user_id": 87, "first_name": "Omar",  "last_name": "K", "image_id": null, "gamification_score": 230 }
  ],
  "me": { "user_id": 87, "rank": 2, "gamification_score": 230 }
}
```

Both the board and `me.rank` are scoped to the resolved `role_id` (students by default). `me` always reflects the **caller's** standing within that role pool, even if they are outside the top-N — note that if the caller's own role differs from `role_id`, their rank is computed relative to the requested role's pool.

***

### **6.7 Recompute Scores (bulk, staff)**

```shellscript theme={null}
POST /api/progress/recompute-scores
```

Rebuilds `gamification_score` for **every user** in the company. **Staff only** (`403` otherwise). Logged to history.

**Body:** `company_id` (✅).

**Response `200`**

```json theme={null}
{
  "message": "Gamification scores recomputed.",
  "company_id": 1,
  "users_updated": 412
}
```

***

### **6.8 Overview (single trimmed snapshot)**

```shellscript theme={null}
GET /api/progress/overview
```

One compact call that returns everything you need to render a "my progress" screen — **no nested question banks, no order graphs, no useless stuff**. It recomputes the gamification score first, then lists the student's **enrolled** trainings (trimmed summaries) and **every quiz/exam they've attempted** (trimmed summaries).

**Query params:** `company_id` (✅), `student_id` (❌, staff only for others).

**Response `200`**

```json theme={null}
{
  "user": { "id": 87, "first_name": "Omar", "last_name": "K", "image_id": null },
  "gamification": {
    "gamification_score": 230,
    "rank": 2,
    "lessons_completed": 13,
    "quizzes_passed": 2,
    "points_per_lesson": 10,
    "points_per_quiz_passed": 50
  },
  "totals": {
    "trainings_count": 3,
    "trainings_completed": 1,
    "quizzes_attempted": 4,
    "quizzes_passed": 2
  },
  "trainings": [
    {
      "training_id": 12, "training_label": "Intro to Laravel", "slug": "intro-to-laravel",
      "lessons_total": 20, "lessons_completed": 13,
      "quizzes_total": 4, "quizzes_passed": 2,
      "overall_progress_percentage": 62.5, "is_completed": false
    }
  ],
  "quizzes_exams": [
    {
      "quiz_id": 9, "quiz_label": "Final Exam", "is_exam": true,
      "training_id": null, "chapter_id": null, "passing_percentage": 75.0,
      "attempts_count": 3, "best_score_percentage": 90.0, "passed": true
    }
  ]
}
```

> `trainings` are the student's **enrolled** (owned-by-slug) trainings; `quizzes_exams` are only the ones they have **attempted**. For a single training's chapter/lesson detail or a quiz's per-question breakdown, drill in with §6.1 / §6.3.

***

## **7. Real-time score updates (completion hooks)**

`gamification_score` stays fresh automatically — it is recomputed at the exact moments completion happens, so the leaderboard reflects activity immediately (no need to poll `/score` or run `/recompute-scores` for day-to-day use):

| Trigger | Where | Effect |
| - | - | - |
| Lesson marked complete/incomplete | `LessonsController@toggle_lesson_validation` | Recomputes the affected student's score after the `student_lesson` row is attached/detached |
| Quiz/exam result saved | `Quizzes_Exams_Progress_Tracking_Controller@save_student_quiz_result` | Recomputes the student's score after the attempt's score is persisted |

Both hooks call `GamificationService::recomputeForUser($student)` and are **wrapped in their own try/catch** — a scoring hiccup is logged via `report()` and **never** breaks the underlying lesson-toggle or result-save response. The recompute is fully idempotent, so firing it on every toggle/save can never double-count.

> These hooks are **additive**: they did not change the request/response contract or the stored data of the existing lesson and quiz/exam tracking endpoints. `recompute-scores` (§6.7) remains available for backfills or after changing point weights.

***

## **8. `gamification_score` in the auth-user payload**

`GET /api/auth/user` (the `auth_user` endpoint, `UserResource`) now includes the caller's score as a top-level field:

```json theme={null}
{ "id": 87, "first_name": "Omar", "loyalty_points": 40, "gamification_score": 230, "...": "..." }
```

This is intentionally **just the integer** — the auth-user payload is already large, so the full per-training / per-quiz breakdown is **not** embedded there. For the trimmed progress lists, call `GET /api/progress/overview` (§6.8); for deep detail use §6.1 / §6.3.

***

## **9. Per-question correctness model**

The breakdown in §6.3 mirrors exactly how `finish_student_quiz` stores answers in `quiz_student_answers`:

| Question type (`quiz_question_type_id`) | Stored answer rows | Correct when… |
| - | - | - |
| 1 True/False, 2 Single, 3 Multiple | one row per selected option (`quiz_question_option_id`, item id null) | the **set** of selected option ids equals the **set** of `is_correct` option ids for that question (and is non-empty) |
| 4 Drag & Drop | one row per placed item (`quiz_question_option_id` = the bucket it was dropped into, `quiz_question_option_item_id` = the item) | **every** item is placed into its **own parent option** (the bucket it belongs to) **and** all items are placed |

A question with no submitted answer is counted as incorrect.

***

## **10. Configuration**

`config/custom_config.php`:

```php theme={null}
'gamification' => [
    'points_per_lesson'      => env('GAMIFICATION_POINTS_PER_LESSON', 10),
    'points_per_quiz_passed' => env('GAMIFICATION_POINTS_PER_QUIZ_PASSED', 50),
    'leaderboard_size'       => env('GAMIFICATION_LEADERBOARD_SIZE', 10),
],
```

Override per environment via `.env`. **Changing point values does not retro-edit stored scores** — run `recompute-scores` afterwards to apply new weights.

**Migration:** `2026_06_08_000000_add_gamification_score_to_users_table.php` adds the indexed `users.gamification_score` column (default 0). Run `php artisan migrate`.

***

## **11. History Tracking Actions**

| Action | `table_name` | When |
| - | - | - |
| `recompute_gamification_scores` | `users` | Bulk recompute (`recompute-scores`) |

Read endpoints do not write history. Per-user `score` recompute is a lightweight idempotent write and is intentionally not logged.

***

## **12. Related Files**

| File | Role |
| - | - |
| `app/Http/Controllers/UserProgressAnalyticsController.php` | All endpoints in this doc |
| `app/Services/GamificationService.php` | Score computation + persistence |
| `database/migrations/2026_06_08_000000_add_gamification_score_to_users_table.php` | `users.gamification_score` column |
| `config/custom_config.php` | Point values + leaderboard size |
| `app/Models/User.php` | `gamification_score` fillable/cast |
| `app/Http/Resources/UserResource.php` | exposes `gamification_score` on the auth-user payload (§8) |
| `app/Http/Controllers/LessonsController.php` | real-time score hook on lesson toggle (§7) |
| `app/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.php` | real-time score hook on result save (§7) — otherwise unchanged |
| `routes/api.php` | `progress/*` route group |
| [Quizzes & Exams progress tracking](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-progress-tracking-api.md) | The **unchanged** writer of attempts/answers (read-only consumed here) |

***

## **13. Future Suggestions**

Captured here for the planned "gamification award system":

1. ✅ **Real-time score updates — IMPLEMENTED.** The score is recomputed live at both completion points (lesson toggle + quiz/exam result save) — see §7. The on-demand (`/score`) and bulk (`/recompute-scores`) paths remain for backfills and weight changes. *(A model observer on `QuizStudentAttempt` remains a possible refactor if you ever want the hook to live outside the controller entirely.)*
2. **A score-event ledger** (`gamification_score_events`: user\_id, source\_type, source\_id, points, awarded\_at) for a full audit trail and "how did I earn this" UI, while keeping `users.gamification_score` as the denormalized cache. Enables streaks, daily caps, and reversals.
3. **Badges / achievements** (`badges`, `badge_user` pivot) — e.g. "First Course Completed", "Perfect Exam", "7-day streak". The current score is the foundation; badges become rule evaluations over the same sources of truth.
4. **Streaks** — consecutive-day activity from `student_lesson.created_at` and attempt timestamps; bonus points for maintaining streaks.
5. **Cross-company / global leaderboard** for super-admins (a `scope=global` option), and **time-windowed** leaderboards (weekly/monthly) using the score-event ledger.
6. **Configurable weights per company** — move point values into `loyalty_points_settings`-style per-company rows instead of global config, so each tenant tunes its own gamification.
7. **Caching the leaderboard** (e.g. 1–5 min cache) for high-traffic tenants, invalidated on `recompute-scores`.
8. **Completion certificates trigger** — when `overall_progress_percentage` hits 100, auto-suggest issuing the training certificate (ties into the existing certification module).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.