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

# Auth User Optimize — Slim GET /auth/user

> Plan to cut the student boot payload of GET /auth/user from 1.5 MB to ~20 KB — what the payload holds today, why it is slow, the target shape, the backend / frontend / docs work, rollout order and how to measure it.

Mirror of **`LMS_Project/AUTH_USER_OPTIMIZE.md`** (written 13/10/2026). It was written from the code as it
is on `main` in `LMS_Backend`, `LMS_Frontend` and `LMS_Documentation`, plus the captured payload
`LMS_Frontend/docs/api_samples/student_fivermail/02_GET_auth_user.full.json`.

**Status: DONE ✅ (19/10/2026).** Shipped in the three repos on the `chat+ai` branch (backend
`AuthUserPayloadService` + `?legacy=1`, frontend Update 125, this site). What was built and what
was left out on purpose is listed in [§0 What shipped](#0-what-shipped-19102026) right below.

Goal: a student who logs in on the web app should see the dashboard in well under a second of
API time instead of 6–7 s, without touching the Flutter renderer (no wasm). The lever is the
boot payload: `GET /auth/user` was 1.5 MB and is now \~20 KB.

## 0. What shipped (19/10/2026) ✅

| Task | Status |
| :- | :- |
| §4 target shape: `my_trainings[]` + `progress`, `my_exams[]` + `attempts`, `validated_lesson_ids`, flat `certifications`, `cart_summary`, 3 notifications | DONE ✅ — `LMS_Backend/app/Services/AuthUserPayloadService.php`, `AuthController::auth_user()`. Extra: `progress.next_item` (`type`, `number`, `id`, `label`) feeds the "next: Lesson 65 · …" line of the Continue-learning card |
| §5.2 progress computed in the backend, set-based | DONE ✅ — `AuthUserPayloadService::trainingProgress()`: 6 queries for any number of trainings / lessons |
| §5.4 attempt arithmetic owned by the backend | DONE ✅ — `examAttempts()`; the same block on `GET /quiz/{id}` (student caller) and `allowed_attempts` / `reset_count` / `attempts_used` on every `show_quiz_student_answers` row |
| §5.5 attempts fetched when the Attempts tab opens, `summary=1`, `attempt_id` for the review | DONE ✅ — backend flags + `QuizProvider.loadStudentExamAttempts()` / `openExamAttemptReview()` |
| §5.7 notifications: 3 at boot, popup fetches 10 | DONE ✅ — `NotificationProvider.loadLatestNotifications()` on popup open. The SharedPreferences offset was kept (unchanged behavior) |
| §5.8 cart: `cart_summary` badge, items fetched on demand | DONE ✅ — `CartProvider.updateCartSummary()`; `fetchCartDetails()` runs in the background at boot when the cart is not empty (catalog "in cart" flags) and no longer throws (`Cart.order_status` object, `pivot.price` number) |
| §6.2 `?legacy=1` transition flag | DONE ✅ — slim is the **default**, `?legacy=1` returns the old `UserResource` graph for one release |
| §6.3 `GET /quiz/{id}` `attempts`, `show_quiz_student_answers` fields | DONE ✅. `GET /user/{id}` and `toggle_lesson_validation` responses unchanged: the app updates `validated_lesson_ids` locally after a toggle and no longer re-fetches the user |
| §6.4 indexes | DONE ✅ — migration `2026_10_19_100000_add_progress_indexes_to_student_tables` (run `php artisan migrate` on each backend) |
| §6.5 tests | DONE ✅ — `tests/Feature/AuthUserPayloadTest.php` (12 tests: shape, no leaks, dedup / revoked, progress, next item, attempts ceiling, dates, staff + query budget ≤ 30, legacy flag, summary / attempt\_id, quiz attempts block) |
| §7 frontend models, boot fan-out, progress helpers, exams grid, attempts page, notifications, cart, certificates (B3) | DONE ✅ — `TrainingProgress`, `ExamAttemptsSummary`, `CartSummary`; the progress helpers read `training.progress` first and walk the tree only for the player's full training |
| §7.4 `quiz_home_main.dart` future hoisted out of `build()` | DONE ✅ |
| §7.2 statistics call no longer awaited in series | DONE ✅ — runs concurrently with the fan-out; a failure can no longer block the boot |
| Phone dashboard pull-to-refresh (re-fetches `/auth/user` + statistics) | DONE ✅ — `AppService.refreshAuthUser()`, `AppPullToRefresh` on `OverviewScreenMobile` |
| §8 docs | DONE ✅ — [auth.mdx §9](https://theteam-13fc7f49.mintlify.site/api-reference/auth.md), [progress tracking §5.4](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-progress-tracking-api.md), [quiz show](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-endpoints.md), [notifications](https://theteam-13fc7f49.mintlify.site/api-reference/notifications.md), [user progress](https://theteam-13fc7f49.mintlify.site/api-reference/user-progress.md), [student boot payload](https://theteam-13fc7f49.mintlify.site/api-reference/student-boot-payload.md) (renamed from `untitled-page`) |
| §9 committed payload dump | DONE ✅ — `02_GET_auth_user.full.json` removed from the repo, `*.full.json` git-ignored, `capture.sh` reads the password from the environment only, sample e-mails redacted. History **not** purged (the user decides) |
| **B5 answer-key leak in the boot payload** | **Not a goal (user decision, 19/10/2026).** It disappears anyway, because the slim payload carries no questions; `GET /quiz/{id}` still ships `is_correct` to a student before an attempt (§12) and that is left as is |
| §7.9 paint before the network (cached payload) | Not done — not needed with a \~20 KB payload |
| §6.6 `UserResource` clean-up | Partly — the class and its order / attempt branches stay until `?legacy=1` is dropped |
| Rollout | Slim is the default right away (both repos deploy together from `chat+ai`); a stale mobile build keeps working with `?legacy=1` only if it sends it, so update the APK with the backend |

Measured on 19/10/2026 on a local backend with a seeded student (2 trainings, 2 exams): slim **6.3 KB**
vs legacy **15.3 KB**, 28 SQL queries. The phone app was run on the Android emulator against that
backend: dashboard, My Trainings, Simulator, Exam Attempts, attempt review, course player and the new
pull-to-refresh all work on the slim payload.

***

## Table of contents

0. [What shipped (19/10/2026)](#0-what-shipped-19102026)
1. [What happens today (measured)](#1-what-happens-today-measured)
2. [Why it is slow: three costs, not one](#2-why-it-is-slow-three-costs-not-one)
3. [What the student app actually reads from the payload](#3-what-the-student-app-actually-reads-from-the-payload)
4. [Target shape of `GET /auth/user` (student)](#4-target-shape-of-get-/auth/user-student)
5. [Answers to the specific questions](#5-answers-to-the-specific-questions)
6. [Backend plan (`LMS_Backend`)](#6-backend-plan-lms_backend)
7. [Frontend plan (`LMS_Frontend`, student side)](#7-frontend-plan-lms_frontend-student-side)
8. [Documentation plan (`LMS_Documentation`)](#8-documentation-plan-lms_documentation)
9. [Repo hygiene: the committed payload dump](#9-repo-hygiene-the-committed-payload-dump)
10. [Rollout order and compatibility](#10-rollout-order-and-compatibility)
11. [How to measure before / after](#11-how-to-measure-before-/-after)
12. [Out of scope (but related)](#12-out-of-scope-but-related)

***

## 1. What happens today (measured)

### 1.1 The boot sequence on the web app

```text theme={null}
app start / F5 / login
  └─ app.dart:52-56  →  AppService.authUserAndRole()        (awaited: nothing is painted until it returns)
       ├─ GET /auth/user                                     1.5 MB for the sample student
       ├─ handleStudentRole()  app_service.dart:212-264      fans the payload out into 6 providers
       └─ GET /dashboard/user/{id}/progress   (:231)         awaited in series, after the first call
```

Nothing is cached between boots. Every page reload re-downloads and re-parses the full payload.

### 1.2 What the 1.5 MB is made of

From `02_GET_auth_user.full.json` (user id 1, student, company 1 — 1 550 159 bytes serialized):

| Key | Bytes | Share | What is inside |
| :- | -: | -: | :- |
| `orders` | 1 350 526 | 87.1 % | 19 orders. 4 trainings (767 lessons, 239 quiz questions) and **6 exams with 180 questions each, every option carrying `is_correct`** (3 of those exams are `access_revoked`) |
| `cart` | 155 109 | 10.0 % | 1 exam in the cart, again with its full question bank and answer key |
| `lessons` | 33 933 | 2.2 % | 65 validated lessons, each with its full `content` body |
| `notifications` | 5 864 | 0.4 % | last 10 notifications, 16 columns each |
| `quiz_student_attempts` | 3 153 | 0.2 % | 13 rows: `id, quiz_id, score, created_at, quiz_questions` |
| everything else | \< 1 500 | \~0 % | profile, `company`, `role`, `certifications`, `image` |

Counters over the whole document: `"is_correct"` appears **5 790 times**, `"content"` 65 times.

The analysis page `LMS_Documentation/api-reference/untitled-page.mdx` (§1.2) says the quizzes nested in
`orders[].quizzes[]` are summaries without `quiz_questions`. That is wrong for the committed sample: the
owned exams carry 180 questions each. `OrderResource` serializes them with `QuizResource` in its
default `full` shape and `UserResource` eager-loads `quizzes.quiz_questions.*`.

### 1.3 Where the shape comes from

| Layer | File | What it does |
| :- | :- | :- |
| Controller | `LMS_Backend/app/Http/Controllers/AuthController.php:62-146` | `User::with([... 'cart' ⇒ trainings.chapters.quizzes.quiz_questions.quiz_question_options.quiz_question_option_items ..., 'orders', 'quiz_student_attempts.quiz_questions.quiz_question_options', 'lessons', 'reviews', 'images_uploaded', 'company.categories', 'company.tags', 'trainings_as_instructor', 'certifications.training', 'certifications.quiz'])`, then two notification queries, then `UserResource::make($user)` |
| Resource | `app/Http/Resources/UserResource.php` | Lazily `->load()`s the deep `orders.trainings.chapters…` and `wishlist.*` graphs **inside the resource** (lines 112-162), maps attempts with their questions, returns raw `$this->lessons` (full `Lesson` models with `content`) |
| Nested | `OrderResource` → `TrainingResource` (`full`) → `QuizResource` (`full`) | `QuizResource` full emits `quiz_questions[].quiz_question_options[].is_correct` plus `explanation`, and `'instructor' => $this->instructor` (a `User` with `email` and `default_revenue_percentage`, see `Quiz::instructor()` select list) |
| Model side-effects | `app/Models/Training.php:60`, `app/Models/Quiz.php:43,58` | `$appends` on both models (`average_rating`, `total_ratings`, `total_sales`, `total_active_sales`, lesson/chapter counts) run **one SQL query each, per model, at serialization time**; `Quiz::$with = ['instructor']` auto-joins the instructor on every quiz object |

***

## 2. Why it is slow: three costs, not one

1. **Database time (server).** The eager-load tree is \~30 queries, but the `$appends` accessors add
   roughly 6 queries per `Training` and 5 per `Quiz` *at serialization* (`reviews()->avg`, `reviews()->count`,
   `orders()->count` ×2, `chapters()->sum`, `chapters()->count`). The sample serializes 4 trainings, 2 chapter
   quizzes, 7 exams (6 in orders + 1 in cart) → \~60 extra queries, each a real round-trip, and none of them
   are read by the student UI. Add the two notification queries and the `wishlist`/`orders` lazy loads that run
   inside the resource.
2. **Bytes on the wire (network).** 1.5 MB of JSON. If the Plesk/nginx vhost does not gzip `application/json`
   this is 1.5 MB on a Tunisian mobile connection; gzipped it is still \~150 KB that has to be decoded.
3. **Parse and fan-out time (browser main thread).** Dart's `jsonDecode` + `json_serializable` `fromJson` for
   \~1 500 question objects, then `handleStudentRole()` and three separate call sites
   (`overview_screen.dart:59-61`, `student_space.dart:27-29`, `student_statefulshell.dart:54-66`) that walk
   `orders[].trainings[].chapters[].lessons[]` **on every build** to set `lesson.isValidate`. On CanvasKit web
   this all runs on the UI thread, so the spinner stays up while it runs.

Shrinking the payload fixes all three at once. Date formatting (question in §5.6) is not one of the costs.

***

## 3. What the student app actually reads from the payload

Traced in `LMS_Frontend/lib` (details in `LMS_Frontend/authed_user_and_attempts.md` and the agent trace of
13/10/2026). `User` model: `lib/core/data/remote/user.dart:7-78`. Fan-out: `lib/core/providers/app_service.dart:212-264`.

### 3.1 Read and needed at boot

| Payload key | Who reads it | Fields really used |
| :- | :- | :- |
| profile scalars (`id, company_id, role_id, first_name, last_name, email, phone_number, image_id, birthday, bio, two_factor_enabled, public_profile_about_me/skills/linkedin_url/verification`) | settings / profile screens, `handleUserRole` switches on `role.id` (`app_service.dart:128`) | as listed |
| `image` | app bar avatar | `url` |
| `company` | price / currency display | `id, label, currency` |
| `role` | role routing, permission-gated widgets | `id, label` (+ `permissions` for staff roles) |
| `notifications` + `notification_count` | bell badge + web popup (`student_appbar_widget.dart:195-290`) | `id, title, message, notification_type_id, notif_read_status, created_at` |
| `orders[].trainings[]` → `myTrainings` | dashboard "Continue learning", My Trainings grid, stat cards | `id, label, slug, image.url, instructor{first_name,last_name,image}, duration, updated_at, views_count, pivot.access_revoked`, and the **id/label tree** `chapters[].lessons[].id`, `chapters[].quiz.{id,passing_percentage}`, `quiz.{id,passing_percentage}` (only to compute progress client-side) |
| `orders[].quizzes[]` → `myExams` | My Exams grid (`enrolled_quizzes_screen.dart:155,218`), attempts table labels | `id, label, description, duration, passing_percentage, max_attempts, instructor{first_name,last_name,image}, pivot.access_revoked`, and `quiz_questions.length` **only as a count** (`exam_card_with_progress.dart:38` blocks the tap when the list is empty) |
| `lessons[]` → `currentUser.lessons` | progress helper, course player `validateLessonsInTraining` (`training_details_home_main_screen.dart:47-50`) | **`id` only** |
| `quiz_student_attempts[]` → `studentQuizAttempts` / `studentExamAttempts` | progress helper "quiz passed", Attempts tab | `quiz_id, score_percentage, correct_answers, uncorrect_answers, passed_at, finished_at` (the backend sends **none** of these, see §3.3) |
| `certifications[]` | dashboard stat, tab badge, "earned" checks in 5 widgets | `training_id, quiz_id, created_at` (the backend sends `training{id,label}` / `quiz{id,label}` instead, see §3.3) |
| `cart` | `CartProvider.userCart` at boot; nothing re-fetches it on the cart page | `id, coupon, total_price, discounted_price, trainings[]/quizzes[]` card fields |

### 3.2 Sent but never read by the Flutter app

`applied_coupons`, `classroom_joined_slugs`, `classroom_admin_slugs`, `owned_trainings`, `owned_exams`,
`public_profile_website/description/phone_number/location/email`, `hide_public_profile`, `loyalty_points`,
`trainings_as_instructor`, `reviews`, `wishlist`, `images_uploaded` (already
commented out), `lessons[].content`, every `quiz_questions[]` inside
`orders[].trainings` and `cart`, every `quiz_question_option_items[]`, `explanation`, `is_correct`, `revenue_percentage`,
`total_sales`, `total_active_sales`, `average_rating`, `total_ratings` on nested quizzes/trainings, `pivot.price`.

**Re-checked on 19/10/2026 (every read site of `lib/` traced) — three keys of the original list WERE read:**

* `default_revenue_percentage` → `User.revenuePercentage`: the instructor create-training / create-quiz
  screens take the logged-in instructor as the selected instructor and read it (`basic_info_screen.dart`,
  `quiz_basic_info_widget.dart`, `add_trainings_screen.dart`, `create_quiz.dart`). **Kept** in the slim payload.
* `quiz_student_attempts[].quiz_questions` → the exam-attempt review read the student's answers (`pivot`)
  from the cached row. **Replaced**: the review now fetches the single attempt with
  `show_quiz_student_answers?quiz_id&attempt_id`.
* `orders[].quizzes[].quiz_questions` → `.length` on the My Exams cards and the "no questions" start gate,
  plus the review fallback. **Replaced** by `quiz_questions_count`.

`explanation`, `is_correct` and `quiz_question_option_items` were read only on a fallback path (when
`GET /quiz/{id}` fails); `loyalty_points` only in commented code. Everything else is confirmed unread.

### 3.3 Bugs caused by the current shape (they disappear with the new shape)

| # | Symptom | Cause |
| :- | :- | :- |
| B1 | "Continue learning" always shows **0/N quizzes passed** | `/auth/user` attempt rows have no `score_percentage`, so `passed()` in `continue_learning_card.dart:136-173` is always false |
| B2 | Attempts tab shows **0 correct / 0 incorrect / 0 % / "unfinished"** for every row | same rows; the table reads `correctAnswers`, `uncorrectAnswers`, `scorePercentage`, `passedAt` (`exam_attempts_datasource.dart:157-171`) |
| B3 | Certificate "earned" badge is false until the Certificates tab is opened | checks compare `certificate.trainingId`/`quizId`, but the payload sends nested `training{id}`/`quiz{id}` |
| B4 | Opening one training zeroes the other trainings' quiz counts | `getTrainingWithQuizAttempts` replaces the global `studentQuizAttempts` list (`training_quiz_provider.dart:243,270`) |
| B5 | **Answer-key leak**: `is_correct` for every owned exam and the cart exam is in the student's network tab before any attempt — **ignored on purpose (user decision, 19/10/2026): not a goal of this work**; it still disappears from `/auth/user` because the slim payload carries no questions | `QuizResource` `full` shape through `OrderResource` |
| B6 | Instructor `email` + `default_revenue_percentage` leak on every nested quiz | `Quiz::$with = ['instructor']` + `QuizResource` `'instructor' => $this->instructor` |
| B7 | Attempt ceiling shown to the student is guessed client-side and goes stale after an admin reset | `show_quiz_student_answers` returns `reset_cycle` but not `allowed_attempts` / `reset_count` (rules 1–2 in `quiz_provider.dart:124-157` never fire) |

***

## 4. Target shape of `GET /auth/user` (student) (DONE ✅)

Principle: **ids, labels, counters and card fields only. No question banks, no lesson bodies, no order history,
no attempt history.** Everything heavy moves to the endpoint of the page that needs it, fetched when that page
opens. The trainings/exams cards reuse the **same field names as `MarketplacePreviewService::trainingCard()` /
`examCard()`** so the Flutter `Training` / `Quiz` models already parse them; only the `progress` and
`attempts` sub-objects are new.

```jsonc theme={null}
{
  // ── identity / profile (unchanged keys, trimmed list) ──────────────────────
  "id": 1, "company_id": 1, "role_id": 1,
  "first_name": "…", "last_name": "…", "email": "…", "phone_number": "…",
  "image_id": 12, "image": { "id": 12, "url": "…" },
  "birthday": null, "bio": null, "blocked_at": null, "two_factor_enabled": 0,
  "public_profile_about_me": null, "public_profile_skills": null,
  "public_profile_linkedin_url": null, "public_profile_verification": 0,
  "company": { "id": 1, "label": "…", "currency": "TND", "company_image": { … } },
  "role":    { "id": 1, "label": "Student", "permissions": [ … ] },   // keep: tiny, staff UI gates on it

  // ── notifications: 3 newest + unread count (was 10) ────────────────────────
  "notification_count": 2,
  "notifications": [ { "id": 9, "title": "…", "message": "…", "notification_type_id": 5,
                       "notif_read_status": 0, "created_at": "2026-10-12 09:41:03" } ],

  // ── cart: a badge, not the cart ────────────────────────────────────────────
  "cart_summary": { "id": 156, "items_count": 1, "total_price": 120, "discounted_price": 120 },

  // ── my trainings: one card per owned training (deduped, access_revoked = 0) ─
  "my_trainings": [
    {
      "id": 2, "label": "Business Analysis", "slug": "business-analysis", "level": "…",
      "duration": 1200, "views_count": 311, "updated_at": "2026-09-30 14:02:11",
      "image": { "id": 40, "url": "…" }, "badge_logo": { … } | null,
      "instructor": { "id": 7, "first_name": "…", "last_name": "…", "image_id": 3, "image": { … } },
      "total_chapters_count": 6, "total_lessons_count": 415, "total_quizzes_count": 1,
      "progress": {
        "lessons_completed": 26, "lessons_total": 415,
        "quizzes_passed": 0,     "quizzes_total": 1,
        "overall_progress_percentage": 6.25, "is_completed": false,
        "last_activity_at": "2026-10-11 18:20:44",     // max(student_lesson.created_at, attempt.created_at)
        "next_lesson_id": 1234                         // optional: first non-validated lesson in position order
      },
      "certification_id": null                         // id of the issued certificate, or null
    }
  ],

  // ── my exams: one card per owned exam (access_revoked = 0) ─────────────────
  "my_exams": [
    {
      "id": 22, "label": "Examen Blanc PMP 7 FR", "slug": "examen-blanc-pmp-7",
      "description": "…", "duration": 210, "break_interval": 0, "break_duration": 0,
      "max_attempts": 3, "passing_percentage": 70, "quiz_questions_count": 180,
      "updated_at": "2026-08-01 10:00:00", "image": { … } | null,
      "instructor": { "id": 7, "first_name": "…", "last_name": "…", "image_id": 3, "image": { … } },
      "attempts": {
        "used": 7, "allowed": 12, "reset_count": 3,     // allowed = null when max_attempts == 0 (unlimited)
        "remaining": 5,
        "best_score_percentage": 61.1, "passed": false,
        "last_attempt_at": "2026-07-10 13:10:34"
      },
      "certification_id": null
    }
  ],

  // ── small id lists the course player still needs ───────────────────────────
  "validated_lesson_ids": [ 101, 102, 118, … ],        // replaces `lessons[]`
  "certifications": [ { "id": 4, "certification_id": "CERT-TR00002-…", "training_id": 2, "quiz_id": null,
                        "created_at": "2026-09-01 11:00:00" } ]
}
```

**Removed from the student payload:** `orders`, `cart` (full), `lessons`, `quiz_student_attempts`, `wishlist`,
`reviews`, `trainings_as_instructor`, `images_uploaded`, `owned_trainings`, `owned_exams`, `applied_coupons`,
`classroom_*_slugs`, unused `public_profile_*` keys, `hide_public_profile`, `loyalty_points`,
`default_revenue_percentage`.

**Staff roles (instructor / content manager / admin / super-admin):** the student blocks (`my_trainings`,
`my_exams`, `validated_lesson_ids`, `cart_summary`, `certifications`) are omitted; they get profile + `company` +
`role.permissions` + notifications. Staff screens already use their own endpoints (instructor dashboard, admin
lists). Before removing `trainings_as_instructor`, grep the instructor/admin Flutter code once; the Dart `User`
model has no field for it, so it is safe as far as the current app goes.

Estimated size for the sample student: **≈ 15–25 KB** (vs 1 550 KB), **≈ 12 SQL queries** (vs \~90).

***

## 5. Answers to the specific questions

### 5.1 "Why fetch the trainings with chapters, lessons and content after login?"

There is no reason left. The course player (`/student-my-content/student-training-details/:id`) does **not** use
the `/auth/user` copy of the training: it calls `GET /training/{id}` on open
(`training_provider.dart:589-593`) and reads lesson content from that. The only thing the boot payload's
chapter/lesson tree is used for is the client-side progress count, which §5.2 moves to the backend. So
`my_trainings[]` can be the marketplace-card shape (what `GET /preview-training/{idOrSlug}` returns, minus
`is_owned`, minus the free-preview lesson) **plus** a `progress` block. You keep using `auth/user`, as you asked;
you just borrow the preview *shape*, not the preview *endpoint*.

### 5.2 "Can the 26/300 lessons and 3/5 quizzes be computed in the backend?"

Yes, and the backend already knows how: `UserProgressAnalyticsController::trainings_progress()`
(`GET /progress/trainings`, `LMS_Backend/app/Http/Controllers/UserProgressAnalyticsController.php:207-291`)
returns exactly `lessons_total / lessons_completed / quizzes_total / quizzes_passed / overall_progress_percentage /
is_completed` per owned training, and `quizzes_progress()` (`:399-481`) returns `attempts_count /
best_score_percentage / passed` per quiz. The Flutter app never calls either.

Today the frontend computes it (`training_progress_helper.dart:6-51`, `continue_learning_card.dart:136-173`):
walk every chapter and lesson of every training, compare against `user.lessons` ids, and compare attempts against
`passing_percentage`. For the 415-lesson training that is a 415-iteration loop **per training per build**, and it
is wrong anyway (bug B1).

**How to compute it in the backend without a per-training loop** (the existing `trainings_progress()` loops and
runs 4 queries per training; for the boot payload do it set-based, 4 queries total whatever the training count):

```sql theme={null}
-- 1. totals per training (lessons)           → GROUP BY chapters.training_id
SELECT c.training_id, COUNT(l.id) AS lessons_total
FROM chapters c JOIN lessons l ON l.chapter_id = c.id
WHERE c.training_id IN (…owned…) AND c.deleted_at IS NULL AND l.deleted_at IS NULL
GROUP BY c.training_id;

-- 2. completed per training                   → same join + student_lesson
SELECT c.training_id, COUNT(*) AS lessons_completed
FROM student_lesson sl JOIN lessons l ON l.id = sl.lesson_id JOIN chapters c ON c.id = l.chapter_id
WHERE sl.student_id = ? AND sl.deleted_at IS NULL AND c.training_id IN (…) GROUP BY c.training_id;

-- 3. quizzes per training (training-level + chapter-level)
SELECT COALESCE(q.training_id, c.training_id) AS training_id, COUNT(*) AS quizzes_total, GROUP_CONCAT(q.id)
FROM quizzes q LEFT JOIN chapters c ON c.id = q.chapter_id
WHERE (q.training_id IN (…) OR c.training_id IN (…)) AND q.deleted_at IS NULL GROUP BY 1;

-- 4. passed quizzes per training (same rule as passedQuizIdsFor(): finished, score >= passing)
SELECT training_id, COUNT(DISTINCT a.quiz_id) FROM quiz_student_attempts a JOIN quizzes q … JOIN chapters …
WHERE a.student_id = ? AND a.finished_at IS NOT NULL AND a.score_percentage >= q.passing_percentage GROUP BY training_id;
```

Cost is independent of the number of lessons (415 lessons is one `COUNT`). Two cheap indexes make it
instant at scale (§6.4). If you later want zero runtime cost, materialize the counters in a
`student_training_progress` table updated by `toggle_lesson_validation` and `finish_student_quiz`, but that is
not needed for the first version; the set-based queries are already sub-10 ms.

### 5.3 "My Exams grid and the quiz-start page"

* The grid (`/student-my-content/exams`) reads only card fields plus `questions.length`. Send
  `quiz_questions_count` (one `withCount('quiz_questions')`, no question rows) and stop sending `quiz_questions`
  inside `orders[].quizzes[]`. The card's "no questions" guard becomes `quiz_questions_count == 0`.
* The quiz-start page (`/student-my-content/quiz-start/:id`) already fetches what it needs when it opens:
  `GET /quiz/{id}` then `GET /quiz/show_quiz_student_answers?quiz_id&is_exam=1`. Keep that. Two fixes there: hoist
  the `FutureProvider` out of `build()` (it refires on every rebuild, `quiz_home_main.dart:91-92`), and stop
  shipping the answer key on `GET /quiz/{id}` to students before an attempt is finished (clean\_up.md G.1 #5–6;
  out of scope for this file but the same `is_correct` problem).

### 5.4 "How and where are exam attempts calculated, and what is the fastest way?"

Today:

| Thing | Where | How |
| :- | :- | :- |
| The attempt number | backend, `start_student_quiz` (`Quizzes_Exams_Progress_Tracking_Controller.php:51-70`) | `max(attempt) + 1`, server-derived, monotonic |
| The ceiling | backend decides when starting (`max_attempts * (reset_count + 1)`), **but the student page recomputes it client-side** from `reset_cycle` on the attempt rows (`quiz_provider.dart:124-157`) | guess; stale after a fresh reset (B7) |
| "Used" | client: `examPreviewAttempts.where(quizId == …).length` | counts rows of a per-quiz GET |
| "Passed" | client: compare each row's `score_percentage` with `passing_percentage` | and at boot the rows have no score (B1/B2) |

Fastest and correct: **the backend owns the arithmetic, the client only displays**. Two grouped queries give
every owned exam's numbers in one go, with no per-exam loop:

```sql theme={null}
SELECT quiz_id, COUNT(*) AS used, MAX(attempt) AS max_attempt, MAX(score_percentage) AS best,
       MAX(created_at) AS last_attempt_at,
       SUM(finished_at IS NOT NULL AND score_percentage >= ?) …      -- or compare to quizzes.passing_percentage via JOIN
FROM quiz_student_attempts WHERE student_id = ? AND quiz_id IN (…owned exams…) GROUP BY quiz_id;

SELECT quiz_id, COUNT(*) AS reset_count FROM quiz_student_resets WHERE student_id = ? AND quiz_id IN (…) GROUP BY quiz_id;
```

Then `allowed = max_attempts == 0 ? null : max_attempts * (reset_count + 1)` and `remaining = allowed - used`
(clamped at 0). Use `MAX(attempt)` for `used` so it matches the check in `start_student_quiz`. Put this
`attempts` block on each `my_exams[]` card (boot) **and** on the response of `GET /quiz/{id}` for the calling
student (quiz-start page), and add `allowed_attempts` + `reset_count` to each row of `show_quiz_student_answers`
(the Flutter rules 1–2 already consume them, zero frontend change for B7).

### 5.5 "Attempts page: fetch only when `/student-my-content/attempts` opens"

Yes. Drop `quiz_student_attempts` from `/auth/user`. The page calls
`GET /quiz/show_quiz_student_answers?student_id={me}&is_exam=1` on open (it already does this after an exam is
submitted, `quiz_provider.dart:3020-3027`). That endpoint returns the full rows the table needs (`attempt,
correct_answers, uncorrect_answers, score_percentage, finished_at, passed_at, reset_cycle`), which fixes B2.

One caveat: that endpoint eager-loads `quiz_questions.image` for every attempt, so for a student with 13 attempts
on 180-question exams it is heavy for a list view. Add a `?summary=1` (or `with_answers=0`) flag that skips the
`quiz_questions` relation; the review (eye icon) then fetches a single attempt with answers
(`?quiz_id=…&attempt_id=…`, or a new `GET /quiz/attempt/{id}/answers`). Cache the list in `QuizProvider` with a
`loaded` flag and invalidate it after `finish_student_quiz`.

### 5.6 Dates: `2026-06-19T07:40:12.000000Z` vs `serializeDate`, cost, and which dates to keep

**Why both formats appear.** Every model overrides `serializeDate()` to `Y-m-d H:i:s`. That override only runs
when the *model* is converted to an array/JSON. In `UserResource` / `OrderResource` the code returns the raw
Carbon object (`'created_at' => $this->created_at`), and Laravel's JSON encoder serializes a bare Carbon through
Carbon's own `jsonSerialize()` → ISO 8601 with microseconds in UTC. That is why `orders[].created_at`,
`cart.*`, `certifications[].created_at` and `quiz_student_attempts[].created_at` are ISO while everything that
goes through a model (`notifications[]`, nested quizzes) is `Y-m-d H:i:s`. The sample has 185 ISO dates and 178
`Y-m-d H:i:s` dates.

**Is `serializeDate` a heavy calculation?** No. It is one `DateTime::format()` call per date, about a microsecond.
A thousand dates cost under a millisecond on either side (Dart `DateTime.parse` is similar). The cost you are
seeing is the *number of objects*, not the date strings. Do not add a cached/pre-formatted column for this.

**Recommended approach:**

1. Pick **one** wire format for the whole API and stick to it. The existing convention is `Y-m-d H:i:s` in
   `Africa/Tunis` (backend `CLAUDE.md` §8), and the Flutter code depends on it (`passedAt.substring(0,16)`
   in `exam_attempts_datasource.dart:107`). Keep it. In resources, emit dates through the model format
   (`$this->created_at?->format('Y-m-d H:i:s')`, or a tiny `Dates::wire()` helper) instead of the raw Carbon.
   If one day the app needs real timezone handling for users outside Tunisia, switch *everything* to
   `toIso8601String()` (`2026-06-19T07:40:12+01:00`) in one release; do not mix.
2. **Only send dates the UI shows.** In the student payload that is: `my_trainings[].updated_at` (the
   "Last updated MM/yyyy" chip), `my_trainings[].progress.last_activity_at`, `my_exams[].attempts.last_attempt_at`,
   `notifications[].created_at`, `certifications[].created_at`. Attempt rows (on their own page) keep
   `created_at`, `finished_at`, `passed_at`. Drop `updated_at`, `deleted_at`, `archived_at`, pivot timestamps
   everywhere else.
3. **Frontend:** parse once, not on every build. Give the Dart models `DateTime?` fields with a small
   `JsonConverter` (`Y-m-d H:i:s` ⇄ `DateTime`) instead of `String?` + `DateTime.parse` inside `build()`
   (`glass_training_card.dart:636-643`, `helper.dart:351,384`). Formatting for display (`MM/yyyy`, "3 h ago")
   stays in the client because it is locale-dependent (en/fr/ar).

### 5.7 Notifications: 3 at boot instead of 10

Yes. `NotificationController::getUserNotifications()` (`:367-374`) is a `limit(10)`; make it `limit(3)` (or a
`$limit` parameter, default 3) and keep `getUserNotificationCount()` for the badge. When the bell popup opens,
call the existing paginated `GET /notifications/user/{id}?company_id&per_page=10&page=1` (the Flutter client
already has it, `api_client.dart:1370-1449`) and replace the provider list. Also trim the notification columns in
the boot list to what the popup renders (`id, title, message, notification_type_id, notif_read_status,
created_at`). The SharedPreferences "`notificationCount$userId`" subtraction in `notification_provider.dart:903-925`
can go: `notification_count` is already the unread count.

### 5.8 Cart

Only `app_service.dart:223` reads `user.cart`; the cart page never re-fetches. Replace with `cart_summary`
(`id`, `items_count`, totals) for the header badge, and have the cart page call `GET /user/{id}/cart/2` on open
(`2` is `Order::CART` — the route is `/user/{student_id}/cart/{type}`, so the hard-coded `cartId: 2` in
`cart_provider.dart:26` is the order *type*, not a bug; rename the parameter for clarity). The cart endpoint
should serialize items in `summary` shape (no questions): check `UsersController::cart()` emits
`QuizResource(..., 'summary')` / `TrainingResource(..., 'summary')` and not the `full` default.

***

## 6. Backend plan (`LMS_Backend`) (DONE ✅)

### 6.1 New payload builder (whitelist pattern, like `MarketplacePreviewService` / `ClassroomPayloadService`)

Create `app/Services/AuthUserPayloadService.php`:

```text theme={null}
build(User $user): array
├─ base(User)               profile scalars + image + company + role(+permissions)
├─ notifications(User)      3 newest (6 columns) + unread count          2 queries
├─ studentBlocks(User)      only when $user->isStudent()
│   ├─ ownedTrainingIds / ownedExamIds   via orders (type 3, status 4/5, access_revoked = 0)   1 query
│   │                                     (reuse the logic of MarketplacePreviewService::ownedTrainingIds();
│   │                                      do NOT use User::trainings()/exams(), they are miswired — CLAUDE.md §12)
│   ├─ trainingCards        Training::whereIn('id')->select(card columns)->with(image, badge_logo, instructor:safe cols)
│   │                         ->withCount('chapters')->withSum('chapters', 'lessons_count')   1–3 queries
│   │                       → setAppends([]) on each model (or ->toBase()) so Training::$appends never fire
│   ├─ trainingProgress     the 4 grouped queries of §5.2, keyed by training_id             4 queries
│   ├─ examCards            Quiz::whereIn('id')->isExam()->without('instructor')->withCount('quiz_questions')
│   │                         ->with(image, instructor:safe cols)                              1–2 queries
│   ├─ examAttempts         the 2 grouped queries of §5.4, keyed by quiz_id                   2 queries
│   ├─ validatedLessonIds   DB::table('student_lesson')->where(student_id)->whereNull(deleted_at)->pluck(lesson_id)   1 query
│   ├─ certifications       select id, certification_id, training_id, quiz_id, created_at   1 query
│   └─ cartSummary          Order type 2 + order_items count + totals                        1 query
└─ dates through one helper → 'Y-m-d H:i:s'
```

Rules for the service: select columns explicitly, never return a model, never load `quiz_questions`, `lessons.content`,
`quiz_question_option_items`, `reviews`, or the instructor `email` / `default_revenue_percentage`. Instructor
columns = `MarketplacePreviewService::INSTRUCTOR_COLUMNS`. Reuse its `file()` / `instructor()` helpers (make
them `public static` or extract a small `PayloadFormatters` trait) so cards look identical to the marketplace.

### 6.2 `AuthController::auth_user()`

* Replace the `User::with([...])` block + `UserResource::make()` with `User::with(['image','company.companyImage','role.permissions'])->find(auth()->id())` and
  `response()->json(app(AuthUserPayloadService::class)->build($user))`. Keep `$this->authorize('view', $user)`.
* Transition flag (see §10): while the frontend switches, accept `?legacy=1` to return the old `UserResource` so
  a stale web bundle keeps working; delete the flag and `UserResource`'s order/attempt branches afterwards.

### 6.3 Related endpoints (small, same release)

| Endpoint | Change |
| :- | :- |
| `GET /quiz/show_quiz_student_answers` | add `allowed_attempts`, `reset_count`, `attempts_used` per row (B7); add `summary=1` to skip `quiz_questions`; optionally `attempt_id` filter for the review |
| `GET /quiz/{id}` (student caller) | append the same `attempts` block as `my_exams[].attempts` for `auth()->id()` so the quiz-start page has the ceiling from the server |
| `GET /user/{id}` (`UsersController::show`) | it is called after every lesson toggle only to refresh `currentUser.lessons`; either return `validated_lesson_ids` instead of full `lessons`, or (better) make `POST /lesson/{id}/toggle_lesson_validation` return `{ lesson_id, validated, validated_lesson_ids, training_progress }` so the frontend never re-fetches the user (clean\_up.md G.2 row 3) |
| `GET /user/{student_id}/cart/{type}` | make sure nested trainings/quizzes use the `summary` resource shapes |
| `NotificationController::getUserNotifications` | `limit(3)`, selected columns |
| `GET /user/{id}/trainings_by_student`, `GET /user/{id}/exams_by_student` | they return the full `chapters.quizzes.quiz_questions…` tree (`UsersController.php:339-349, 369-430`) and are called after a voucher; switch them to the same card shapes (or have the voucher flow simply re-call `/auth/user`) |

### 6.4 Indexes (migration)

`student_lesson` and `quiz_student_attempts` were created without composite indexes
(`2024_03_01_134652_create_student_lesson_table.php`, `2024_03_31_025930_create_quiz_student_attempts_table.php`;
only the FK indexes exist). Add:

* `student_lesson (student_id, lesson_id, deleted_at)`
* `quiz_student_attempts (student_id, quiz_id)` and `(quiz_id, student_id, finished_at)`
* `order_items (order_id, training_id)` / `(order_id, quiz_id)` if not already there
* `notifications (student_id, notif_archive_status, created_at)`

### 6.5 Tests (`tests/Feature/AuthUserPayloadTest.php`, SQLite like the other exposure tests)

1. Student payload has `my_trainings`, `my_exams`, `validated_lesson_ids`, `cart_summary`, `certifications`, 3 notifications max.
2. Student payload **never** contains the strings `is_correct`, `"content"`, `quiz_questions"`, `explanation`,
   `revenue_percentage`, an instructor `email`, or the keys `orders` / `quiz_student_attempts` / `lessons`.
3. Revoked exams/trainings are excluded; a training bought twice appears once.
4. Progress numbers match fixtures (26 validated lessons → `lessons_completed: 26`; a passed quiz → `quizzes_passed: 1`).
5. `attempts.allowed` is `null` for `max_attempts = 0`, equals `max_attempts * (reset_count + 1)` otherwise.
6. Every date in the payload matches `/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/`.
7. Staff payload has no student blocks; query count assertion (`DB::enableQueryLog`, expect ≤ 15).

### 6.6 Clean-up in the same pass

* `UserResource.php`: delete the dead commented blocks and the `wishlist`/`orders` lazy `->load()` branches once
  the legacy flag is gone. Keep the class if admin tooling uses it (`grep UserResource` first).
* `Training::$appends` / `Quiz::$appends`: nothing in this plan removes them globally (other endpoints rely on
  them), but every list-type endpoint should opt out (`setAppends([])` or `->withAvg/withCount` instead). Note it
  in `clean_up.md` G.2 as a follow-up.
* Update `LMS_Backend/CLAUDE.md` §10/§12 with a line: "`GET /auth/user` is whitelist-based
  (`AuthUserPayloadService`); extend by adding to the whitelist, never by loading relations on the user."
* Update `clean_up.md` G.2 row "GET /auth/user" to ✅ when done.

***

## 7. Frontend plan (`LMS_Frontend`, student side) (DONE ✅)

### 7.1 Models (`lib/core/data/remote/…`, then `dart run build_runner build -d`)

| Model | Change |
| :- | :- |
| `User` (`user.dart`) | add `myTrainings: List<Training>?` (`my_trainings`), `myExams: List<Quiz>?` (`my_exams`), `validatedLessonIds: List<int>?`, `cartSummary: CartSummary?`; remove `orders`, `lessons`, `examAttempts`, `cart`, `imagesUploaded`. Keep `certifications` but switch `Certificate` to read `training_id` / `quiz_id` directly. Keep `notifications`, `unreadNotifications` |
| `Training` | add `progress: TrainingProgress?` (`lessons_completed, lessons_total, quizzes_passed, quizzes_total, overall_progress_percentage, is_completed, last_activity_at, next_lesson_id`), `certificationId`, `totalQuizzesCount`. `chapters` becomes optional/empty on the card (it already is nullable for preview cards) |
| `Quiz` | add `attempts: ExamAttemptsSummary?` (`used, allowed, remaining, reset_count, best_score_percentage, passed, last_attempt_at`), `certificationId`; reuse `quizQuestionsCount` if the preview-exam work already added it |
| `QuizAttempt` | add `allowedAttempts`, `resetCount`, `attemptsUsed` if missing (the provider already reads `allowed_attempts` / `reset_count`) |
| new `CartSummary` | `id, itemsCount, totalPrice, discountedPrice` |
| Dates | `DateTime?` + a shared `WireDateConverter` (`Y-m-d H:i:s`) for `updatedAt`, `createdAt`, `passedAt`, `finishedAt`, `lastActivityAt`, `lastAttemptAt` |

### 7.2 Boot (`lib/core/providers/app_service.dart`)

* `handleStudentRole()`: fill `StudentTrainingProvider.myTrainings` from `user.myTrainings`,
  `QuizProvider.myExams` from `user.myExams`, `certificateStudent` from `user.certifications`,
  `NotificationProvider` from the 3 notifications + count, `CartProvider` from `cartSummary`,
  `StudentTrainingProvider.validatedLessonIds` from `user.validatedLessonIds`.
  Delete the `orders[].trainings` / `orders[].quizzes` `access_revoked` filters (the backend already filtered)
  and the `studentQuizAttempts` / `studentExamAttempts` assignment.
* Do not `await` `GET /dashboard/user/{id}/progress` in series at boot (`:231`). Either fire it from the
  "Learning activity" card when it mounts or run it in `Future.wait` with `/auth/user`.
* Send `?legacy=0` (or nothing once the backend default flips, see §10).

### 7.3 Progress widgets: read, don't compute

* `TrainingProgressProvider` (`training_progress_provider.dart:54-66`): remove `validateLessonsInTraining`
  at boot and the per-build calls in `overview_screen.dart:59-61`, `student_space.dart:27-29`,
  `student_statefulshell.dart:54-66`. `allTrainingsWithProgress` becomes a thin wrapper around
  `myTrainings` (each already carries `progress`).
* `ContinueLearningCard` (`continue_learning_card.dart:136-173`), `enrolled_training_screens.dart:170-188`,
  `learner_progress_cards.dart:151-205`, `overview_screen.dart:640-655`: replace
  `calculateValidatedLessons / calculateTotalItems / quizzes.where(passed)` with
  `training.progress.lessonsCompleted / lessonsTotal / quizzesPassed / quizzesTotal`. "Exams passed" stat =
  `myExams.where((e) => e.attempts?.passed == true).length`.
* `training_progress_helper.dart`: keep only what the course player still needs (marking `isValidate` on the
  `GET /training/{id}` tree using `validatedLessonIds`); delete the rest. Fixes B1 and B4.
* After `POST /lesson/{id}/toggle_lesson_validation`: update `validatedLessonIds` locally (and `progress`
  counters from the toggle response once the backend returns them) instead of `GET /user/{id}`
  (`student_training_provider.dart:218-223`).

### 7.4 My Exams grid and quiz-start

* `enrolled_quizzes_screen.dart:155,218`, `exam_card_with_progress.dart:38`, `quiz_attempt_listtile.dart:66`:
  use `quiz.quizQuestionsCount` instead of `questions?.length`.
* Show `attempts.used / allowed` and `best_score_percentage` on the card if wanted (data is now free).
* `quiz_home_main.dart:91-92`: create the `getQuizApi` future in `initState` / memoize it.
* `exam_preview_page.dart` + `quiz_provider.dart:124-157`: prefer the server `attempts.allowed` / `remaining`
  (from `GET /quiz/{id}` or the card); keep the rule chain only as a fallback. Fixes B7.

### 7.5 Attempts page (`/student-my-content/attempts`)

* On route open (`initState` of `ExamAttemptsDataTable` / `ExamAttemptsMobile`, or a `FutureProvider` in the
  route builder): `QuizProvider.loadStudentExamAttempts()` → `GET /quiz/show_quiz_student_answers?student_id&is_exam=1&summary=1`.
  Keep a `loaded` flag + manual refresh; invalidate after `finish_student_quiz`.
* `exam_attempts_datasource.dart:155`: `myExams.firstWhere(... , orElse: ...)` (an attempt can outlive access).
* The review (eye icon) keeps `openExamAttemptReview` but fetches the single attempt with answers.
* Fixes B2.

### 7.6 Notifications

* Popup open (`student_appbar_widget.dart:270-290`): call `GET /notifications/user/{id}?per_page=10` and
  replace `provider.notifications`; the 3 boot items are shown meanwhile.
* Badge = `user.notification_count` directly; remove the SharedPreferences offset
  (`notification_provider.dart:903-925`).

### 7.7 Cart

* Header badge from `cartSummary.itemsCount`.
* Cart page `initState`: `GET /user/{id}/cart/2` → `CartProvider.userCart`. Rename the `cartId` param to
  `orderType`.

### 7.8 Certificates

* `Certificate.fromJson` reads `training_id` / `quiz_id`; the five "earned" checks
  (`course_content_tree.dart:446`, `certificate_progress_dialog.dart:58`, `course_player_mobile.dart:372`,
  `enrolled_training_screens.dart:148`, `quiz_provider.dart:2979`) work at boot. Fixes B3. Alternatively read
  `training.certificationId` / `quiz.certificationId` from the cards.

### 7.9 Optional, after the above: paint before the network

With a \~20 KB payload the blocking `await` in `app.dart:52-56` costs one short round-trip, which is acceptable.
If you want instant paint later: store the last `/auth/user` JSON in `SharedPreferences`, build the router from
it immediately, refresh in the background and `notifyListeners()`. Not required for the 6–7 s problem.

### 7.10 Bookkeeping

* Append an `# Update N — GET /auth/user slimmed …` entry to `major_code_updates.md` (append only, never
  truncate), with the B1–B7 bug list and the files touched.
* Update `LMS_Frontend/CLAUDE.md` §3 ("`myExams` come from the login payload") and
  `authed_user_and_attempts.md` (or delete the latter once the docs page is rewritten, §8).
* `clean_up.md` App Speed Suggestions row 2 → ✅.

***

## 8. Documentation plan (`LMS_Documentation`) (DONE ✅)

| Page | Change |
| :- | :- |
| `api-reference/auth.mdx` §9 "Get Authenticated User" | replace the example response with the §4 shape (student) and the staff variant; document `?legacy=1` during the transition and remove it after; list every removed key in a "Changed in vN" note |
| `api-reference/untitled-page.mdx` | rename to `api-reference/student-boot-payload.mdx` (update `docs.json`), fix the wrong §1.2 claim, rewrite §4–§5 as "before / after", keep the Mermaid flow diagrams updated to the new call sequence |
| `api-reference/quizzes-and-exams-progress-tracking-api.mdx` | `show_quiz_student_answers`: new row fields `allowed_attempts`, `reset_count`, `attempts_used`; new `summary=1` flag; `GET /quiz/{id}` `attempts` block |
| `api-reference/notifications.mdx` | note that the boot list is now 3 items and which columns it carries |
| `api-reference/users.mdx` | `GET /user/{id}`, `cart/{type}`, `trainings_by_student`, `exams_by_student` new shapes if changed; `toggle_lesson_validation` response (on `lessons.mdx`) |
| `api-reference/user-progress.mdx` | cross-link: "`/auth/user` carries the per-training summary; `/progress/*` stays the detailed/staff view" |
| `changelog/changelog.mdx`, `changelog/issues-and-errors.mdx`, `clean-up/backend.mdx`, `clean-up/frontend.mdx` | mirror the frontend Update N and the clean\_up.md rows (CLAUDE.md §2.2) |
| `system-design/` (frontend architecture page) | update the boot diagram if it shows the old fan-out |

Run `mint broken-links` and `mint dev` before pushing (push to `main` auto-deploys).

***

## 9. Repo hygiene: the committed payload dump (DONE ✅ — history not purged)

`LMS_Frontend/docs/api_samples/student_fivermail/02_GET_auth_user.full.json` (1.41 MB) is **tracked** (added in
commit `efa1cf9`), not git-ignored, and contains a real account e-mail, the student's whole purchase history and
5 790 `is_correct` flags, i.e. the answer keys of six commercial exams. `capture.sh:7-8` explicitly refuses to
commit the `/quiz/{id}` dumps for that very reason, yet the `/auth/user` dump holds the same keys. Nothing in the
codebase reads the file (only `capture.sh`, `authed_user_and_attempts.md`, `clean_up.md` and `graphify-out/`).

Plan:

1. `git rm` the `.full.json`; keep `02_GET_auth_user.shape.json` (6 KB, one item per list) after redacting the
   e-mail / names / phone.
2. Add `docs/api_samples/**/*.full.json` to `.gitignore`; make `capture.sh` write full dumps to a git-ignored
   `docs/api_samples/_local/` folder. Remove the hard-coded password from `capture.sh` (read `EMAIL` /
   `PASSWORD` from env only).
3. Decide whether to purge history (`git filter-repo` on the path, then force-push + everyone re-clones). The
   account is a seeded test user on the test backend, so the privacy exposure is low, but the exam answer keys
   are real content. Recommendation: purge, since the repo is private and small.
4. Delete the stray `LMS_Frontend/gitignore` file (no dot) noted in `clean_up.md`.
5. Re-capture a *new* shape sample once the new payload ships, so the docs example is real.

***

## 10. Rollout order and compatibility (DONE ✅ — slim by default, `?legacy=1` kept)

The exams grid will break if the backend drops `orders[].quizzes[].quiz_questions` before the frontend reads
`quiz_questions_count` (the card shows "0 questions" and refuses the tap). So ship **additively, switch, then
remove**:

| Step | Repo | What |
| :- | :- | :- |
| 1 | Backend | New `AuthUserPayloadService`; `auth_user()` returns the **new** shape by default and the old `UserResource` when `?legacy=1`. Deploy to the TEST backend. Add the `show_quiz_student_answers` fields, `GET /quiz/{id}` `attempts` block, notification limit, indexes, tests. Meanwhile the currently deployed web bundle must send `?legacy=1`… it does not, so either (a) flip the default the other way for one release (`?slim=1` opt-in, legacy default), or (b) deploy backend + frontend to TEST together. **(a) is safer for PROD.** |
| 2 | Frontend | Models + fan-out + widgets per §7, sending `?slim=1`. Verify on TEST with the seeded student (`student@fivermail.com`): dashboard counts, My Trainings, My Exams, quiz-start remaining attempts, Attempts tab, bell popup, cart badge, certificates badge, lesson toggle in the player, voucher redemption, logout/login. |
| 3 | Backend | Make slim the default, keep `?legacy=1` for one more release, then delete the legacy branch and the dead `UserResource` code. |
| 4 | Docs | §8 pages, aligned with step 3. |
| 5 | Frontend | `docs/api_samples` clean-up (§9), `major_code_updates.md`, `clean_up.md`, `CLAUDE.md`. |

Mobile (APK / iOS) users update later than web, which is the other reason to keep `?legacy=1` alive for a release.

***

## 11. How to measure before / after (measured on a local backend, see §0)

Record these for the seeded student on TEST before step 1 and after step 3:

| Metric | How |
| :- | :- |
| Payload bytes (raw and gzipped) | `curl -s -H "Authorization: Bearer …" …/api/auth/user -o /dev/null -w '%{size_download}'` with and without `-H 'Accept-Encoding: gzip'` |
| Server time + query count | Telescope on TEST, or `DB::enableQueryLog()` around the controller in a feature test; target ≤ 15 queries, \< 50 ms |
| Time to dashboard | Chrome DevTools → Performance, from the login click to the first "Continue learning" paint; or the Sentry transaction for `authUserAndRole` |
| Main-thread JSON time | DevTools → Performance, the `jsonDecode` + `fromJson` slice during boot |
| Correctness | the B1–B7 list: each must be visibly fixed on the pages named in §3.3 |

Also check the vhost: `curl -sI -H 'Accept-Encoding: gzip, br' …/api/auth/user | grep -i content-encoding`. If
JSON is not compressed, enable gzip/brotli for `application/json` in the Plesk/nginx config; it is a free win
independent of this plan.

***

## 12. Out of scope (but related)

* `GET /training/{id}` (course player) still returns every lesson's `content` and the chapter-quiz answer keys
  in one shot; `clean_up.md` G.1 #4 / G.2 row 2 already describe the fix (structure endpoint + per-lesson content).
* `GET /quiz/{id}` ships `is_correct` to a student before the attempt starts (G.1 #5–6).
* `Training::$appends` / `Quiz::$appends` fire N queries on every list endpoint in the app, not only here.
* Code-splitting the Flutter bundle by role (`clean_up.md` App Speed row 1) is the next biggest web win after this.


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