# Forest Department Recruitment — Performance & Reliability Audit

**Target load:** 4,000–10,000 concurrent candidates, many on slow 3G in remote Balochistan.
**Scope:** Audit only. No code changes yet. Excludes schema, auth, CSRF, scoring, route names, validation rules.

---

## SECTION 1 — Asset delivery

### Current state

| Asset | Path / source | Size | Notes |
|---|---|---|---|
| `electric-ocean-theme.css` | `public/css/` (static, not via Vite) | **127.9 KB** unminified | ~35 KB gzip estimate. Serves fully on every page including exam. |
| `electric-ocean-theme.js` | `public/js/` (static) | 2.5 KB | Fine. |
| `vendor-jquery.min.js` | `public/` | 78.7 KB | jQuery 4 served from disk. |
| `vendor-bootstrap.bundle.min.js` | `public/` | 80.5 KB | Includes Popper. |
| `vendor-sweetalert2.all.min.js` | `public/` | 80 KB | Loaded on **exam page** too — not used there. |
| Bootstrap CSS | `cdn.jsdelivr.net` | ~230 KB (31 KB gzip) | CDN — **blocking** if jsDelivr is throttled in PK. |
| Bootstrap Icons | `cdn.jsdelivr.net` | ~120 KB + font files | CDN. |
| DataTables CSS + JS | `cdn.datatables.net` | ~140 KB combined | CDN. Loaded on every admin page including those without tables. |
| DataTables Buttons + JSZip | CDN | ~120 KB extra | Loaded unconditionally. |
| Chart.js | `cdn.jsdelivr.net` | ~200 KB minified | Loaded via `@push('scripts')` on dashboards only — OK. |
| Google Fonts (Inter + JetBrains Mono + Space Grotesk) | `fonts.googleapis.com` | ~100–200 KB across multiple requests | **Five weights of Inter** being pulled (400/500/600/700/800). **Blocking** for rendering. |
| Vite built CSS | `public/build/assets/app-nDuDUwLb.css` | 7 KB (empty stub) | Vite entry neutralized after EO migration; `@vite` directive in layouts is unused. |
| Vite built JS | none | — | No JS entry in Vite config. |

**Exam page JS/CSS payload (per candidate, first visit, uncached):**
- Bootstrap CSS (CDN): ~31 KB gz
- Bootstrap Icons CSS + woff (CDN): ~40 KB gz
- Electric Ocean CSS: ~35 KB gz
- jQuery: ~29 KB gz
- Bootstrap bundle: ~22 KB gz
- SweetAlert2: ~22 KB gz (**not needed on exam**)
- Google Fonts CSS + woff2 × 5 weights × 3 families: ~120 KB
- **Total: ~300 KB gzipped, 14+ requests.** At 50 KB/s (slow 3G), this is a ~6-second first-paint delay — before any exam logic runs.

### Findings

| # | Finding | Risk |
|---|---|---|
| 1.1 | `.env` has `APP_DEBUG=true`, `APP_ENV=local`. Production deploy must flip these. | **HIGH** |
| 1.2 | Vite is installed but the `app.css` entry is empty and `app.js` doesn't exist. No Vite production build is currently serving anything meaningful. All CSS/JS is static. **No minification, no fingerprinting, no gzip pipeline except whatever the web server does.** | **HIGH** |
| 1.3 | Electric Ocean CSS is 127 KB unminified because it contains ~3200 lines including the full auth split-screen, exam runner, merit podium, and all report pages. The exam page gets all of it even though it uses ~10% of the rules. | **MEDIUM** |
| 1.4 | Google Fonts is loaded via `<link>` on every page, including the exam page. PK networks often throttle Google CDN. If the request stalls, the browser still renders but with FOIT/FOUT. | **HIGH** |
| 1.5 | SweetAlert2 is loaded globally by `layouts/app.blade.php` including on admin pages that don't use it, and by `layouts/exam.blade.php` where it's only used for the "Enter review" confirmation. | **MEDIUM** |
| 1.6 | DataTables + Buttons + JSZip loaded on every admin page via the master layout, even on pages without tables (admin dashboard, candidates show, posts create/edit). | **MEDIUM** |
| 1.7 | No `defer` / `async` on any `<script>` tag — scripts block parsing. | **MEDIUM** |
| 1.8 | No `<link rel="preload" as="font">` for any font. | **LOW** |
| 1.9 | No `<img>` tag in the Blade layer uses `loading="lazy"`, `decoding="async"`, or explicit `width`/`height`. Only candidate profile photos and `ui-avatars.com` fallbacks appear — small impact but flaggable. | **LOW** |
| 1.10 | No `console.log` in shipped JS. ✓ | — |

