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

# [Suggestions] User Attempts Fix (v4)

> ## **✅ STATUS — SHIPPED (count-based variant). See [v3 backlog](https://github.com/THE-TEAM-com-tn/elearny_app_web/blob/Gamification-06-2026/x_README_API_Documentation/readme_suggestions.md#suggestions-user-attempts-fix--v3-post-implementation-backlog) below.**
>
> The feature described in §1–§12 below was **implemented**, but with a **simpler count-based ceiling**, not the timestamp-cutoff design originally proposed here. What actually shipped:
>
> * **Table / model:** `quiz_student_resets` + `App\Models\QuizStudentReset` — as proposed.
> * **Ceiling rule (the key difference):** `start_student_quiz` does **not** count "attempts after the latest reset timestamp." Instead it counts **reset rows** and allows `max_attempts * (reset_count + 1)` total attempts, gated against the **absolute monotonic** `attempt` counter. `max_attempts == 0` stays unlimited.
> * **Endpoint:** `POST /api/quiz/{quiz_id}/reset_student_attempts` (`reset_student_attempts($quiz_id)`), authorized via `$this->authorize('update', $quiz)` with a `403` on `AuthorizationException`. Returns `reset_count` + computed `allowed_attempts`.
> * `show_quiz_student_answers`**:** the optional `reset_cycle` annotation **was** implemented (derived in-memory, timestamp-bucketed). The `?include_resets=true` reset-log payload was **not**.
> * **History:** `reset_student_attempts` action logged against `quiz_student_resets`.
>
> ⚠️ The pseudocode/snippets in §3, §7 below describe the **original (timestamp-based) proposal**, which is **NOT** what runs in the codebase. Treat §1–§12 as design history; the **v3 section is the source of truth for current behavior and open work.**
>
> 📎 The backend `CLAUDE.md` still lists this document as `x_README_API_Documentation/suggestions_attempts_readme.md` (its file was `readme_suggestions.md`). That folder no longer exists in `LMS_Backend`; this page is the surviving copy.

# **Suggestions — Reset Student Attempts (Non-Destructive)**

> **Context:** This document proposes how to add an "attempt reset" feature that lets an admin give a student a fresh start on a quiz or exam **without deleting any historical attempt records.**
>
> References: [Quizzes & Exams endpoints](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-endpoints.md) · [Quizzes & Exams progress tracking](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-progress-tracking-api.md)

***

## **Table of Contents**

1. **Problem Statement**
2. **Why Not Just Delete the Old Attempts?**
3. **Recommended Approach —** `quiz_student_resets `**Table**
4. **New Database Table**
5. **New Model:** `QuizStudentReset`
6. **New Endpoint — Reset Attempts**
7. **Modified Endpoint —** `start_student_quiz`
8. **Optional Enhancement —** `show_quiz_student_answers`
9. **History Tracking**
10. **Routes Addition**
11. **Business Rules & Edge Cases**
12. **Summary of Files to Create / Modify**

***

## **1. Problem Statement**

Currently `start_student_quiz` computes the next attempt number as:

```text theme={null}
attempt = (latest attempt for this student × quiz) + 1, or 1 if none
```

If `quiz.max_attempts > 0` and the new number exceeds it, the endpoint returns:

```text theme={null}
401 Quiz/Exam cannot be started, too many attempts.
```

There is no way for an admin to grant extra chances without either:

* Deleting rows from `quiz_student_attempts` / `quiz_student_answers` (destroys history), or
* Increasing `quiz.max_attempts` globally (affects every student, not just one).

***

## **2. Why Not Just Delete the Old Attempts?**

| **Concern** | **Impact** |
| :- | :- |
| Audit trail | Score history and answers are lost for compliance / reporting |
| Certification records | `CertificationsController` and instructor dashboards reference past attempts |
| Analytics | Drop in sample size skews per-question difficulty stats |
| Student trust | Students expect to see their previous results |

The only safe path is to **keep all rows and make the "max attempts" check aware of resets**.

***

## **3. Recommended Approach —** `quiz_student_resets `**Table**

Introduce a lightweight **reset log** table. Each row says:\
*"As of this timestamp, student X's attempt counter for quiz Y is considered to restart."*

When `start_student_quiz` checks the attempt ceiling, it:

