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

# Authed user, "My Exam Simulator" and exam attempts — how the data actually flows

**Superseded on 19/10/2026.** `GET /auth/user` is now the slim payload documented in
[Auth §9](https://theteam-13fc7f49.mintlify.site/api-reference/auth.md) and [Auth User Optimize](https://theteam-13fc7f49.mintlify.site/clean-up/auth-user-optimize.md): `my_exams[]` cards
with `quiz_questions_count` and a server-computed `attempts` block, no `orders[]`, no attempt rows (the
Attempts tab calls `show_quiz_student_answers?is_exam=1&summary=1` on open), and the attempt ceiling comes
from the backend (`allowed_attempts` / `reset_count` / `attempts_used`). Since the same day the same
payload is also returned by `POST /auth/login` under `user` ([Auth §4](https://theteam-13fc7f49.mintlify.site/api-reference/auth.md),
[Auth User Super Fast](https://theteam-13fc7f49.mintlify.site/clean-up/auth-user-super-fast.md)), so a login no longer calls `GET /auth/user`
at all. Everything below describes the **old** flow (captured 2026-07-13) and is kept as history. One claim in §1.2 was wrong: the owned exams
inside `orders[].quizzes[]` carried their full `quiz_questions` (180 per exam in the sample), not summaries.

# **Authed user, "My Exam Simulator" and exam attempts — how the data actually flows**

> Captured live against `https://backend-test.theteam.com.tn/api` on **2026-07-13** with the seeded learner `student@fivermail.com `**/** `123456` (user id **1**, role 1 = student, company 1). Raw request/response JSON lives in `docs/api_samples/student_fivermail/`; re-capture it any time with `docs/api_samples/student_fivermail/capture.sh`.

***

## **0. TL;DR — the four things worth knowing**

1. `GET /auth/user `**is the *only* source for the My Exam Simulator tab.** There is no "my exams" endpoint on that screen. The list is built client-side from `orders[].quizzes[]` where `pivot.access_revoked == 0`.
2. `GET /auth/user `**is 1.45 MB for this student, and 87 % of it is** `orders` (with `cart` another 10 %). It is fetched on **every app boot**.
3. **The attempt ceiling ("how many attempts do I get after the admin gave me more?") is *derived on the client*, not sent by the backend.** `show_quiz_student_answers` returns `reset_cycle` but **not** `reset_count` and **not** `allowed_attempts` — the two fields `QuizProvider.effectiveAllowedAttemptsFor()` prefers. So the app silently falls back to `max_attempts × max(reset_cycle)`. It happens to produce a plausible number today, but it is a guess, and it breaks in the edge case below (§5.1).
4. **Two real bugs are visible in this student's data**: the *Exam Attempts* tab renders score/correct/passed columns that `/auth/user` never sends (so it shows `0 / 0 / 0% / unfinished` for all 13 rows — §5.2), and `GET /quiz/{id}` hands the student the **full answer key** before they start (§5.3).

***

## **1. Page:** `http://localhost:5000/student-my-content `**→ tab "My Exam Simulator"**

### **1.1 What the API loads**

**Nothing.** Opening this page fires **zero** network calls for the exam list. Everything was already loaded at boot:

```text theme={null}
app boot  →  AppService.authUserAndRole()          (lib/core/providers/app_service.dart:58)
             └─ GET /auth/user                     (ApiClient.authUser, api_client.dart:43)
                └─ AppService.handleStudentRole()  (app_service.dart:204)
```

`handleStudentRole()` fans the single `User` object out into the providers:

| **Provider field** | **Filled from (**`/auth/user `**JSON)** | **Filter applied** |
| :- | :- | :- |
| `StudentTrainingProvider.myTrainings` | `orders[].trainings[]` | `pivot_training.access_revoked == 0` |
| `QuizProvider.myExams` | `orders[].quizzes[]` | `pivot.access_revoked == 0` |
| `TrainingQuizProvider.studentQuizAttempts` | `quiz_student_attempts[]` | none — **all** rows |
| `QuizProvider.studentExamAttempts` | `quiz_student_attempts[]` | only attempts whose `quiz_id` is in `myExams` |
| `CertificateProvider.certificateStudent` | `certifications[]` | none |
| `CartProvider.userCart` | `cart` | none |
| `NotificationProvider.notifications` | `notifications[]` | none |

Then the render path:

```text theme={null}
/student-my-content → StudentSpace (student_space.dart)
   → TrainingProgressProvider.initAllExamsWithProgress(quizProvider.myExams)
   → MyProfileScreen (desktop) / MyProfileMobileScreen
       tab 2 "My Exam Simulator" → EnrolledQuizzes(quizzesData: allExamsWithProgress)
           → ExamCardWithProgress  (art = ExamSimulatorBanner CustomPainter, no network image)
```

The tab's **badge count** comes from `quizProvider.myExams.length`; the **cards** come from `TrainingProgressProvider.allExamsWithProgress` (the same list, wrapped with progress state). Search/filter in `EnrolledQuizzes` is purely in-memory (`quiz.label.contains(...)`).

The only network call this tab can trigger is an *indirect* one: after applying a voucher, `VoucherProvider` calls `QuizProvider.getExamByStudent()` → `GET /user/{id}/exams_by_student` (`api_client.dart:619`). **That endpoint exists and returns the same list, but the My Content page never calls it on load** — it only ever reads what `/auth/user` already put in the provider.

### **1.2 What this student actually has**

19 orders, of which 4 carry exams:

| **Order** | **Exam id** | **Label** | `pivot.access_revoked` | **In**`myExams`**?** |
| :- | :- | :- | :- | :- |
| 126 | 22 | Examen Blanc PMP 7 FR | 0 | ✅ |
| 126 | 23 | Examen Blanc PMP 8 FR | 1 | ❌ revoked |
| 126 | 24 | Examen Blanc PMP 9 FR | 1 | ❌ revoked |
| 141 | 1 | Examen Blanc CCBA 1 | 0 | ✅ |
| 142 | 19 | Examen Blanc PMP 4 FR | 0 | ✅ |
| 145 | 27 | Examen Blanc PMP 10 FR | 1 | ❌ revoked |

→ **"My Exam Simulator" shows 3 cards: exams 22, 1, 19.**

Note the quiz objects nested in `orders[].quizzes[]` are *summaries*: 24 keys, `quiz_questions` absent, so `Quiz.questions` is `null` here. Full questions only arrive on the quiz-start page (§2).

Also note `owned_exams: ["examen-blanc-pmp-4","examen-blanc-pmp-7"]` (slugs) exists on the user root and lists only **2** of the 3 accessible exams (CCBA 1 is missing). The Flutter app **doesn't read this field at all** — it's dead weight, and it disagrees with the orders-derived list.

***

## **2. Page:** `http://localhost:5000/quiz-start/1`

Route `StudentAppRoutes.quizStart` → `/quiz-start/:quizId` → `QuizHomeMainScreen(quizId, isOnlyQuizPreview: false)` (`student_statefulshell.dart:121`).

### **2.1 Call sequence (exactly two requests)**

```text theme={null}
1) QuizHomeMainScreen.build
   └─ QuizProvider.getQuizApi(quizId)        →  GET /quiz/{id}                       ← ALWAYS
        (FutureProvider; spinner until it resolves)
   └─ QuizInitializerScreen → QuizDetailsScreen → ExamPreviewPage

2) ExamPreviewPage.initState  (postFrame)
   └─ QuizProvider.loadExamPreviewAttempts(quizId)
        →  GET /quiz/show_quiz_student_answers?student_id={me}&quiz_id={id}&is_exam=1  ← CONDITIONAL
```

The second call is **skipped** when `isOnlyQuizPreview == true` (marketplace/staff preview) **or** when `quiz.max_attempts == 0` (unlimited) — see `exam_preview_page.dart:60`. So on `/quiz-start/1` (exam 1, `max_attempts: 0`) **only** `GET /quiz/1 `**fires**; the attempts call never happens, and the page shows "unlimited attempts".

⚠️ `QuizHomeMainScreen` builds the `FutureProvider` with `getQuizApi(...)` **inline in** `build()`, so `GET /quiz/{id}` is re-issued on **every rebuild** of that widget, not once per page visit.

### **2.2** `GET /quiz/{id} `**— what comes back**

Same shape for all three exams (34 keys): `id, company_id, training_id, chapter_id, instructor_id, image_id, label, slug, description, max_attempts, passing_percentage, duration, break_interval, break_duration, is_published, price, discounted_price, revenue_percentage, created_at, updated_at, archived_at, deleted_at, questions_count, average_rating, total_ratings, total_sales, final_price, total_active_sales, instructor, image, categories, reviews, chapter, quiz_questions`.

`quiz_questions` (→ `Quiz.questions`, `@JsonKey(name: 'quiz_questions')`) carries every question with `quiz_question_options[] { id, label, is_correct, … }`.

| **Exam** | **bytes** | **questions** | **options** | **options with**`is_correct: 1` |
| :- | :- | :- | :- | :- |
| 1 (`/quiz-start/1`) | 198 KB | 74 | — | — |
| 19 | 323 KB | 180 | 726 | 195 |
| 22 | 332 KB | 180 | 726 | 195 |

The exam config the intro page reads:

| **Exam** | `max_attempts` | `passing_percentage` | `duration `**(min)** | `break_interval` |
| :- | :- | :- | :- | :- |
| 1 | **0 → unlimited** | 70 | 2190 | 0 |
| 19 | 3 | 70 | 210 | 0 |
| 22 | 3 | 70 | 210 | 0 |

### **2.3** `GET /quiz/show_quiz_student_answers `**— the attempts**

Query: `student_id`, `quiz_id`, `is_exam=1`. Returns a **flat array of attempt rows**:

```text theme={null}
['id','student_id','quiz_id','attempt','created_at','updated_at','finished_at',
 'correct_answers','uncorrect_answers','score_percentage','passed_at','reset_cycle','quiz_questions']
```

Stored into `QuizProvider.examPreviewAttempts` and consumed by:

* `usedAttemptsFor(quiz)` → `examPreviewAttempts.where(quizId == …).length`
* `effectiveAllowedAttemptsFor(quiz)` → the ceiling (§3)
* `_HistoryBand` on the intro page, and the "Before you begin" `StartExamModal` (`remaining = total − used`, start button disabled when `remaining <= 0`).

***

## **3. Where "number of attempts" and "increased attempts" come from**

### **3.1 The base number**

`quiz.max_attempts` — authored in the admin quiz form (`quiz_basic_info_widget.dart`), returned by `GET /quiz/{id}`. `0 `**means unlimited** (`Quiz` model default is `3`).

### **3.2 The increase (admin "gives the user more attempts")**

The admin does this from the learner-analytics view:

```text theme={null}
AdminLearnerAnalyticsProvider.resetStudentExamAttempts()   (admin_learner_analytics_provider.dart:264)
   → POST /quiz/{quiz_id}/reset_student_attempts           (api_client.dart:656)
     body: { "student_id": <id>, "note": "<optional>" }
     → ResetAttemptsResponse { message, reset_count, allowed_attempts }
```

It is **non-destructive**: old attempts stay, the ceiling is raised by one full `max_attempts` cycle. The success dialog quotes `allowed_attempts` straight from the POST response.

### **3.3 How the *student* side computes the ceiling — and the mismatch**

`QuizProvider.effectiveAllowedAttemptsFor(quiz)` (`quiz_provider.dart:136`) tries four rules in order, reading them **off the attempt rows** returned by `show_quiz_student_answers`:

| **#** | **Rule** | **Needs field** | **Present in the live response?** |
| :- | :- | :- | :- |
| 1 | `max(allowed_attempts)` | `allowed_attempts` | ❌ **never returned** |
| 2 | `max_attempts × (max(reset_count) + 1)` | `reset_count` | ❌ **never returned** |
| 3 | `max_attempts × max(reset_cycle)` | `reset_cycle` | ✅ returned |
| 4 | `max_attempts` | — | fallback |

So **in production today the app always lands on rule 3**. The two fields the code prefers (`allowed_attempts`, `reset_count`) exist on `ResetAttemptsResponse` (the POST) and on the `QuizAttempt` model, but the **GET never sends them** — they come back `null` on every row.

### **3.4 What this student sees, computed**

| **Exam** | `max_attempts` | **attempt rows** | `reset_cycle `**values** | **ceiling shown** | **remaining shown** |
| :- | :- | :- | :- | :- | :- |
| **22** | 3 | 7 (`attempt` 1…7) | `{3, 4}` | **12** (`3 × 4`, rule 3) | **5** |
| **19** | 3 | 3 (`attempt` 1…3) | `{3}` | **9** (`3 × 3`, rule 3) | **6** |
| **1** | 0 | 1 | `{1}` | *unlimited* | — |

So exam 22 legitimately has **7 attempts against a base of 3** — the admin reset it 3× (cycle 4). `attempt` is a **cumulative** counter (1…7), it does *not* restart per cycle. `reset_cycle` is stamped **at attempt creation** and is **not back-filled** onto older rows — which is why exam 22's first attempt still reads `reset_cycle: 3` while the rest read `4`.

***

## **4. Full field inventory of** `GET /auth/user `**(this student)**

Top-level keys (38): `id, company_id, role_id, first_name, last_name, email, phone_number, image_id, birthday, bio, blocked_at, default_revenue_percentage, applied_coupons, classroom_joined_slugs, classroom_admin_slugs, owned_trainings, owned_exams, loyalty_points, two_factor_enabled, public_profile_* (8), hide_public_profile, notifications, notification_count, company, role, trainings_as_instructor, reviews, lessons, image, wishlist, cart, orders, quiz_student_attempts, certifications`.

Payload weight (1 450 340 bytes total):

| **key** | **bytes** | **share** |
| :- | :- | :- |
| `orders` | **1 350 526** | **87.1 %** |
| `cart` | 155 109 | 10.0 % |
| `lessons` | 33 933 | 2.2 % |
| `notifications` | 5 864 | 0.4 % |
| `quiz_student_attempts` | 3 153 | 0.2 % |
| everything else | \< 1 000 | \~0 % |

Collections: `orders` 19 · `lessons` 65 · `quiz_student_attempts` 13 · `notifications` 10 · `certifications` 2 · `owned_trainings` 4 · `owned_exams` 2 · `reviews` 0 · `trainings_as_instructor` 0.

### **The** `quiz_student_attempts `**rows are almost empty**

Live shape — **5 keys only**:

```text theme={null}
{ "id": 23, "quiz_id": 19, "score": null,
  "created_at": "2026-07-10T12:10:20.000000Z",
  "quiz_questions": [ … 2 items … ] }
```

The Dart `QuizAttempt` model (`quiz_attempt.dart`) declares `attempt, finished_at, correct_answers, uncorrect_answers, score_percentage, passed_at, reset_cycle, reset_count, allowed_attempts` — **none of which are in this payload**, so they all deserialize to `null`. And `score` (which *is* sent) has **no matching** `@JsonKey`, so it's dropped on the floor.

13 rows, grouped: `quiz 22 → 7`, `quiz 19 → 3`, `quiz 1 → 1`, `quiz 23 → 1`, `quiz 10 → 1`. Rows for quiz **23** (access revoked) and **10** (never owned) are in the payload; `QuizProvider.studentExamAttempts` filters them out (→ 11 rows), but `TrainingQuizProvider.studentQuizAttempts` keeps all 13.

***

## **5. What's wrong (ranked)**

### **5.1 The attempt ceiling is guessed client-side, and goes stale after a fresh reset**

`show_quiz_student_answers` doesn't return `allowed_attempts` / `reset_count`, so the client uses `max_attempts × max(reset_cycle)` **over the attempt rows that exist**. Consequences:

* **An admin reset that isn't followed by a new attempt is invisible to the student.** `reset_cycle` is only stamped on *newly created* attempts and isn't back-filled, so a student sitting at `used == ceiling` who is granted a reset still computes the **old** ceiling → the "Start the exam" button stays **disabled**. That is the classic "admin gave me more attempts but I still can't start" report.
* **A student with 0 attempts always shows the base** `max_attempts`, no matter how many resets were granted (there is no row to read a cycle from).
* Rule 3's `base × cycle` is arithmetically different from rule 2's `base × (reset_count + 1)`; they only agree if `cycle == reset_count + 1`. Nothing guarantees that.

**Fix (backend, 1 line each):** have `GET /quiz/show_quiz_student_answers` return `allowed_attempts` and `reset_count` on each row — the Flutter side (rule 1) already consumes them and needs **no change**. Better still: expose the ceiling on the *quiz* for the current student (e.g. `max_attempts`, `attempts_used`, `attempts_allowed`) so a student with zero attempts is also correct.

### **5.2 The "Exam Attempts" tab renders columns the payload doesn't contain**

`exam_attempts_datasource.dart:155-169` builds cells from `correctAnswers`, `uncorrectAnswers`, `scorePercentage`, `passedAt` — all `null` in `/auth/user`'s `quiz_student_attempts`. Result for this student: **all 11 rows show** `0 `**correct,** `0 `**incorrect,** `0%`**, status computed from** `null`**, and "unfinished"** — even for attempt 23 of exam 19, which really scored **1.11 %** and has `passed_at: 2026-07-10 13:10:34` (visible in `show_quiz_student_answers`). Also fragile: line 155 does `myExams.firstWhere((e) => e.id == row.quizId)` **without** `orElse` — it throws if an attempt ever survives the `myExams` filter (e.g. access revoked after an attempt).

**Fix:** either include `attempt, score_percentage, correct_answers, uncorrect_answers, passed_at, finished_at` in `/auth/user`'s attempt rows, or drop them from `/auth/user` entirely and have the tab call `show_quiz_student_answers` (which already returns them).

### **5.3** `GET /quiz/{id} `**ships the full answer key to the student *before* they start**

On `/quiz-start/{id}`, the intro page (not the exam itself) already downloads all 180 questions with `quiz_question_options[].is_correct` — 195 flagged-correct options for exam 22. Anyone can read them in the Network tab and then start the exam. Same class of leak as the training-preview one already noted in `CLAUDE.md`. **Fix is backend**: strip `is_correct` (and `explanation`) unless the caller is staff or the attempt is finished, or serve the intro page from a slim `GET /quiz/{id}/preview` (label, duration, max\_attempts, questions\_count, passing\_percentage).

### **5.4** `/auth/user `**is a 1.45 MB boot-blocking request**

87 % is `orders`, which eager-loads every purchased training with its chapters/lessons; `cart` adds another 10 %. It's fetched on every cold start and blocks the first paint (`authUserAndRole` is awaited in `app.dart`). It also carries `quiz_questions` inside each attempt row for no reason.

**Fix:** trim `orders` to what `handleStudentRole()` actually reads (`id, reference, trainings[{id,label,slug,image,pivot}], quizzes[{id,label,slug,max_attempts,pivot}]`), drop `quiz_questions` from `quiz_student_attempts`, and move `cart` behind its own endpoint. That alone should take the payload under \~100 KB.

### **5.5 Smaller stuff**

* `QuizHomeMainScreen` re-issues `GET /quiz/{id}` on every rebuild (`FutureProvider` created inside `build()`). Hoist it into `initState`/a memoized future.
* `owned_exams` (slugs) is returned but never read, and disagrees with the orders-derived list (2 vs 3 exams). Either fix it and use it as *the* source, or remove it.
* `GET /user/{id}/exams_by_student` exists and returns exactly `myExams`, but is only called after a voucher redemption. If `orders` gets slimmed, this is the natural endpoint for the tab.
* Revoked-access exams (23, 24, 27) and their attempts still ship in the payload; the filtering is entirely client-side.

***

## **6. Reproduce**

```text theme={null}
docs/api_samples/student_fivermail/capture.sh          # test backend, seeded student
API=http://127.0.0.1:8020/api EMAIL=… PASSWORD=… docs/api_samples/student_fivermail/capture.sh
```

Saved artefacts (`docs/api_samples/student_fivermail/`):

| **file** | **what** |
| :- | :- |
| `01_POST_auth_login.json` | login response (token redacted) |
| `02_GET_auth_user.full.json` | the complete 1.45 MB payload |
| `02_GET_auth_user.shape.json` | same, every list truncated to 1 item — readable |
| `03_GET_quiz_{1,19,22}.shape.json` | quiz payload, 1 sample question kept |
| `04_GET_show_quiz_student_answers_quiz_{1,19,22}.json` | the attempt rows (question bodies stripped) |

The `03_*.full.json` dumps are deliberately **not** committed — ≈400 KB each and they contain the answer key. `capture.sh` regenerates them.

***

## **7. Diagrams — how the app works today**

> Mermaid. Renders natively on GitHub/GitLab and in most IDE markdown previews. 🔴 red = the broken/leaky edges described in §5.

### **7.1 Boot — the one call that feeds "My Exam Simulator"**

### **7.2 Page** `/student-my-content `**→ tab "My Exam Simulator" (zero network calls)**

### **7.3 Page** `/quiz-start/:quizId `**— the two calls**

### **7.4 The attempt-ceiling decision (**`effectiveAllowedAttemptsFor`**)**

### **7.5 Admin "give more attempts" vs. what the student computes**

### **7.6 Data model as wired today (fields the app reads)**

### **7.7 One picture: the whole student exam surface**


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