### Proposed fixes (not yet applied)

| # | Fix | Est. bytes saved per candidate | Risk |
|---|---|---|---|
| 1.A | Self-host Inter (4 weights), JetBrains Mono (2), Space Grotesk (3) as WOFF2 in `public/fonts/`. Replace Google Fonts `<link>` with local `@font-face` + `font-display: swap`. | ~100 KB + eliminates CDN dependency | LOW |
| 1.B | Preload the 400-weight Inter woff2 before any other asset. | — (improves perceived speed) | LOW |
| 1.C | Run `vite build` in production with Vite's proper entry, register the Electric Ocean CSS through Vite so it's minified + fingerprinted. Keep the static copy as fallback. | ~60 KB gzipped savings on CSS | LOW |
| 1.D | Split the exam page CSS into `exam-theme.css` (~15 KB) containing only tokens + exam rules. Load the full theme only on non-exam pages. | ~100 KB gzipped saved on the critical exam page | MEDIUM |
| 1.E | Remove SweetAlert2 from the exam layout. Replace the single `Swal.fire` confirmation dialog with a plain modal or `confirm()`. | ~22 KB gz | LOW |
| 1.F | Remove DataTables + Buttons + JSZip from `layouts/app.blade.php`; include them conditionally via `@push('scripts')` only on pages with tables. | ~60 KB gz on non-table pages | MEDIUM |
| 1.G | Add `defer` to every non-inline `<script>`. Move jQuery + Bootstrap below page content. | Non-blocking render | LOW |
| 1.H | Add `loading="lazy"` + `width`/`height` to every candidate photo and avatar image. Bundle a simple lazy-load shim for IE fallback (not needed — Laravel 11 ships with modern browser assumptions). | Prevents CLS | LOW |
| 1.I | Document in `DEPLOY.md` the production build command (`npm run build && php artisan optimize`) and recommended Nginx cache headers (`Cache-Control: public, max-age=31536000, immutable` for fingerprinted assets). | — | LOW |
| 1.J | Produce a Nginx config snippet with `gzip on; gzip_types text/css application/javascript application/json;` and `brotli on` if available. | — | LOW |

---

## SECTION 2 — Exam interface (critical path)

### Current state

Analyzed [resources/views/exam/show.blade.php](resources/views/exam/show.blade.php) (873 lines) and [app/Http/Controllers/Candidate/ExamController.php](app/Http/Controllers/Candidate/ExamController.php) (371 lines) and [app/Http/Controllers/ExamSecurityController.php](app/Http/Controllers/ExamSecurityController.php) (299 lines).