1. Finds the **latest reset** for this student × quiz (if any).
2. Counts only the attempts created **after** that reset timestamp.
3. Uses that count (+ 1) as the "effective attempt number" against `max_attempts`.

The **absolute** `attempt` column in `quiz_student_attempts` continues to increment monotonically (preserving the full record), but it is no longer what gates the ceiling check.

***

## **4. New Database Table**

**Migration:** `create_quiz_student_resets_table`\
**Table name:** `quiz_student_resets`

```text theme={null}
Schema::create('quiz_student_resets', function (Blueprint $table) {
    $table->id();
    $table->unsignedBigInteger('quiz_id');
    $table->unsignedBigInteger('student_id');
    $table->unsignedBigInteger('reset_by');   // admin / instructor user id
    $table->text('note')->nullable();          // optional reason
    $table->timestamps();

    $table->foreign('quiz_id')->references('id')->on('quizzes')->onDelete('cascade');
    $table->foreign('student_id')->references('id')->on('users')->onDelete('cascade');
    $table->foreign('reset_by')->references('id')->on('users')->onDelete('cascade');

    $table->index(['quiz_id', 'student_id']);  // fast lookup in start_student_quiz
});
```

**Columns:**

| **Column** | **Type** | **Notes** |
| :- | :- | :- |
| `id` | bigIncrements | PK |
| `quiz_id` | FK → `quizzes` | which quiz/exam |
| `student_id` | FK → `users` | which student |
| `reset_by` | FK → `users` | who triggered the reset (admin / instructor) |
| `note` | text, nullable | reason (e.g. "technical issue during attempt 2") |
| `created_at` | timestamp | the moment the reset took effect |
| `updated_at` | timestamp | |

***

## **5. New Model:** `QuizStudentReset.php`

**File:** `app/Models/QuizStudentReset.php`

```text theme={null}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class QuizStudentReset extends Model
{
    protected $table = 'quiz_student_resets';

    protected $fillable = [
        'quiz_id',
        'student_id',
        'reset_by',
        'note',
    ];

    public function quiz()
    {
        return $this->belongsTo(Quiz::class, 'quiz_id');
    }

    public function student()
    {
        return $this->belongsTo(User::class, 'student_id');
    }

    public function resetBy()
    {
        return $this->belongsTo(User::class, 'reset_by');
    }
}
```

***

## **6. New Endpoint — Reset Attempts**

**Controller:** `app/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.php`\
**Route:** `POST /api/quiz/{quiz_id}/reset_student_attempts`\
**Authorization:** admin / instructor only (use existing policy gate — e.g. `update` on `Quiz` or a dedicated `resetAttempts` policy method)

### **Request**

| **Field** | **Type** | **Required** | **Notes** |
| :- | :- | :- | :- |
| `student_id` | integer | **yes** | must exist in `users` |
| `note` | string | no | reason for the reset; stored for audit |

```text theme={null}
{
  "student_id": 7,
  "note": "Student experienced a technical failure during attempt 3 — granting a fresh start."
}
```

### **Behavior**

1. Validate `student_id` (exists in `users`) and `quiz_id` (exists in `quizzes`, found via route param).
2. Authorize the caller (`update` on the quiz, or a dedicated policy).
3. Insert a row into `quiz_student_resets`:
   * `quiz_id` = route param
   * `student_id` = request body
   * `reset_by` = `auth()->id()`
   * `note` = request body (nullable)
4. Return success response.
5. History: `reset_student_attempts`.

### **Success Response (200)**

```text theme={null}
{
  "message": "Student attempts reset successfully.",
  "student_id": 7,
  "quiz_id": 5,
  "reset_at": "2026-05-20T11:00:00.000000Z",
  "note": "Student experienced a technical failure during attempt 3 — granting a fresh start."
}
```

### **Error Responses**

| **Status** | **Reason** |
| :- | :- |
| `404` | `quiz_id` not found |
| `403` | Caller is not authorized to reset attempts for this quiz |
| `422` | Missing or invalid `student_id` |

***

## **7. Modified Endpoint —** `start_student_quiz`

**File:** `app/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.php`\
**Route:** `POST /api/quiz/start_student_quiz` (unchanged)

### **Current logic (pseudocode)**

