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 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) ✅
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
- What shipped (19/10/2026)
- What happens today (measured)
- Why it is slow: three costs, not one
- What the student app actually reads from the payload
- Target shape of
GET /auth/user(student) - Answers to the specific questions
- Backend plan (
LMS_Backend) - Frontend plan (
LMS_Frontend, student side) - Documentation plan (
LMS_Documentation) - Repo hygiene: the committed payload dump
- Rollout order and compatibility
- How to measure before / after
- Out of scope (but related)
1. What happens today (measured)
1.1 The boot sequence on the web app
1.2 What the 1.5 MB is made of
From02_GET_auth_user.full.json (user id 1, student, company 1 — 1 550 159 bytes serialized):
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
2. Why it is slow: three costs, not one
- Database time (server). The eager-load tree is ~30 queries, but the
$appendsaccessors add roughly 6 queries perTrainingand 5 perQuizat 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 thewishlist/orderslazy loads that run inside the resource. - Bytes on the wire (network). 1.5 MB of JSON. If the Plesk/nginx vhost does not gzip
application/jsonthis is 1.5 MB on a Tunisian mobile connection; gzipped it is still ~150 KB that has to be decoded. - Parse and fan-out time (browser main thread). Dart’s
jsonDecode+json_serializablefromJsonfor ~1 500 question objects, thenhandleStudentRole()and three separate call sites (overview_screen.dart:59-61,student_space.dart:27-29,student_statefulshell.dart:54-66) that walkorders[].trainings[].chapters[].lessons[]on every build to setlesson.isValidate. On CanvasKit web this all runs on the UI thread, so the spinner stays up while it runs.
3. What the student app actually reads from the payload
Traced inLMS_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
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 withshow_quiz_student_answers?quiz_id&attempt_id.orders[].quizzes[].quiz_questions→.lengthon the My Exams cards and the “no questions” start gate, plus the review fallback. Replaced byquiz_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)
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.
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):
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 plusquestions.length. Sendquiz_questions_count(onewithCount('quiz_questions'), no question rows) and stop sendingquiz_questionsinsideorders[].quizzes[]. The card’s “no questions” guard becomesquiz_questions_count == 0. - The quiz-start page (
/student-my-content/quiz-start/:id) already fetches what it needs when it opens:GET /quiz/{id}thenGET /quiz/show_quiz_student_answers?quiz_id&is_exam=1. Keep that. Two fixes there: hoist theFutureProviderout ofbuild()(it refires on every rebuild,quiz_home_main.dart:91-92), and stop shipping the answer key onGET /quiz/{id}to students before an attempt is finished (clean_up.md G.1 #5–6; out of scope for this file but the sameis_correctproblem).
5.4 “How and where are exam attempts calculated, and what is the fastest way?”
Today:
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:
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:
- Pick one wire format for the whole API and stick to it. The existing convention is
Y-m-d H:i:sinAfrica/Tunis(backendCLAUDE.md§8), and the Flutter code depends on it (passedAt.substring(0,16)inexam_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 tinyDates::wire()helper) instead of the raw Carbon. If one day the app needs real timezone handling for users outside Tunisia, switch everything totoIso8601String()(2026-06-19T07:40:12+01:00) in one release; do not mix. - 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) keepcreated_at,finished_at,passed_at. Dropupdated_at,deleted_at,archived_at, pivot timestamps everywhere else. - Frontend: parse once, not on every build. Give the Dart models
DateTime?fields with a smallJsonConverter(Y-m-d H:i:s⇄DateTime) instead ofString?+DateTime.parseinsidebuild()(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
Onlyapp_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:
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()withUser::with(['image','company.companyImage','role.permissions'])->find(auth()->id())andresponse()->json(app(AuthUserPayloadService::class)->build($user)). Keep$this->authorize('view', $user). - Transition flag (see §10): while the frontend switches, accept
?legacy=1to return the oldUserResourceso a stale web bundle keeps working; delete the flag andUserResource’s order/attempt branches afterwards.
6.3 Related endpoints (small, same release)
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 therenotifications (student_id, notif_archive_status, created_at)
6.5 Tests (tests/Feature/AuthUserPayloadTest.php, SQLite like the other exposure tests)
- Student payload has
my_trainings,my_exams,validated_lesson_ids,cart_summary,certifications, 3 notifications max. - Student payload never contains the strings
is_correct,"content",quiz_questions",explanation,revenue_percentage, an instructoremail, or the keysorders/quiz_student_attempts/lessons. - Revoked exams/trainings are excluded; a training bought twice appears once.
- Progress numbers match fixtures (26 validated lessons →
lessons_completed: 26; a passed quiz →quizzes_passed: 1). attempts.allowedisnullformax_attempts = 0, equalsmax_attempts * (reset_count + 1)otherwise.- Every date in the payload matches
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/. - 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 thewishlist/orderslazy->load()branches once the legacy flag is gone. Keep the class if admin tooling uses it (grep UserResourcefirst).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/withCountinstead). Note it inclean_up.mdG.2 as a follow-up.- Update
LMS_Backend/CLAUDE.md§10/§12 with a line: “GET /auth/useris whitelist-based (AuthUserPayloadService); extend by adding to the whitelist, never by loading relations on the user.” - Update
clean_up.mdG.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)
7.2 Boot (lib/core/providers/app_service.dart)
handleStudentRole(): fillStudentTrainingProvider.myTrainingsfromuser.myTrainings,QuizProvider.myExamsfromuser.myExams,certificateStudentfromuser.certifications,NotificationProviderfrom the 3 notifications + count,CartProviderfromcartSummary,StudentTrainingProvider.validatedLessonIdsfromuser.validatedLessonIds. Delete theorders[].trainings/orders[].quizzesaccess_revokedfilters (the backend already filtered) and thestudentQuizAttempts/studentExamAttemptsassignment.- Do not
awaitGET /dashboard/user/{id}/progressin series at boot (:231). Either fire it from the “Learning activity” card when it mounts or run it inFuture.waitwith/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): removevalidateLessonsInTrainingat boot and the per-build calls inoverview_screen.dart:59-61,student_space.dart:27-29,student_statefulshell.dart:54-66.allTrainingsWithProgressbecomes a thin wrapper aroundmyTrainings(each already carriesprogress).ContinueLearningCard(continue_learning_card.dart:136-173),enrolled_training_screens.dart:170-188,learner_progress_cards.dart:151-205,overview_screen.dart:640-655: replacecalculateValidatedLessons / calculateTotalItems / quizzes.where(passed)withtraining.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 (markingisValidateon theGET /training/{id}tree usingvalidatedLessonIds); delete the rest. Fixes B1 and B4.- After
POST /lesson/{id}/toggle_lesson_validation: updatevalidatedLessonIdslocally (andprogresscounters from the toggle response once the backend returns them) instead ofGET /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: usequiz.quizQuestionsCountinstead ofquestions?.length.- Show
attempts.used / allowedandbest_score_percentageon the card if wanted (data is now free). quiz_home_main.dart:91-92: create thegetQuizApifuture ininitState/ memoize it.exam_preview_page.dart+quiz_provider.dart:124-157: prefer the serverattempts.allowed/remaining(fromGET /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 (
initStateofExamAttemptsDataTable/ExamAttemptsMobile, or aFutureProviderin the route builder):QuizProvider.loadStudentExamAttempts()→GET /quiz/show_quiz_student_answers?student_id&is_exam=1&summary=1. Keep aloadedflag + manual refresh; invalidate afterfinish_student_quiz. exam_attempts_datasource.dart:155:myExams.firstWhere(... , orElse: ...)(an attempt can outlive access).- The review (eye icon) keeps
openExamAttemptReviewbut fetches the single attempt with answers. - Fixes B2.
7.6 Notifications
- Popup open (
student_appbar_widget.dart:270-290): callGET /notifications/user/{id}?per_page=10and replaceprovider.notifications; the 3 boot items are shown meanwhile. - Badge =
user.notification_countdirectly; 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 thecartIdparam toorderType.
7.8 Certificates
Certificate.fromJsonreadstraining_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 readtraining.certificationId/quiz.certificationIdfrom the cards.
7.9 Optional, after the above: paint before the network
With a ~20 KB payload the blockingawait 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 tomajor_code_updates.md(append only, never truncate), with the B1–B7 bug list and the files touched. - Update
LMS_Frontend/CLAUDE.md§3 (“myExamscome from the login payload”) andauthed_user_and_attempts.md(or delete the latter once the docs page is rewritten, §8). clean_up.mdApp Speed Suggestions row 2 → ✅.
8. Documentation plan (LMS_Documentation) (DONE ✅)
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:
git rmthe.full.json; keep02_GET_auth_user.shape.json(6 KB, one item per list) after redacting the e-mail / names / phone.- Add
docs/api_samples/**/*.full.jsonto.gitignore; makecapture.shwrite full dumps to a git-ignoreddocs/api_samples/_local/folder. Remove the hard-coded password fromcapture.sh(readEMAIL/PASSWORDfrom env only). - Decide whether to purge history (
git filter-repoon 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. - Delete the stray
LMS_Frontend/gitignorefile (no dot) noted inclean_up.md. - 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:
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:
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’scontentand the chapter-quiz answer keys in one shot;clean_up.mdG.1 #4 / G.2 row 2 already describe the fix (structure endpoint + per-lesson content).GET /quiz/{id}shipsis_correctto a student before the attempt starts (G.1 #5–6).Training::$appends/Quiz::$appendsfire N queries on every list endpoint in the app, not only here.- Code-splitting the Flutter bundle by role (
clean_up.mdApp Speed row 1) is the next biggest web win after this.