| Behavior | Current | Adequate? |
|---|---|---|
| Answer save persistence | POST `/candidate/exam/{attempt}/save-answer` on every radio change. No localStorage backup. On error, **silently ignored** (`error: function () { /* Non-blocking */ }`). | ❌ **Dropped answers on network blip.** |
| Retry on failed save | None. Single attempt, no exponential backoff. | ❌ |
| localStorage for timer | `timeMap` is persisted per-attempt in localStorage (`exam_tm_{attempt_id}`). Answers are **not**. | ⚠️ Partial |
| Timer source of truth | Client-side JS countdown + periodic heartbeat `POST /candidate/exam/{attempt}/heartbeat` at `heartbeat_interval_seconds` from config. Server response includes `review_remaining_seconds` and `force_submitted` + redirect. | ✓ Good pattern |
| Heartbeat fail handling | Retry every 5s via `retryInterval`, show red `#connection-bar`. Timer does not pause. | ✓ Correct |
| Force-submit on expiry | `triggerAutoSubmit()` clears timers + POSTs once + retries once after 2s if it fails, then gives up. | ⚠️ Needs **unlimited retries until success** per spec. |
| Double-submit guard | `isSubmitting` flag + button disabled on click. Backend: needs audit (see below). | ⚠️ Partial |
| Question navigation | All questions loaded inline in `@json($questionsData)` on page load; navigation is pure JS (no AJAX per question). | ✓ Excellent — zero extra server roundtrips. |
| Preload next question | N/A — all loaded upfront. | ✓ |
| `beforeunload` warning | Implemented (line 455–460). | ✓ |
| Lightweight exam page | ❌ Loads Bootstrap + Bootstrap Icons + jQuery + Bootstrap bundle + SweetAlert2 + 127 KB theme CSS + Google Fonts × 3. Animated `.hero-*` blobs don't run (no hero on exam), but the CSS rules are parsed anyway. | ❌ |
| Security events batching | ❌ Every `tab_switch` / `fullscreen_exit` / `window_blur` fires one AJAX immediately via `logSecurityEvent()`. A candidate who alt-tabs 30× generates 30 POSTs. | ❌ |
| Backend idempotency on submit | `ExamSubmissionService::finalize()` uses `DB::transaction` + `lockForUpdate`. Re-submit on already-completed attempt is **not explicitly blocked at controller level** — needs verification. | ⚠️ Audit required |
| Answer save rate-limit | None on `/save-answer`. A malicious or buggy client could hammer it. | ⚠️ |
| Heartbeat rate-limit | None. | ⚠️ |

### Critical gaps vs. spec

| # | Gap | Severity |
|---|---|---|
| 2.1 | **No localStorage backup for answers before AJAX.** A dropped POST = dropped answer. This is the single most dangerous bug. | 🔴 CRITICAL |
| 2.2 | **No retry logic** on `save-answer`. Silent failure on error. | 🔴 CRITICAL |
| 2.3 | **No reconciliation on page load** — if the candidate's browser crashed with 5 pending answers in localStorage, they're lost. | 🔴 CRITICAL |
| 2.4 | **Final submit retries only once.** If the server is saturated at exam-end (9:00 AM spike), this will silently fail for thousands. | 🔴 CRITICAL |
| 2.5 | **Security events not batched.** 30 alt-tabs × 4k candidates = 120k extra DB writes. | 🟠 HIGH |
| 2.6 | **No save-status pill** ("Saved ✓ / Saving… / 3 pending"). Candidates don't know if their answer stuck. | 🟠 HIGH |
| 2.7 | **Exam page loads 300 KB of unused CSS/JS** (SweetAlert, half of Electric Ocean theme, DataTables via layout inheritance path). | 🟠 HIGH |
| 2.8 | **No debounce** on answer saves (radio changes are single-click so fine, but if text inputs are added later this becomes a bug). | 🟡 MEDIUM |
| 2.9 | **No rate-limiting middleware** on `/save-answer`, `/heartbeat`, `/security-log`. | 🟡 MEDIUM |
| 2.10 | **Submit button** has `isSubmitting` JS flag but no server-side idempotency token. If the first POST succeeds but network drops before response, a retry creates duplicate submission. Needs audit of `ExamSubmissionService::finalize()`. | 🟠 HIGH |

### Proposed fixes