```text theme={null}
next_attempt = (latest attempt for student × quiz) + 1 ?? 1
if quiz.max_attempts > 0 and next_attempt > quiz.max_attempts → 401
```

### **Proposed new logic (pseudocode)**

```text theme={null}
// 1. Find the latest reset for this student × quiz (if any)
latest_reset = quiz_student_resets
    .where(quiz_id, student_id)
    .latest('created_at')
    .first()

// 2. Count attempts AFTER the reset (or all attempts if no reset)
if latest_reset exists:
    attempts_since_reset = quiz_student_attempts
        .where(quiz_id, student_id)
        .where('created_at', '>', latest_reset.created_at)
        .count()
else:
    attempts_since_reset = quiz_student_attempts
        .where(quiz_id, student_id)
        .count()

// 3. Effective next attempt number in the current cycle
effective_next = attempts_since_reset + 1

// 4. Gate against max_attempts using the effective counter
if quiz.max_attempts > 0 and effective_next > quiz.max_attempts → 401

// 5. Absolute attempt number still monotonically increments
absolute_next = (latest(attempt) for student × quiz) + 1 ?? 1

// 6. Create the row with the absolute attempt number (unchanged column semantics)
QuizStudentAttempt::create([
    'quiz_id'    => quiz_id,
    'student_id' => student_id,
    'attempt'    => absolute_next,
])
```

### **Key points**

* The `attempt `**column in** `quiz_student_attempts `**never resets** — it always grows (1, 2, 3, 4 …). This preserves the full ordered history.
* Only the **ceiling check** uses the post-reset count, so old attempts remain permanently visible.
* No columns are added to `quiz_student_attempts`; all reset state lives in `quiz_student_resets`.

### **Updated PHP snippet (for the controller method)**

```text theme={null}
public function start_student_quiz(Request $request)
{
    $request->validate([
        'student_id' => 'required|exists:users,id',
        'quiz_id'    => 'required|exists:quizzes,id',
    ]);

    $quiz = Quiz::findOrFail($request->quiz_id);

    // Find latest reset for this student × quiz
    $latestReset = \App\Models\QuizStudentReset::where('quiz_id', $request->quiz_id)
        ->where('student_id', $request->student_id)
        ->latest('created_at')
        ->first();

    // Count attempts in the current cycle
    $query = QuizStudentAttempt::where('quiz_id', $request->quiz_id)
        ->where('student_id', $request->student_id);

    if ($latestReset) {
        $query->where('created_at', '>', $latestReset->created_at);
    }

    $attemptsInCycle = $query->count();
    $effectiveNext   = $attemptsInCycle + 1;

    if ($quiz->max_attempts > 0 && $effectiveNext > $quiz->max_attempts) {
        return response()->json([
            'message' => 'Quiz/Exam cannot be started, too many attempts.',
        ], 401);
    }

    // Absolute attempt counter (monotonic, never resets)
    $absoluteNext = (QuizStudentAttempt::where('quiz_id', $request->quiz_id)
        ->where('student_id', $request->student_id)
        ->max('attempt') ?? 0) + 1;

    QuizStudentAttempt::create([
        'quiz_id'    => $request->quiz_id,
        'student_id' => $request->student_id,
        'attempt'    => $absoluteNext,
    ]);

    insert_in_history_table('start_student_quiz', $request->student_id, 'quiz_student_attempts');

    return response()->json(['message' => 'Quiz/Exam started successfully.']);
}
```

***

## **8. Optional Enhancement —** `show_quiz_student_answers`

The existing read endpoint already returns all attempts ordered latest-first. Optionally, annotate each attempt with **which reset cycle it belongs to**, so the frontend can group them visually (e.g. "Cycle 1 — attempts 1–3", "Cycle 2 — attempts 4–5 (after reset)").

### **Suggested addition to the response**

```text theme={null}
[
  {
    "id": 17,
    "quiz_id": 5,
    "student_id": 7,
    "attempt": 4,
    "reset_cycle": 2,
    "correct_answers": 9,
    "score_percentage": 90,
    "finished_at": "2026-05-20T12:00:00.000000Z",
    "passed_at": "2026-05-20T12:02:00.000000Z",
    "quiz_questions": [ ... ]
  },
  {
    "id": 14,
    "quiz_id": 5,
    "student_id": 7,
    "attempt": 3,
    "reset_cycle": 1,
    "correct_answers": 4,
    "score_percentage": 40,
    "finished_at": "2026-05-18T10:10:00.000000Z",
    "passed_at": "2026-05-18T10:12:00.000000Z",
    "quiz_questions": [ ... ]
  }
]
```

