Skip to main content
Superseded on 19/10/2026. GET /auth/user is now the slim payload documented in Auth §9 and Auth User Optimize: 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, Auth User Super Fast), 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:
handleStudentRole() fans the single User object out into the providers: Then the render path:
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: → “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)

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, … }. The exam config the intro page reads:

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:
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:
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: 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

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

Saved artefacts (docs/api_samples/student_fivermail/): 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