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 againsthttps://backend-test.theteam.com.tn/apion 2026-07-13 with the seeded learnerstudent@fivermail.com/123456(user id 1, role 1 = student, company 1). Raw request/response JSON lives indocs/api_samples/student_fivermail/; re-capture it any time withdocs/api_samples/student_fivermail/capture.sh.
0. TL;DR — the four things worth knowing
GET /auth/useris the only source for the My Exam Simulator tab. There is no “my exams” endpoint on that screen. The list is built client-side fromorders[].quizzes[]wherepivot.access_revoked == 0.GET /auth/useris 1.45 MB for this student, and 87 % of it isorders(withcartanother 10 %). It is fetched on every app boot.- 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_answersreturnsreset_cyclebut notreset_countand notallowed_attempts— the two fieldsQuizProvider.effectiveAllowedAttemptsFor()prefers. So the app silently falls back tomax_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). - Two real bugs are visible in this student’s data: the Exam Attempts tab renders score/correct/passed columns that
/auth/usernever sends (so it shows0 / 0 / 0% / unfinishedfor all 13 rows — §5.2), andGET /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:
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)
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:
QuizProvider.examPreviewAttempts and consumed by:
usedAttemptsFor(quiz)→examPreviewAttempts.where(quizId == …).lengtheffectiveAllowedAttemptsFor(quiz)→ the ceiling (§3)_HistoryBandon the intro page, and the “Before you begin”StartExamModal(remaining = total − used, start button disabled whenremaining <= 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: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:
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_cycleis only stamped on newly created attempts and isn’t back-filled, so a student sitting atused == ceilingwho 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 × cycleis arithmetically different from rule 2’sbase × (reset_count + 1); they only agree ifcycle == reset_count + 1. Nothing guarantees that.
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
QuizHomeMainScreenre-issuesGET /quiz/{id}on every rebuild (FutureProvidercreated insidebuild()). Hoist it intoinitState/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_studentexists and returns exactlymyExams, but is only called after a voucher redemption. Ifordersgets 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
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.