> `reset_cycle` is computed in-memory by loading the reset timestamps and bucketing each attempt by when it was created relative to those timestamps. It is a derived display value, not a stored column.

### **Optional** `resets `**query parameter**

Add `?include_resets=true` to also return the reset log alongside attempts:

```text theme={null}
{
  "attempts": [ ... ],
  "resets": [
    {
      "id": 1,
      "reset_at": "2026-05-19T09:00:00.000000Z",
      "reset_by": { "id": 1, "name": "Admin User" },
      "note": "Technical failure during attempt 3."
    }
  ]
}
```

This is entirely optional and can be added in a follow-up iteration.

***

## **9. History Tracking**

Add a new action to `insert_in_history_table`:

| **Action** | **Endpoint** | **Resource id** | **Table** |
| :- | :- | :- | :- |
| `reset_student_attempts` | `POST /api/quiz/{quiz_id}/reset_student_attempts` | `student_id` | `quiz_student_resets` |

***

## **10. Routes Addition**

In `routes/api.php`, inside the `Quizzes_Exams_Progress_Tracking_Controller` group:

```text theme={null}
Route::controller(Quizzes_Exams_Progress_Tracking_Controller::class)->group(function () {
    Route::post('/quiz/start_student_quiz',                            'start_student_quiz');
    Route::post('/quiz/finish_student_quiz',                           'finish_student_quiz');
    Route::post('/quiz/save_student_quiz_result/{attempt_id}',         'save_student_quiz_result');
    Route::get( '/quiz/show_quiz_student_answers',                     'show_quiz_student_answers');

    // NEW
    Route::post('/quiz/{quiz_id}/reset_student_attempts',              'reset_student_attempts');
});
```

***

## **11. Business Rules & Edge Cases**

| **Rule** | **Detail** |
| :- | :- |
| **Multiple resets are allowed** | Each reset adds a new row; the ceiling check always uses the *latest* reset as the cutoff. A student can be reset more than once. |
| **Reset with unlimited attempts** | If `quiz.max_attempts == 0` (unlimited), a reset is a no-op functionally — `start_student_quiz` never blocks anyway. The reset row is still recorded for the audit log. |
| **Reset before any attempts** | Valid — creates a row in `quiz_student_resets` with no practical effect. Does not cause errors. |
| **Who can reset?** | Recommend gating behind the `update` policy on `Quiz` (admin / instructor). Students must not be able to self-reset. |
| `finish_student_quiz `**is unchanged** | It still operates on the latest attempt row regardless of reset cycle — no changes needed there. |
| `save_student_quiz_result `**is unchanged** | It takes an explicit `attempt_id` — no cycle awareness needed. |
| `show_quiz_student_answers `**is unchanged (base)** | The `reset_cycle` annotation is optional and additive. |
| `attempt `**column stays monotonic** | Never reset or recalculated. Attempt 4 is always attempt 4 in the DB, even if it's the student's first attempt in cycle 2. |

***

## **12. Summary of Files to Create / Modify**

| **Action** | **File** |
| :- | :- |
| **Create** | `database/migrations/xxxx_xx_xx_create_quiz_student_resets_table.php` |
| **Create** | `app/Models/QuizStudentReset.php` |
| **Modify** | `app/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.php` — add `reset_student_attempts()` method; update `start_student_quiz()` ceiling logic |
| **Modify** | `routes/api.php` — add `reset_student_attempts` route |
| **Modify** (optional) | `app/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.php` — annotate `show_quiz_student_answers` with `reset_cycle` |
| **Modify** (optional) | `app/Http/Policies/QuizPolicy.php` — add `resetAttempts` gate if you want finer control than `update` |

***

>

# **Suggestions & Backlog (v2)**

###### ***Tracked improvements, follow-ups, and security notes that are not yet implemented***

This file collects suggestions raised during development — things worth doing but not yet shipped. Each entry records **what** to do, **why**, the **status**, and enough context to pick it up later. When a suggestion is implemented, mark it **Done** and link the doc that now describes the shipped behavior.