| # | Fix | Risk |
|---|---|---|
| 2.A | **localStorage-first answer save.** Write `exam_{attempt_id}_answers[question_id] = choice` synchronously before POST. On POST success, mark as `synced: true`. On POST error, mark as `pending: true` and queue for retry. | LOW |
| 2.B | **Exponential-backoff retry** (1s, 2s, 4s, 8s, 16s, cap 30s, **infinite retries** for exam endpoints). | LOW |
| 2.C | **Boot-time reconciliation.** On `showQuestion(startIndex)`, scan localStorage for any `pending: true` answers from prior session and drain the queue before proceeding. | LOW |
| 2.D | **Save-status pill** in the top bar: `Saved ✓` (green), `Saving…` (cyan), `Pending: N` (amber), `Offline` (red). Updates on every queue state change. | LOW |
| 2.E | **Infinite final-submit retries** with a modal "Submitting your exam — do not close this window" that ignores Escape + `beforeunload`. Remove the 2-retry cap. | LOW |
| 2.F | **Batch security events** — buffer into `pendingSecurityEvents[]`, flush every 10 seconds OR on visibility change OR on submit. One POST per batch. | LOW |
| 2.G | **Strip Bootstrap + SweetAlert + DataTables** from the exam layout. Exam page should load: Inter font, split mini-theme CSS, jQuery, exam.js. ~65 KB total vs current 300 KB. | MEDIUM — need to port the `Swal.fire` review confirmation to a plain modal. |
| 2.H | **Rate-limit** `/save-answer`: `throttle:120,1` (120 req/min per user). `/heartbeat`: `throttle:60,1`. `/security-log`: `throttle:30,1`. | LOW |
| 2.I | **Idempotency audit** — inspect `ExamSubmissionService::finalize()`. If not already guarded, add a check: if `$attempt->status === 'completed'` and `submitted_at` is set, return the existing result instead of re-running scoring. Propose as separate patch. | LOW (read-only check) |
| 2.J | **Connection-health pill** — green "Connection good" when last save < 1s, amber "Connection slow" when 1–3s, red "Connection lost" when heartbeat fails 2× consecutively. | LOW |
| 2.K | **Add `prefers-reduced-motion` to the exam page** so animations never run for screen readers. Already handled in theme but verify the exam path. | LOW |

---

## SECTION 3 — Backend (Laravel application-level)

### Current state

**.env (local, will differ in prod):**
```
APP_ENV=local              ⚠️ must be "production" in prod
APP_DEBUG=true             🔴 must be false in prod
SESSION_DRIVER=database    ✓ good (NOT file)
SESSION_LIFETIME=120       ⚠️ only 2 hours — exam is 3 hours
CACHE_STORE=database       ⚠️ acceptable; Redis better under load
QUEUE_CONNECTION=database  ⚠️ acceptable; Redis better under load
REDIS_* is configured      ✓ Redis available but not used
```

### Findings

#### 3.1 Unpaginated queries (N+1 and memory risk at scale)

**Every index / list controller uses `->get()` with no pagination.** At 10k candidates and 6k exam attempts this will OOM the PHP process.

| Controller | Line | Call | Rows at scale | Risk |
|---|---|---|---|---|
| `Admin\ApplicationController@index` | 21 | `Application::with(...)->latest()->get()` | 10k+ | 🔴 |
| `Admin\CandidateController@index` | 20 | `CandidateProfile::with('user')->get()` | 10k | 🔴 |
| `Admin\DashboardController@index` | 47, 63, 69, 76 | Multiple `->get()` for dashboards | bounded by limits ✓ | OK for 47/63/69 which use `->limit(8)`; row 76 = same |
| `Admin\ExamAttemptController@index` | 24 | `ExamAttempt::with(...)->get()` | 6k+ | 🔴 |
| `Admin\MeritListController@index` | 20, 33 | `Post::with(...)->get()` + inner `->get()` | OK small | LOW |
| `Admin\PostController@index` | 23 | `Post::with('creator')->get()` | small (~20) | OK |
| `Admin\QuestionController@index` | 21 | `Question::with(...)->get()` | thousands possible | 🟠 |
| `Admin\ReportController@*` (6 methods) | 19, 35, 51, 71, 87, 103 | Each uses `->get()` | 10k+ each | 🔴 |
| `Candidate\ApplicationController@index` | 23 | Candidate's own apps | ~dozens per user ✓ | OK |
| `Candidate\ApplicationController@availablePosts` | 94 | Active posts | small ✓ | OK |
| `Candidate\DashboardController@index` | 30 | Per-user applications | ~dozens ✓ | OK |

**Expected query count** (manually inferred — Debugbar would confirm):
- Admin dashboard: ~12 queries (stats × 19 aggregates + eager loads). Should be <15 via stat-aggregate consolidation.
- Super-admin dashboard: ~14 queries.
- Reports pages: eager loads `with(['user', 'examAttempt', 'application.post'])` — `applicationsRaw.get()` + joined user/post loads = 4 queries, fine.
- Candidate dashboard: 2–3 queries.

#### 3.2 Database indexes

