Skip to main content
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. 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

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)

⚠️ 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”):
A denominator of 0 yields 0.0 (never divide-by-zero).

4. How the gamification score is computed

  • 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


6.1 Training Progress (detailed)

Query params Response 200
chapters[].quizzes lists the chapter quizzes only; the training’s final quiz counts in summary.quizzes_total / quizzes_passed. Errors
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)

Query params Response 200 — standard paginator shape:

6.3 Quiz/Exam Progress (per-question breakdown)

Works for both quizzes (training/chapter-linked) and exams (standalone) — they are the same Quiz model. Query params Response 200
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)

Lists every quiz/exam the student has attempted, within the company. Query params Response 200 — paginator with rows:

6.5 Gamification Score (recompute + rank)

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
rank is dense (1 = highest score in the company).

6.6 Leaderboard (top-N)

Reads the stored gamification_score (fast). To guarantee freshness, call recompute-scores first. Query params Response 200
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)

Rebuilds gamification_score for every user in the company. Staff only (403 otherwise). Logged to history. Body: company_id (✅). Response 200

6.8 Overview (single trimmed snapshot)

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
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): 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:
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: A question with no submitted answer is counted as incorrect.

10. Configuration

config/custom_config.php:
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

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

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).