## **Table of Contents**

1. **Strip answer data from the player-facing quiz fetch (**`GET /api/quiz/{id}`**)**

***

## **1. Strip answer data from the player-facing quiz fetch (**`GET /api/quiz/{id}`**)**

| **Field** | **Value** |
| :- | :- |
| **Status** | 🟡 Open (not implemented) |
| **Area** | `QuizzesController@show` (and `@index`) |
| **Severity** | Medium — answer disclosure if a student can reach the endpoint |
| **Raised** | 2026-06-17 |
| **Related** | Instructor API doc §7 (already fixed there); Quizzes/Exams doc §5.2 |

### **The finding**

`GET /api/quiz/{id}` (`QuizzesController@show`) is the real question-delivery endpoint a quiz **player** uses to render a quiz — `POST /api/quiz/start_student_quiz` only creates an attempt row and returns a message, it carries **no** question data.

With `WithQuizQuestions=true` (the default), `show` eager-loads `quiz_questions.quiz_question_options.quiz_question_option_items` and returns the quiz **with no stripping**, so it exposes:

* every option's `is_correct` flag (True/False, Single, Multiple), and
* the intact **drag-&-drop grouping** — which `quiz_question_option_items` sit under which `quiz_question_option` (the correct answer for type 4).

Any caller who passes the `view` policy therefore receives the correct answers.

> **Current mitigation (why this is "medium", not "high"):** `QuizPolicy@view` allows only super-admin / admin / `QUIZZES_VIEW` permission holders — the student line is commented out. So *as currently seeded* a plain student cannot call `show`. The risk is realized only if `QUIZZES_VIEW` is ever granted to students, or if the player is wired to this endpoint.

`GET /api/quiz/{id}` is **not** a safe payload to serve to a student who is taking the quiz.

### **What already exists**

The instructor listing `GET /api/instructors/{instructor_id}/quizzes` (`UsersController@quizzes_by_instructor`) already strips answer data:

* hides `is_correct` on every option, and
* for Drag & Drop, removes the per-option item lists and returns all items flattened + shuffled under `draggable_items` (no `quiz_question_option_id` back-reference).

See Instructor API doc §7. That same logic is the model for the fix below.

### **Suggested fix**

1. Extract the stripping logic from `quizzes_by_instructor()` into a small reusable helper (e.g. `stripQuizAnswerData($quiz|$quizzes)`), so the listing and `show`/`index` share one implementation.
2. Apply it in `QuizzesController@show` (and `@index`) **conditionally**:
   * **Skip** stripping when the caller has quiz-edit rights (instructors authoring / admins legitimately need `is_correct`).
   * **Strip** otherwise (anyone fetching a quiz to take it). Gate on a capability check such as `Gate::allows('update', $quiz)` / the `QUIZZES_UPDATE` permission rather than a raw role string.
3. Keep the strip **per-response** (`makeHidden` + `draggable_items`), never on the model's global `$hidden` — global hiding would break the authoring endpoints that must serialize `is_correct`.

### **Acceptance criteria**

* A non-editor calling `GET /api/quiz/{id}` receives no `is_correct` and no drag-&-drop grouping; drag-&-drop items arrive flattened + shuffled under `draggable_items`.
* An editor/admin calling the same endpoint still receives the full structure (authoring unaffected).
* `index` follows the same rule.
* Quizzes/Exams doc §5.2 updated to drop the ⚠️ exposure note once shipped.

### **Notes / risks**

* `show`/`index` are widely consumed — this is a behavior change on a hot path; coordinate with the frontend before shipping.
* Confirm where the student quiz **player** actually fetches questions. If it uses a different/dedicated endpoint, that endpoint must be the one that strips (and `show` may legitimately stay editor-only).

***

# **\[Suggestions] User Attempts Fix — v3 (Post-Implementation Backlog)**

###### ***Reconciles the shipped count-based reset feature against the original proposal, and tracks the next round of hardening / UX gaps found by reading the live code.***