Coverage is **good** across migrations:
- `users`: indexed on `role`, `is_active`.
- `candidate_profiles`: `user_id`, `status`, `cnic`.
- `posts`: `status`, `created_by`, `code`.
- `post_quotas`: `post_id`, `quota_type`.
- `applications`: `status`, `post_id`.
- `questions`: `post_id`, `status`, composite `(post_id, status)` ✓.
- `exam_attempts`: `application_id`, `user_id`, `post_id`, `status`.
- `exam_attempt_questions`: `exam_attempt_id`.
- `exam_answers`: `exam_attempt_id`.
- `result_email_logs`: `exam_attempt_id`.
- `audit_logs`: composite `(user_id, action)`, `(model_type, model_id)`, `created_at`.
- `sessions`: `user_id`, `last_activity`.

**Missing indexes — recommend adding (propose as new migration, do NOT auto-run):**

| Table | Column | Reason |
|---|---|---|
| `applications` | `user_id` | Candidate's "my applications" query filters by `user_id`. |
| `applications` | `roll_number` | Merit list joins + admin search by roll. |
| `applications` | composite `(post_id, status)` | Admin filter "applications for post X with status Y". |
| `exam_answers` | composite `(exam_attempt_id, question_id)` | Lookups for a specific answer within an attempt. |
| `exam_security_logs` | `exam_attempt_id` | Heavy per-attempt queries. Verify it exists in latest migration. |
| `exam_security_logs` | `event_type` | Reports filter by type. |
| `result_email_logs` | `status` | Reports filter by send status. |

#### 3.3 Cache usage

Minimal usage — only for session tracking in `AuthenticatedSessionController`. Dashboard stats, merit-list aggregations, active-posts list are **re-computed on every request**. Under 10k concurrent load the DB will serialize on these.

**Proposed caches** (all invalidated on model save):
- `Cache::remember('admin.dashboard.stats', 60, …)` — TTL 60s
- `Cache::remember('super-admin.dashboard.stats', 60, …)` — TTL 60s
- `Cache::remember('candidate.available-posts', 120, …)` — TTL 120s
- `Cache::remember('merit.index.posts', 300, …)` — TTL 300s
- `Cache::remember("merit.post.{$post->id}", 300, …)` — TTL 300s

Add model `saved`/`deleted` observers to invalidate.

#### 3.4 Synchronous heavy work in HTTP path

| Location | Operation | Current | Should be |
|---|---|---|---|
| `Candidate\ExamController::downloadAnswerSheetPdf` line 313 | `Pdf::loadView(...)->download(...)` | **synchronous** (blocks the request ~200–500ms) | Keep sync — user is actively waiting. LOW. |
| `Services\ExamSubmissionService::dispatchResultEmail` | `dispatch(new SendResultEmailJob(...))` | ✓ queued | ✓ good |
| `Jobs\SendResultEmailJob::handle` | `Pdf::loadView(...)` + `Mail::send` | ✓ queued | ✓ good |
| Profile/application approvals | Audit logs written synchronously via `AuditLog::create`. | sync | Fine — single insert, low cost. |

**Finding:** Queue infrastructure is in place for result emails. Only PDF download is sync and that's appropriate.

#### 3.5 Rate limiting

Current:
- `auth.php` line 43: `throttle:6,1` on email verification link.
- `auth.php` line 47: `throttle:6,1` on resend verification.
- **Login**: **no throttle** — `AuthenticatedSessionController` is vulnerable to brute force.
- **Exam save-answer**: **no throttle** — a buggy or malicious client can hammer the endpoint.
- **Exam heartbeat**: **no throttle**.
- **Security log**: **no throttle**.

**Proposed middleware:**

| Route | Middleware |
|---|---|
| `POST /login` | `throttle:5,1` |
| `POST /forgot-password` | `throttle:3,1` |
| `POST /register` | `throttle:3,1` |
| `POST /candidate/exam/{attempt}/save-answer` | `throttle:120,1` |
| `POST /candidate/exam/{attempt}/heartbeat` | `throttle:60,1` |
| `POST /candidate/exam/{attempt}/security-log` | `throttle:30,1` |
| `POST /candidate/exam/{attempt}/submit` | `throttle:5,1` |

#### 3.6 Session lifetime

