> ## 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 (v1)

###### ***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.

***

## **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)**
7. **Per-question correctness model**
8. **Configuration**
9. **History Tracking Actions**
10. **Related Files**
11. **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**

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

```text 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"):

```text 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 + caller's own rank |
| 6.7 | POST | `/progress/recompute-scores` | Rebuild every user's score in the company (staff) |

***

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

```text 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", "position": 1, "completed": true }
      ],
      "quizzes": [
        { "quiz_id": 9, "label": "Chapter quiz", "passing_percentage": 75.0, "passed": true }
      ]
    }
  ]
}
```

***

### **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)**

```text 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)**

```text 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)**

```text 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)**

```text 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 leaderboard to a single role (validated against `roles.id`). **Defaults to the student role** (`Role::STUDENT`) so the board shows learners, not staff. The caller's own rank in `me` is computed within the same role filter. |
| `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 }
}
```

`me` always reflects the **caller's** standing, even if they are outside the top-N. Both the leaderboard rows and `me.rank` are scoped to the resolved `role_id`, so a staff caller's `me.rank` is relative to the same role being listed (student by default).

***

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

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

***

## **7. 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.

***

## **8. 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`.

***

## **9. 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.

***

## **10. 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 |
| `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) |

***

## **11. Future Suggestions**

These are deliberately **not** implemented yet — captured here for the planned "gamification award system":

1. **Real-time score updates.** Today the score is recomputed on demand (`/score`) or in bulk (`/recompute-scores`). To make the leaderboard live without a bulk job, add a small recompute hook in the two places completion happens:
   * after `LessonsController@toggle_lesson_validation` attaches/detaches a lesson, call `GamificationService::recomputeForUser($student)`;
   * after `save_student_quiz_result` persists a passing score, do the same. These are additive (no behavioural change to the existing tracking) but were left out to honour the "do not touch the quiz/exam tracking" constraint. A model observer on `QuizStudentAttempt` is an alternative that keeps the controller untouched.
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.