> **Scope:** the attempt-reset feature is **live** (`quiz_student_resets`, `QuizStudentReset`, `reset_student_attempts`, the count-based ceiling in `start_student_quiz`, and the `reset_cycle` annotation in `show_quiz_student_answers`). This section does **not** re-propose it — it documents exactly what runs today and the concrete follow-ups worth doing.
>
> **Files in play:** `app/Http/Controllers/Quizzes_Exams_Progress_Tracking_Controller.php` · `app/Models/QuizStudentReset.php` · `database/migrations/2026_06_05_000000_create_quiz_student_resets_table.php` · `routes/api.php`

## **Table of Contents**

1. **What actually shipped (proposal vs. reality)**
2. **V3-1 — Abandoned/empty attempts burn the ceiling**
3. **V3-2 — Wrong HTTP status for "too many attempts" (401)**
4. **V3-3 — No company/tenant check on** `student_id`
5. **V3-4 — Reset is unconditional & cannot be undone**
6. **V3-5 — No reset-log read endpoint (**`?include_resets=true `**never shipped)**
7. **V3-6 —** `reset_cycle `**(timestamp) vs. ceiling (count) use different mental models**
8. **V3-7 — Race condition on the absolute** `attempt `**counter**
9. **V3-8 —** `allowed_attempts: 0 `**overloads "unlimited" and "none"**
10. **V3-9 — Student is not notified when granted a reset**
11. **Priority summary**

***

## **1. What actually shipped (proposal vs. reality)**

| **Aspect** | **Original proposal (§1–§12)** | **Shipped (live code)** |
| :- | :- | :- |
| Ceiling computation | Count attempts **created after** the latest reset's timestamp; compare `count + 1` to `max_attempts` | Count **reset rows**; allow `max_attempts * (reset_count + 1)` total, compared to the **absolute** `attempt` value |
| Reset row meaning | A timestamp cutoff that restarts the cycle | A grant of **one more full cycle** of `max_attempts` |
| `start_student_quiz` signature | `start_student_quiz(Request $request)` | `start_student_quiz()` (reads `request()`), no params |
| `reset_student_attempts` response | `message`, `student_id`, `quiz_id`, `reset_at`, `note` | adds `reset_count` + computed `allowed_attempts` (`0` = unlimited) |
| `reset_cycle` annotation | Optional | **Implemented** (in-memory, timestamp-bucketed) |
| `?include_resets=true` log | Optional | **Not implemented** |
| Authorization | `update` on `Quiz` (or dedicated gate) | `$this->authorize('update', $quiz)`, `403` on `AuthorizationException` |

**Net effect:** behaviorally the count-based variant is *equivalent* to the timestamp variant **as long as resets are only granted after a cycle is exhausted** — but it is simpler and does not depend on attempt timestamps. The divergences below stem from that simplification.

***

## **2. V3-1 — Abandoned/empty attempts burn the ceiling**

| **Field** | **Value** |
| :- | :- |
| **Status** | 🟡 Open |
| **Area** | `start_student_quiz()` |
| **Severity** | High — a student can lock themselves out without ever finishing a quiz |
| **Raised** | 2026-06-18 |

### **The finding**

`start_student_quiz()` increments the absolute `attempt` counter on **every call** and immediately creates a `quiz_student_attempts` row — *before* any question is answered:

```text theme={null}
$attempt = (QuizStudentAttempt::where('quiz_id', request('quiz_id'))
    ->where('student_id', request('student_id'))
    ->max('attempt') ?? 0) + 1;
// ... ceiling check ...
QuizStudentAttempt::create([... 'attempt' => $attempt]);
```

`finish_student_quiz()` only ever operates on `latest('attempt')->first()` **if** that latest row has no answers yet. So if a student hits "start" three times by accident (network retry, double-click, refresh), they create attempts 1, 2, 3 — all empty — and a quiz with `max_attempts = 3` is now exhausted with **zero** completed attempts. The admin's only recourse is to grant a reset.

### **Suggested fix**

Before creating a new attempt, **reuse the latest unfinished attempt** instead of minting a new one:

```text theme={null}
$open = QuizStudentAttempt::where('quiz_id', request('quiz_id'))
    ->where('student_id', request('student_id'))
    ->whereNull('finished_at')
    ->latest('attempt')->first();

if ($open) {
    return response()->json(['message' => 'Quiz/Exam already started.', 'attempt' => $open->attempt]);
}
```

Only consume a new attempt number when the previous one is actually `finished_at`. This makes "attempt" mean *a completed sitting*, which is what `max_attempts` is meant to bound.