`SESSION_LIFETIME=120` minutes = 2 hours. Exam is 3 hours. **If candidate takes longer than 2h to open the exam, CSRF token may expire mid-exam.** Recommend:
- Set `SESSION_LIFETIME=480` (8 hours) in production `.env` — covers exam + buffer + admin workflows.
- Alternatively, implement a `/csrf-token` endpoint the exam page pings every 30 min to refresh.

Recommend option 1 — simpler, no code changes.

#### 3.7 Database connection pool

`config/database.php` not inspected line-by-line, but Laravel defaults to 1 connection per PHP worker. With PHP-FPM at 20 workers × 4 app servers = 80 concurrent DB connections at peak. MySQL default `max_connections=151`. **Recommend raising to 500** in production my.cnf + sizing PHP-FPM pool accordingly. Also recommend `pool` connection config (`pdo::ATTR_PERSISTENT => true`) for the exam-critical MySQL connection.

#### 3.8 Production optimize commands

No deploy script found. Document the following in `DEPLOY.md`:
```
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
composer install --optimize-autoloader --no-dev
npm run build
```

#### 3.9 Telescope / Debugbar

- Not installed currently (no `telescope` or `debugbar` in composer.json). ✓ No risk of leaking in prod.

### Findings summary

| # | Finding | Severity |
|---|---|---|
| 3.1 | **11 admin controllers use `->get()` without pagination**. Reports pages will OOM at 10k rows each. | 🔴 CRITICAL |
| 3.2 | **No caching** on expensive dashboard queries. | 🟠 HIGH |
| 3.3 | `SESSION_LIFETIME=120` < 3-hour exam window. | 🟠 HIGH |
| 3.4 | **No login throttle** — brute-force vulnerable. | 🟠 HIGH |
| 3.5 | **No rate limit** on exam endpoints. | 🟠 HIGH |
| 3.6 | 7 recommended missing indexes (see 3.2). | 🟡 MEDIUM |
| 3.7 | No deploy-time `optimize` documentation. | 🟡 MEDIUM |
| 3.8 | Using `database` cache and queue drivers. Switch to `redis` in prod for 10k concurrent. | 🟡 MEDIUM |

---

## SECTION 4 — Front-end runtime performance