### **Acceptance criteria**

* Repeated `start_student_quiz` calls with no intervening `finish` do **not** increment `attempt`.
* The ceiling counts finished sittings, not abandoned starts.

***

## **3. V3-2 — Wrong HTTP status for "too many attempts" (401)**

| **Field** | **Value** |
| :- | :- |
| **Status** | 🟡 Open |
| **Area** | `start_student_quiz()` |
| **Severity** | Low — semantic / client-handling correctness |
| **Raised** | 2026-06-18 |

The ceiling rejection returns `401`:

```text theme={null}
return response()->json(['message' => 'Quiz/Exam cannot be started, too many attempts.'], 401);
```

`401 Unauthorized` means "you are not authenticated." This case is an authenticated, authorized student who has simply run out of attempts. A frontend that auto-redirects to login on `401` will mis-handle it. Use `403 Forbidden` (policy-style refusal) or `409 Conflict `**/** `429 Too Many Requests` (state-based refusal). Recommend `403`. Also route the message through `trans('messages.too_many_attempts')` for i18n parity with the rest of the app.

***

## **4. V3-3 — No company/tenant check on** `student_id`

| **Field** | **Value** |
| :- | :- |
| **Status** | 🟡 Open |
| **Area** | `reset_student_attempts()`, `start_student_quiz()` |
| **Severity** | Medium — cross-tenant data action |
| **Raised** | 2026-06-18 |

`reset_student_attempts` validates only `student_id => exists:users,id` — **globally**, with no `company_id` scoping. `authorize('update', $quiz)` + `company_policy_middleware` constrain the **quiz** to the caller's company, but the **student** is never checked against it. An admin could pass a `student_id` belonging to another company and create a `quiz_student_resets` row pairing a foreign student with a local quiz.

### **Suggested fix**

Verify the target student shares the quiz's company before writing the reset (per CLAUDE.md §3 — company scoping is **never** automatic):

```text theme={null}
$student = User::where('id', request('student_id'))
    ->where('company_id', $quiz->company_id)
    ->firstOrFail();   // 404 instead of a cross-tenant grant
```

***

## **5. V3-4 — Reset is unconditional & cannot be undone**

| **Field** | **Value** |
| :- | :- |
| **Status** | 🟡 Open |
| **Area** | `reset_student_attempts()` |
| **Severity** | Medium — accidental ceiling inflation, no rollback |
| **Raised** | 2026-06-18 |

Every call adds a reset row and grants another full cycle, with **no guard** and **no inverse operation**:

* **No "already has attempts left" guard.** Granting a reset to a student who hasn't exhausted their current cycle inflates the ceiling further. Optionally reject (or warn) when `current_attempt_count < allowed_attempts` — i.e. the student still has chances.
* **No undo.** A mis-click permanently grants a cycle; the only fix is deleting the row in the DB. Consider a `DELETE /api/quiz/{quiz_id}/reset_student_attempts/{reset_id}` (admin-only, soft-delete-friendly) so the audit trail records the revocation rather than vanishing.
* **No cap.** Nothing stops unbounded growth of `allowed_attempts`. A `max_resets` config or per-quiz limit may be warranted.

***

## **6. V3-5 — No reset-log read endpoint (**`?include_resets=true `**never shipped)**

| **Field** | **Value** |
| :- | :- |
| **Status** | 🟡 Open |
| **Area** | `show_quiz_student_answers()` / new endpoint |
| **Severity** | Medium — audit visibility |
| **Raised** | 2026-06-18 |

The reset **log** (who reset, when, why) is written but never **read back** by any API. `show_quiz_student_answers` annotates `reset_cycle` but exposes neither `reset_by`, `note`, nor `reset_at`. Admins/instructors have no way to see *why* a student has an inflated ceiling without DB access.

### **Suggested fix**

Ship the originally-proposed `?include_resets=true` (or a dedicated `GET /api/quiz/{quiz_id}/student_attempts/{student_id}/resets`) returning:

```text theme={null}
{
  "resets": [
    { "id": 1, "reset_at": "2026-06-18 11:00:00", "reset_by": { "id": 4, "name": "Admin User" }, "note": "Technical failure." }
  ]
}
```

Gate it behind the same `update`-on-`Quiz` policy used to create resets (students should not see who granted them attempts).

***

## **7. V3-6 —** `reset_cycle `**(timestamp) vs. ceiling (count) use different mental models**

| **Field** | **Value** |
| :- | :- |
| **Status** | 🟢 Low / document-only |
| **Area** | `show_quiz_student_answers()` vs. `start_student_quiz()` |
| **Severity** | Low — display vs. enforcement can diverge in edge cases |
| **Raised** | 2026-06-18 |

The **ceiling** is purely count-based (`reset_count + 1` cycles, timestamps ignored). The `reset_cycle` display buckets each attempt by `reset.created_at <= attempt.created_at`. These agree under normal use (reset → student takes attempts → reset again), but diverge if resets are granted out of order relative to attempts, e.g. two resets created *before* the student takes any attempt: the ceiling correctly grants 3 cycles, but every attempt is labelled `reset_cycle = 3`, which reads oddly. This is cosmetic; just document that `reset_cycle` is a best-effort display grouping, not the enforcement unit.

***

## **8. V3-7 — Race condition on the absolute** `attempt `**counter**

| **Field** | **Value** |
| :- | :- |
| **Status** | 🟡 Open |
| **Area** | `start_student_quiz()` + migration |
| **Severity** | Low/Medium — duplicate attempt numbers under concurrency |
| **Raised** | 2026-06-18 |

`$attempt = max('attempt') + 1` is a read-then-write with no lock and no unique constraint. Two near-simultaneous `start_student_quiz` calls (double-submit, retried request) can both read the same `max` and insert duplicate `(quiz_id, student_id, attempt)` rows. The migration only has a **non-unique** index `['quiz_id', 'student_id']`.

### **Suggested fix**

Add a unique constraint `['quiz_id', 'student_id', 'attempt']` (new migration) and/or wrap the read-increment-insert in a `DB::transaction` with `lockForUpdate()`. Note V3-1's "reuse open attempt" fix also largely neutralizes this by not minting a new number on every call.

***

## **9. V3-8 —** `allowed_attempts: 0 `**overloads "unlimited" and "none"**

| **Field** | **Value** |
| :- | :- |
| **Status** | 🟢 Low |
| **Area** | `reset_student_attempts()` response |
| **Severity** | Low — client ambiguity |
| **Raised** | 2026-06-18 |

The response returns `allowed_attempts: 0` to mean **unlimited** (when `max_attempts == 0`). A client reading `0` literally would conclude the student has **no** attempts — the opposite of the truth. Prefer an explicit `"unlimited": true` flag or `allowed_attempts: null` with a documented contract, rather than overloading `0`.

***

## **10. V3-9 — Student is not notified when granted a reset**

| **Field** | **Value** |
| :- | :- |
| **Status** | 🟢 Low / UX |
| **Area** | `reset_student_attempts()` |
| **Severity** | Low — UX |
| **Raised** | 2026-06-18 |

A reset silently changes what the student is allowed to do. Consider firing an in-app notification (the unified `NotificationService`, `NotificationType` *Quiz*/*Notify*) so the student knows they have a fresh chance. Per CLAUDE.md §7 this is a single-user target, so SMTP would be respected if desired.

***

## **11. Priority summary**

| **#** | **Title** | **Severity** | **Status** |
| :- | :- | :- | :- |
| V3-1 | Abandoned/empty attempts burn the ceiling | High | 🟡 Open |
| V3-3 | No company/tenant check on `student_id` | Medium | 🟡 Open |
| V3-4 | Reset is unconditional & cannot be undone | Medium | 🟡 Open |
| V3-5 | No reset-log read endpoint | Medium | 🟡 Open |
| V3-7 | Race condition on absolute `attempt` | Low/Medium | 🟡 Open |
| V3-2 | Wrong HTTP status (401) for too many attempts | Low | 🟡 Open |
| V3-6 | `reset_cycle` vs. ceiling mental-model drift | Low | 🟢 Doc |
| V3-8 | `allowed_attempts: 0` overloads unlimited/none | Low | 🟢 Low |
| V3-9 | Student not notified of granted reset | Low | 🟢 Low |

> **Recommended first slice:** V3-1 (correctness — stops self-lockout), V3-3 (tenant safety), V3-5 (audit visibility). These are independent and low-blast-radius.


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