| # | Finding | Evidence | Severity |
|---|---|---|---|
| 4.1 | **No `loading="lazy"`** on any `<img>`. Candidate photos render in tables eagerly. | All admin candidate tables use plain `<img>`. | 🟡 MEDIUM |
| 4.2 | **No explicit `width`/`height`** on most images → CLS. | Multiple pages. | 🟡 MEDIUM |
| 4.3 | **No dynamic imports.** Chart.js + DataTables + SweetAlert ship as `<script>` tags, not ES modules. | All layouts. | 🟡 MEDIUM (minor since they're CDN-cached cross-site) |
| 4.4 | **Animated blobs + orbital SVGs + pulse-ring + gradient-shift** run always, even on exam page (though exam page has no hero). | CSS keyframes always parse and animate when element exists. | 🟡 MEDIUM on dashboards, LOW on exam |
| 4.5 | **No `prefers-reduced-motion`** media query in theme. | Confirmed absent. | 🟡 MEDIUM (a11y + perf) |
| 4.6 | Chart.js render of admin dashboard bar + donut happens on every dashboard hit; **no canvas reuse** or data memoization. | [admin/dashboard.blade.php](resources/views/admin/dashboard.blade.php) | 🟢 LOW |
| 4.7 | No `scroll` or `resize` handlers beyond the theme JS's window-resize listener (already debounced by nature). ✓ | [public/js/electric-ocean-theme.js](public/js/electric-ocean-theme.js) | — |
| 4.8 | **Layout thrashing** — exam JS reads `$('#palette-grid').scrollTop()` + `$pBtn.position().top` then sets `scrollTop` in the same tick. Low-risk (single element, 50× per exam). | [exam/show.blade.php](resources/views/exam/show.blade.php) line ~737 | 🟢 LOW |

### Proposed fixes

| # | Fix | Risk |
|---|---|---|
| 4.A | Add `loading="lazy" decoding="async" width="32" height="32"` to every `<img>` in admin tables + candidate lists. | LOW |
| 4.B | Add `@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }` to end of theme CSS. | LOW |
| 4.C | Add `body.exam-body { --disable-animations: 1; }` + a CSS rule that turns off all decorative animations on the exam page (belt-and-braces with (4.B)). | LOW |
| 4.D | Compute Chart.js color palette once at module load (already done) and cache canvas context. Defer Chart.js import with `<script defer>`. | LOW |

---

## SECTION 5 — Graceful degradation

| # | Finding | Severity |
|---|---|---|
| 5.1 | **No `<noscript>` fallbacks** on login, register, dashboards. Page is functional without JS (Blade renders server-side), but no user-facing message. | 🟡 MEDIUM |
| 5.2 | **No "Connection slow" pill** on exam page. Only `connection-bar` (lost) vs silence (OK) — no "slow" middle state. | 🟠 HIGH |
| 5.3 | **No low-bandwidth mode toggle.** No way for a candidate on 2G to disable animations. | 🟡 MEDIUM |
| 5.4 | **No AJAX retry wrapper** in any page's JS. Each call is a one-shot. (Exception: heartbeat has its own retryInterval.) | 🟠 HIGH (non-exam pages) |
| 5.5 | **Error pages** — `resources/views/errors/403.blade.php` and `404.blade.php` exist. Were these re-themed under Electric Ocean? Need audit. Missing: `500.blade.php`, `503.blade.php`, `419.blade.php` (CSRF expired), `429.blade.php` (throttled). All would show the default Symfony trace page in production. | 🟠 HIGH |

### Proposed fixes

| # | Fix | Risk |
|---|---|---|
| 5.A | Add a noscript stripe on every page: "This portal requires JavaScript for the exam. For other pages, you can continue." | LOW |
| 5.B | Replace the current "connection lost" binary with a 3-state `.conn-secure` / `.conn-slow` / `.conn-lost` pill based on last-save RTT and heartbeat health. | LOW |
| 5.C | Add candidate preference: `CandidateProfile.preferences->low_bandwidth_mode`. Applies `body.lbm` class + disables animations + hides gradient blobs. | LOW — new column needs migration; **requires approval** (schema change). |
| 5.D | Create `resources/js/ajax-retry.js` — a `fetch`-wrapping retry with exponential backoff. Use it on every non-exam AJAX call. | LOW |
| 5.E | Create Electric Ocean themed error pages for 419, 429, 500, 503. | LOW |

---

## SECTION 6 — Observability

| # | Finding | Severity |
|---|---|---|
| 6.1 | **No slow-query logger.** Laravel default `logs/laravel.log` catches exceptions but not slow DB queries. | 🟠 HIGH |
| 6.2 | **Failed answer-save is silently discarded** — `error: function () { /* Non-blocking */ }`. We lose visibility into how many answers failed in the live exam. | 🔴 CRITICAL |
| 6.3 | **No operational pulse metrics.** No way to tail "exam active candidates", "heartbeats/sec", "answers/sec" during the live exam. | 🟠 HIGH |
| 6.4 | **No `/health` endpoint.** | 🟠 HIGH |
| 6.5 | **No error reporting service** (Sentry/Bugsnag/Flare). Errors go to `laravel.log` only. | 🟡 MEDIUM |
| 6.6 | Audit log tables exist (`audit_logs`, `exam_security_logs`, `result_email_logs`) but they track user actions, not operational telemetry. | — |

### Proposed fixes

| # | Fix | Risk |
|---|---|---|
| 6.A | Register `DB::listen` in a service provider guarded by `APP_DEBUG=false`, log queries over 1 second to a dedicated channel `slow_queries`. | LOW |
| 6.B | Change answer-save failure from silent to: log to a dedicated channel `exam_failures` via a new route `POST /exam-client-log` that the exam JS hits on failure. | LOW |
| 6.C | Add an info-level `Log::channel('exam_pulse')->info(...)` on every exam-lifecycle event (begin, save, heartbeat, submit). Config a daily-rotating channel. Emit a counter-friendly single line per event so `tail -f` shows the pulse during live exam. | LOW |
| 6.D | Add `GET /health` returning `{ db, cache, queue, disk, timestamp }`. | LOW |
| 6.E | Recommend **Sentry** (free tier, has Laravel package `sentry/sentry-laravel`). **Requires approval** for new composer package. | — |

---

## PRIORITY-ORDERED ACTION LIST

### Tier 1 — Must fix before live exam (🔴 CRITICAL)

1. **[2.A, 2.B, 2.C]** Exam-page localStorage answer queue + infinite retry + boot-time reconciliation.
2. **[2.E]** Unlimited-retry final submit with blocking modal.
3. **[2.F]** Batch security events (10-second flush).
4. **[6.B]** Log every failed answer save — no more silent failures.
5. **[3.1]** Paginate every `->get()` in `ApplicationController`, `CandidateController`, `ExamAttemptController`, `QuestionController`, `ReportController` × 6 methods.
6. **[3.5]** Add `throttle:5,1` to login, `throttle:120,1` to save-answer, `throttle:60,1` to heartbeat, `throttle:30,1` to security-log.
7. **[3.6]** Bump `SESSION_LIFETIME` to 480 in `.env.example` + document for prod.
8. **[1.1]** Document `APP_DEBUG=false` + `APP_ENV=production` for deploy.
9. **[6.D]** `/health` endpoint.
10. **[5.5]** 419, 429, 500, 503 themed error pages.

### Tier 2 — Should fix (🟠 HIGH)

11. **[2.D]** Save-status pill ("Saved ✓ / Saving… / Pending: N / Offline").
12. **[2.G]** Strip Bootstrap / SweetAlert / DataTables from exam layout — exam CSS split.
13. **[2.H]** Rate limits on exam endpoints (covered above).
14. **[2.J]** 3-state connection-health pill.
15. **[3.3]** Cache admin dashboard, super-admin dashboard, merit-list, available-posts queries with 60–300s TTL + model observer invalidation.
16. **[3.8]** Recommend Redis for cache + queue in prod.
17. **[6.A]** Slow-query logging.
18. **[6.C]** Exam pulse metrics channel.
19. **[1.4]** Self-host fonts.
20. **[2.I]** Audit + confirm `finalize()` idempotency.

### Tier 3 — Nice to have (🟡 MEDIUM)

21. **[1.F]** Conditional DataTables loading.
22. **[1.D]** Split exam CSS into dedicated file.
23. **[3.2]** Propose 7 new indexes as migration (requires approval to run).
24. **[4.A]** `loading="lazy"` + width/height on all images.
25. **[4.B]** `prefers-reduced-motion` CSS block.
26. **[5.D]** AJAX retry wrapper for non-exam calls.
27. **[5.C]** Low-bandwidth mode toggle (requires schema — approval needed).
28. **[1.J]** Nginx/Apache gzip + brotli config snippet in DEPLOY.md.

### Tier 4 — Future (🟢 LOW)

29. **[6.E]** Install Sentry (approval).
30. **[1.I]** Full deploy script / Runbook.
31. **[1.G]** `defer` / `async` on every script.
32. **[3.7]** Raise MySQL `max_connections` to 500 + persistent connections.

---

## EXPECTED IMPACT (back-of-envelope)

| Fix tier | Exam first-paint on slow 3G | Answer-loss rate under network blip | Peak DB query rate per request | Peak server RPS capacity |
|---|---|---|---|---|
| Today | ~6 seconds | **High** (silent fail) | ~30 on admin reports | ~500 RPS before OOM on reports |
| After Tier 1 | ~5 seconds | ~0% | ~30 | ~1500 RPS (pagination) |
| After Tier 1+2 | ~2 seconds (exam CSS split + self-host) | 0% | ~10 with caching | ~3000 RPS |
| After Tier 1+2+3 | ~1.5 seconds | 0% | ~8 | ~5000 RPS |

Rough estimate: **Tier 1 + Tier 2 is sufficient for 10,000-candidate peak**, assuming 4+ app servers behind a load balancer and MySQL primary + read replica.

---

## APPROVAL REQUESTED

I have not changed any file. This is audit-only. Please review and authorize:

- Which **Tier 1** items to implement first (recommend all 10 in order).
- Whether to proceed with **Tier 2** in the same session or separately.
- Whether the one schema-touching item (5.C low-bandwidth mode) is in-scope.
- Whether to propose the 7 missing indexes as a migration file (I will **not run** it; you review + run on staging first).
- Whether to install Sentry (requires new composer package).

Once approved, fixes will be implemented one item at a time with a mini-summary after each, per our working protocol.
