# Chowman Durga Puja Quiz & Win — Backend Implementation Plan

**Status:** Draft for execution
**Scope:** Backend only (Laravel 13, MySQL, Sanctum)
**Campaign window:** 1 Oct 2026 → 8 Nov 2026 (EOD)
**Source PRD:** `Chowman Durga Puja Quiz & Win – Product Requirements` (Prithvish, 23 Sep 2026)

---

## 1. Problem Statement

Chowman needs a backend to power a mobile-web quiz that dine-in guests reach via a shared QR code at every outlet during Durga Puja 2026. The backend must:

- Verify guests by mobile OTP (Bluewaves SMS gateway, integrated later)
- Serve a 10-question timed quiz drawn randomly from a bank of 30
- Enforce timer and scoring server-side (guests never see the correct answer before submitting)
- Award a score-linked offer, deliver it via SMS on unlock
- Provide an admin API for content management (questions, offers, settings) and reporting
- Enforce a one-attempt-per-mobile rule for the whole campaign

The frontend (guest-facing web app and admin panel UI) is **out of scope** for this plan.

## 2. Requirements Summary

Extracted from the PRD:

- **Guest quiz API**: register (name + mobile + consent) → OTP → 10-question session → per-question submit with 45s server-side timer (+3s network grace) → evaluate → score-linked offer → SMS on unlock.
- **One attempt per mobile** for the campaign window. A returning mobile skips the game and sees its last screen (score or offer).
- **Server never reveals** the correct answer before submit. Timer enforced server-side. Scoring 0–10.
- **Score → offer mapping**: 0 → Offer 1, 1 → Offer 1, 2 → Offer 2, …, 10 → Offer 10. Ten fixed offer slots.
- **Resumable**: Refresh or app-switch resumes from the current question; the timer keeps running from the original serve time.
- **Idempotent**: Double-tap submit counts once. Repeated unlock returns the same coupon and does not resend SMS.
- **Admin**: CRUD questions (with XLSX bulk upload), CRUD offers, campaign settings, participants list, dashboard aggregates, CSV/XLSX export. Role-based access (`admin` / `viewer`).
- **Security**: 6-digit OTP, 5-min validity, 5 wrong attempts → 15-min lock, 3 OTP sends per mobile per hour, per-IP throttling on OTP send, HTTPS-only, tokens on all quiz APIs.
- **SMS/OTP**: Bluewaves (custom API integrated later). Abstracted behind a driver interface; dev environment logs OTPs.
- **Analytics events** recorded server-side for the funnel.

## 3. Locked Decisions

| Topic             | Decision                                                                                                                                                       |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Guest auth        | Sanctum personal-access token (Bearer). `Guest` model with `HasApiTokens`. Token ability: `quiz`.                                                              |
| Admin auth        | Sanctum personal-access token (Bearer). `User` model with `HasApiTokens` and a `role` enum column (`admin`, `viewer`). Token ability set from the user's role. |
| Fortify           | Kept installed (starter kit ships with it) but **not used** by the admin API. May be leveraged later for password reset.                                       |
| Database          | MySQL (switch from starter-kit SQLite).                                                                                                                        |
| SMS provider      | `SmsGateway` contract + `LogSmsDriver` (dev) + `BluewavesSmsDriver` skeleton (integration deferred). Sync send, no queues.                                     |
| Excel/CSV         | `maatwebsite/excel` for question bulk upload and report export.                                                                                                |
| Rate limiting     | Built-in `Illuminate\Support\Facades\RateLimiter`.                                                                                                             |
| Campaign settings | `settings` key/value table, cached.                                                                                                                            |
| Token expiration  | Guest tokens: no expiry (campaign is 40 days). Admin tokens: 12 hours. Configurable per-token via `expiresAt()`.                                               |
| API prefix        | Guest: `/api/v1/quiz/*`. Admin: `/api/v1/admin/*`.                                                                                                             |

## 4. Architecture Overview

```mermaid
flowchart LR
    QR[Guest scans QR] --> Register[/POST /api/v1/quiz/register/]
    Register --> OtpService[OtpService]
    OtpService --> SmsGateway
    SmsGateway -.stub in dev.-> Log[laravel.log]
    SmsGateway -.later.-> Bluewaves[(Bluewaves API)]
    Register --> Cache[(Cache: OTP hash + TTL)]
    Guest[Guest] --> Verify[/POST /api/v1/quiz/otp/verify/]
    Verify --> Token[Sanctum token — ability: quiz]
    Token --> QuizAPI[Quiz API]
    QuizAPI --> DB[(MySQL)]
    QuizAPI --> Analytics[analytics_events]

    Admin[Admin] --> Login[/POST /api/v1/admin/auth/login/]
    Login --> AdminToken[Sanctum token — ability: admin or viewer]
    AdminToken --> AdminAPI[Admin API]
    AdminAPI --> DB
```

**Auth model.** One guard (`sanctum`) with two `tokenable_type`s (`App\Models\Guest` and `App\Models\User`). Route access is separated via **token abilities**:

| Actor            | Ability  | Middleware                          |
| ---------------- | -------- | ----------------------------------- |
| Guest (post-OTP) | `quiz`   | `auth:sanctum` + `abilities:quiz`   |
| Admin user       | `admin`  | `auth:sanctum` + `abilities:admin`  |
| Viewer user      | `viewer` | `auth:sanctum` + `abilities:viewer` |

Admin read-only routes accept `admin` OR `viewer` via Sanctum's `ability:admin,viewer` middleware. Admin write routes require `abilities:admin`.

## 5. Data Model

All tables MySQL/InnoDB, UTF-8.

| Table                    | Key fields                                                                                                                                                                                                                                                                                                                                                                                                             |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `users`                  | starter-kit fields + `role` enum (`admin`,`viewer`) default `viewer`                                                                                                                                                                                                                                                                                                                                                   |
| `personal_access_tokens` | Sanctum default table (shared by Guest and User via `tokenable_type`)                                                                                                                                                                                                                                                                                                                                                  |
| `guests`                 | `id`, `name`, `mobile` (unique, 10 digits), `consent` bool, `verified_at`, timestamps                                                                                                                                                                                                                                                                                                                                  |
| `questions`              | `id`, `text`, `option_a..d`, `correct_option` enum(`A`,`B`,`C`,`D`), `active` bool, timestamps                                                                                                                                                                                                                                                                                                                         |
| `offers`                 | `id`, `score_slot` tinyint unique 1–10, `title`, `tagline`, `terms` text, `coupon_code`, `valid_from`, `valid_to`, `active` bool, timestamps                                                                                                                                                                                                                                                                           |
| `quiz_sessions`          | `id`, `guest_id` unique (one attempt per guest), `question_ids` JSON (10 IDs, ordered), `option_orders` JSON (shuffled A/B/C/D per question), `current_index` tinyint, `score` tinyint nullable, `status` enum(`in_progress`,`completed`,`abandoned`), `started_at`, `completed_at`, `offer_id` nullable FK, `coupon_code` nullable (snapshot), `offer_unlocked_at` nullable, `offer_sms_sent_at` nullable, timestamps |
| `session_answers`        | `id`, `session_id` FK, `question_id` FK, `question_index` tinyint, `served_at`, `selected_option` char(1) nullable, `is_correct` bool default 0, `answered_at` nullable, `timed_out` bool default 0, timestamps; unique(`session_id`,`question_index`)                                                                                                                                                                 |
| `settings`               | `key` primary, `value` json, timestamps                                                                                                                                                                                                                                                                                                                                                                                |
| `analytics_events`       | `id`, `event` string (indexed), `guest_id` nullable FK (indexed), `session_id` nullable FK, `payload` json (holds `ip`, `user_agent`, and event-specific extras like `score`, `offer_id`, `question_id`), `created_at` (indexed). Composite index on `(event, created_at)` for dashboard aggregates.                                                                                                                   |
| `sms_logs` (optional)    | `id`, `mobile`, `template`, `payload` json, `status`, `provider_response` text nullable, `created_at` — decided during Task 4                                                                                                                                                                                                                                                                                          |

**OTP storage.** OTP hashes + attempt counters live in Laravel cache with a 5-minute TTL and a separate 15-minute lockout key. If durability across cache flushes is required, we add an `otp_attempts` table in Task 4 (deferred decision).

**Coupon code snapshot.** On unlock, the coupon code from `offers.coupon_code` is copied to `quiz_sessions.coupon_code` so retroactive admin edits don't change past redemptions.

## 6. Route Surface

### 6.1 Guest API (`/api/v1/quiz`)

| Method | Path                    | Auth             | Purpose                                                                                                             |
| ------ | ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------- |
| GET    | `/campaign`             | none             | Campaign status (`active` \| `closed`) + settings snapshot                                                          |
| POST   | `/track/qr-scan`        | none             | Records `qr_scanned` analytics event (IP + UA)                                                                      |
| POST   | `/register`             | none             | name + mobile + consent → upsert guest, send OTP (rate-limited)                                                     |
| POST   | `/otp/resend`           | none             | 30-second cooldown + 3/hr cap per mobile                                                                            |
| POST   | `/otp/verify`           | none             | mobile + otp → resolve state, issue Sanctum token (`quiz`), return state                                            |
| GET    | `/session`              | sanctum + `quiz` | Full session state (score/offer if completed)                                                                       |
| GET    | `/session/question`     | sanctum + `quiz` | Current question (text, shuffled options; no correct flag). Sets `served_at` on first fetch, idempotent thereafter. |
| POST   | `/session/answer`       | sanctum + `quiz` | `question_id`, `selected_option` — validates timer, records answer, returns `is_correct` + `correct_option`         |
| POST   | `/session/timeout`      | sanctum + `quiz` | Marks current question timed-out                                                                                    |
| POST   | `/session/unlock-offer` | sanctum + `quiz` | Reveals coupon, sends SMS, records `offer_unlocked_at`                                                              |

All routes above `EnsureCampaignActive` returns a 200 with `{ "status": "campaign_closed" }` (not a 4xx — this is a UX state) outside the window.

### 6.2 Admin API (`/api/v1/admin`)

| Method | Path                   | Ability       | Purpose                                                                            |
| ------ | ---------------------- | ------------- | ---------------------------------------------------------------------------------- |
| POST   | `/auth/login`          | none          | email + password → Sanctum token with ability = user's role, expires in 12 h       |
| POST   | `/auth/logout`         | admin\|viewer | Deletes current token                                                              |
| GET    | `/auth/me`             | admin\|viewer | Returns authenticated user                                                         |
| GET    | `/questions`           | admin\|viewer | Paginated list                                                                     |
| POST   | `/questions`           | admin         | Create                                                                             |
| PUT    | `/questions/{id}`      | admin         | Update                                                                             |
| DELETE | `/questions/{id}`      | admin         | Delete (or soft toggle via `active`)                                               |
| POST   | `/questions/import`    | admin         | XLSX/CSV bulk upload                                                               |
| GET    | `/questions/template`  | admin         | Streams a sample XLSX template                                                     |
| GET    | `/offers`              | admin\|viewer | List all 10 offer slots                                                            |
| PUT    | `/offers/{slot}`       | admin         | Update offer content + coupon code                                                 |
| GET    | `/settings`            | admin\|viewer | Campaign settings                                                                  |
| PUT    | `/settings`            | admin         | Update campaign settings                                                           |
| GET    | `/participants`        | admin\|viewer | Paginated participants list (filters: `date_from`, `date_to`, `score`, `offer_id`) |
| GET    | `/participants/export` | admin\|viewer | Streamed XLSX/CSV of the filtered result                                           |
| GET    | `/dashboard`           | admin\|viewer | Daily funnel aggregates                                                            |
| GET    | `/users`               | admin         | List admin users                                                                   |
| POST   | `/users`               | admin         | Create admin user                                                                  |
| PUT    | `/users/{id}`          | admin         | Update admin user (incl. role)                                                     |
| DELETE | `/users/{id}`          | admin         | Delete admin user                                                                  |

## 7. Domain Logic

### 7.1 Actions (`app/Actions`)

- `Otp\GenerateAndSendOtp` — generates 6-digit OTP, hashes, caches with TTL, dispatches via `SmsGateway`
- `Otp\VerifyOtp` — validates OTP, tracks attempts, applies lockout
- `Quiz\ResolveGuestState` — returns `new` | `in_progress` | `completed` | `unlocked` for a guest
- `Quiz\StartQuizSession` — picks 10 random active questions, shuffles options per question, persists session + pre-generated `session_answers` rows
- `Quiz\ServeCurrentQuestion` — sets `served_at` on first fetch, returns question payload
- `Quiz\RecordAnswer` — validates timer against `served_at`, maps shuffled option back to the question's `correct_option`, records answer, advances index, idempotent
- `Quiz\HandleTimeout` — marks current question timed-out, advances index
- `Quiz\FinalizeSession` — runs when `current_index == 10`, computes score, resolves offer via `OfferMapper`, snapshots coupon
- `Quiz\UnlockOffer` — records `offer_unlocked_at`, sends SMS via `SmsGateway`, records `offer_sms_sent_at`, idempotent

### 7.2 Services / Support

- `App\Support\Sms\SmsGateway` — interface: `sendOtp(string $mobile, string $otp)`, `sendOfferUnlock(string $mobile, Offer $offer, string $smartlink)`
- `App\Support\Sms\LogSmsDriver` — writes to `laravel.log`
- `App\Support\Sms\BluewavesSmsDriver` — stub that throws `NotImplementedException` with a clear TODO comment
- `App\Support\Offers\OfferMapper::forScore(int $score): int` — returns the score_slot (0 → 1, else `$score`)
- `App\Support\Settings\SettingsRepository` — cached key/value read/write
- `App\Support\Analytics\RecordEvent` — inserts an `analytics_events` row; swallows and logs write errors so analytics never breaks a request
- `App\Enums\UserRole` — `Admin`, `Viewer`
- `App\Enums\SessionStatus` — `InProgress`, `Completed`, `Abandoned`
- `App\Enums\CorrectOption` — `A`, `B`, `C`, `D`
- `App\Enums\AnalyticsEvent` — allowlist for client-provided events

### 7.3 Middleware

- `EnsureCampaignActive` — reads cached settings, short-circuits with `{status: "campaign_closed"}` outside window
- Sanctum's built-in `abilities` / `ability` middleware for role separation
- Standard `throttle` middleware for global caps; custom `RateLimiter::for(...)` in `AppServiceProvider` for named limiters

### 7.4 Rate limiters

Registered in `AppServiceProvider`:

- `otp_send:mobile` — 3 requests / hour per mobile
- `otp_send:ip` — 10 requests / hour per IP
- `otp_verify:mobile` — 5 attempts / 5 minutes per mobile (5th wrong → 15-min lockout stored in cache)
- `otp_resend:mobile` — 30-second cooldown
- `admin_login` — 5 attempts / minute per email + IP

## 8. Task Breakdown

Each task is designed to be independently reviewable. Assumed order.

---

### Task 1: MySQL switch + Sanctum + maatwebsite/excel

**Objective:** Move DB from SQLite to MySQL. Install Sanctum and maatwebsite/excel. Publish configs. Scaffold `routes/api.php`.

**Guidance:**

- Update `.env` / `.env.example` to `DB_CONNECTION=mysql` with local defaults.
- `composer require laravel/sanctum maatwebsite/excel`.
- `php artisan install:api --no-interaction` — creates Sanctum migration, publishes config, adds `routes/api.php`.
- Do not add `HasApiTokens` to models yet — done in Tasks 2 and 3.
- Verify `php artisan migrate:fresh` runs cleanly on MySQL.

**Tests:** Smoke feature test hitting `/` returns 200.

**Demo:** `php artisan migrate:fresh` on MySQL produces users, cache, jobs, personal_access_tokens tables.

---

### Task 2: Admin roles on `users` + `HasApiTokens`

**Objective:** Add `role` enum to `users`, add `HasApiTokens` trait, seed an admin user.

**Guidance:**

- Migration `add_role_to_users_table` — nullable ENUM(`admin`,`viewer`) default `viewer`.
- Add `App\Enums\UserRole` (TitleCase keys: `Admin`, `Viewer`).
- Cast `role` to `UserRole` on `User`.
- Add `HasApiTokens` and helper methods `isAdmin()`, `isViewer()`.
- `UserFactory` states: `admin()`, `viewer()`.
- `DatabaseSeeder` inserts one admin user (email/password from env, no defaults in prod).

**Tests:** Unit — `User::factory()->admin()->create()->isAdmin()` true; token issuance works.

**Demo:** Seeder creates admin user; tinker mints a token with ability `admin`.

---

### Task 3: Guest model + Sanctum guest provider

**Objective:** Create `Guest` model + `guests` migration + factory. Register `guests` provider in `config/auth.php`. Ensure Sanctum's `personal_access_tokens` table supports both types.

**Guidance:**

- `php artisan make:model Guest -mf`.
- Fields per data model. Cast `verified_at` to datetime, `consent` to bool.
- `Guest` uses `HasApiTokens`, `HasFactory`, `Notifiable`.
- Add `guests` provider in `config/auth.php` (Eloquent, model `Guest`).
- Sanctum resolves tokenable via `tokenable_type` — no extra config needed.

**Tests:** Feature — a Guest-issued token with ability `quiz` accepts a stub protected route; the same token rejected on a route requiring ability `admin`.

**Demo:** Tinker — `Guest::factory()->create()->createToken('quiz', ['quiz'])->plainTextToken`.

---

### Task 4: OTP service + SmsGateway abstraction

**Objective:** Build the OTP lifecycle and the SMS gateway interface.

**Guidance:**

- `App\Support\Otp\OtpService::send(string $mobile): void` — generates 6-digit, hashes with HMAC + app key, stores in cache with 5-min TTL, tracks separate attempts counter, delegates SMS to `SmsGateway::sendOtp`.
- `verify(string $mobile, string $otp): OtpVerifyResult` — enum result: `Success` | `Invalid` | `Expired` | `Locked`. Increments attempts. 5th wrong → 15-min lockout key.
- `App\Support\Sms\SmsGateway` interface.
- `LogSmsDriver` implementation writes JSON payload to `laravel.log` at INFO level.
- `BluewavesSmsDriver` implementation throws a runtime exception with a clear TODO. Config-selectable via `config/sms.php`: `driver = env('SMS_DRIVER', 'log')`.
- Bind gateway in `AppServiceProvider` based on config.
- Register rate limiters in `AppServiceProvider` (see §7.4).

**Tests:** Unit — `OtpService` happy path, wrong OTP increments, expiry, lockout, resend cooldown, hourly cap. Use `Cache::store('array')` and `Carbon::setTestNow`. Fake `SmsGateway` in service container.

**Demo:** Tinker — `app(OtpService::class)->send('9876543210')` writes an entry to `laravel.log`; `verify` succeeds on the logged code.

---

### Task 5: Question and Offer domain

**Objective:** `Question` and `Offer` models + migrations + factories + seeders.

**Guidance:**

- `php artisan make:model Question -mfs`, same for `Offer`.
- Enum casts: `correct_option` → `CorrectOption`.
- Scopes: `active()`.
- Enforce `unique` on `offers.score_slot`.
- `OfferMapper::forScore(int $score): int` — returns the mapped slot.
- Seeders: 30 sample questions + 10 offer rows (slots 1–10 with placeholder content) — used in local dev and tests only.
- Factory states: `Question::factory()->active()`, `Offer::factory()->forSlot(int $slot)`.

**Tests:** Unit — `OfferMapper::forScore(0) === 1`, `forScore(5) === 5`, `forScore(10) === 10`. Feature — factories produce valid rows; `score_slot` uniqueness enforced; a Question with a valid `correct_option` is queryable via `active()`.

**Demo:** Seed DB → `Question::active()->count()` returns 30; `Offer::where('score_slot', 5)->exists()` true.

---

### Task 6: Campaign settings

**Objective:** `settings` table + `SettingsRepository` (cached) + `EnsureCampaignActive` middleware + `GET /api/v1/quiz/campaign`.

**Guidance:**

- Keys seeded with defaults: `campaign.start_at`, `campaign.end_at`, `campaign.questions_per_quiz` (10), `campaign.timer_seconds` (45), `campaign.timer_grace_seconds` (3), `campaign.attempts_per_number` (1), `campaign.app_smartlink_url`.
- `SettingsRepository::get(string $key, mixed $default = null)`, `put(string $key, mixed $value): void` — uses `Cache::rememberForever`, invalidates on write.
- `EnsureCampaignActive` short-circuits with `{ "status": "campaign_closed" }` 200 outside window.
- Public endpoint `GET /api/v1/quiz/campaign` returns status + questions_per_quiz + timer_seconds + smartlink URL.

**Tests:** Unit for repository (get/set/cache invalidation). Feature for middleware (before start, during, after end) using `Carbon::setTestNow`.

**Demo:** Set start/end dates; `curl /api/v1/quiz/campaign` reflects them.

---

### Task 7: Guest registration + OTP send/verify endpoints

**Objective:** Public endpoints for `POST /register`, `POST /otp/resend`, `POST /otp/verify`. Verify mints a Sanctum `quiz` token and returns resume state.

**Guidance:**

- Route group under `/api/v1/quiz` with `EnsureCampaignActive`.
- `RegisterGuestRequest` validates `name` (2–50, letters + spaces regex), `mobile` (`/^[6-9]\d{9}$/`), `consent` (accepted).
- Register upserts the `Guest` by `mobile`, calls `OtpService::send`, records `otp_sent` analytics event.
- Resend enforces 30-second cooldown + 3/hr cap via `RateLimiter`.
- Verify calls `OtpService::verify`. On success, sets `guests.verified_at`, records `otp_verified`, resolves state via `Quiz\ResolveGuestState`:
    - No session yet → `state: "new"` + token (ability `quiz`).
    - `quiz_sessions.status = in_progress` → `state: "in_progress"` + token + `current_index`.
    - `quiz_sessions.status = completed` and no `offer_unlocked_at` → `state: "completed"` + token + score + offer preview (no coupon).
    - `offer_unlocked_at != null` → `state: "unlocked"` + token + full offer including coupon.
- Verify response shape:
    ```json
    {
      "state": "new|in_progress|completed|unlocked",
      "token": "...",
      "guest": {...},
      "session": {...}  // shape depends on state
    }
    ```

**Tests:** Feature — invalid mobile 422; valid registration 200 + `otp_sent` event; resend cooldown 429; wrong OTP 422 with remaining attempts; lockout 423; successful verify returns state + token; returning-user verify returns `completed` or `unlocked` with the right payload.

**Demo:** `curl` register → grep log for OTP → `curl` verify → receive Bearer token + `state: "new"`.

---

### Task 8: Quiz session start + question serving

**Objective:** First `GET /session/question` creates the session (10 random active questions, shuffled options), sets `served_at` on the first question, and returns it. Subsequent calls are idempotent.

**Guidance:**

- Route group `/api/v1/quiz/session` — `auth:sanctum` + `abilities:quiz` + `EnsureCampaignActive`.
- `StartQuizSession` action:
    - Fails with 503 if fewer than `questions_per_quiz` active questions exist.
    - Picks IDs via `Question::active()->inRandomOrder()->limit(N)->pluck('id')`.
    - Shuffles A/B/C/D per question — stored as `option_orders: { question_id: ["B","D","A","C"], ... }`.
    - Pre-generates 10 `session_answers` rows with `question_index` 0–9 and `served_at = null`.
- `ServeCurrentQuestion` sets `served_at = now()` only if null.
- Response includes `question_id`, `question_index` (1..10), `text`, `options` (array of `{ key: "A"|"B"|"C"|"D", text }` in shuffled order), `served_at` (ISO8601), `deadline_at` (`served_at + timer + grace`), `progress: { current, total }`.
- Never include correct option or original A/B/C/D mapping.
- If session status is `completed`, return 409 with completed state — client redirects to result screen.

**Tests:** Feature — first call creates session + returns Q1; second call returns same question with same `served_at`; sessions for different guests have independent random orders; response payload never contains `correct_option`; 503 when fewer than 10 active questions.

**Demo:** Two `curl` calls to `/session/question` return the same first question consistently.

---

### Task 9: Answer submission + timeout

**Objective:** `POST /session/answer` locks the answer; `POST /session/timeout` marks the current question timed-out. Both advance `current_index` and are idempotent.

**Guidance:**

- `RecordAnswer` action:
    - Loads session by authenticated guest.
    - Validates `question_id` matches the question at `current_index`.
    - Computes elapsed from `served_at`. If elapsed > `timer + grace`, force `timed_out = true`, `is_correct = false`, ignore submitted option.
    - Otherwise map submitted shuffled option (A/B/C/D at position N) back to original via `option_orders`, compare with question's `correct_option`.
    - If already answered/timed-out for this position, return the previous result (idempotency).
    - Advance `current_index`.
- Response: `{ is_correct, correct_option (original letter), correct_option_display (letter as shown to guest in shuffled order), score_so_far, next: { question_index } | null }`.
- `HandleTimeout` action is the same path but always `timed_out = true`, `is_correct = false`; returns correct option for display.
- If `next` is null (10th question just answered), automatically call `FinalizeSession` and include `state: "completed"` + score in the response.

**Tests:** Feature — correct answer flips `is_correct` true; wrong answer records correct_option; timeout past grace forced to wrong even with submitted option; double-submit returns idempotent same result; wrong `question_id` (out of order) → 409.

**Demo:** End-to-end via curl: `/session/question` → answer → next question → repeat 10× → final response includes score.

---

### Task 10: Finalize session + unlock offer + SMS

**Objective:** After Q10, session finalizes (score, offer). `POST /session/unlock-offer` reveals coupon and sends SMS.

**Guidance:**

- `FinalizeSession` runs automatically at the end of the 10th answer/timeout call:
    - Computes `score = session_answers.where('is_correct', true).count()`.
    - Resolves `offer_id` via `OfferMapper::forScore($score)` → look up offer by `score_slot`.
    - Sets `status = completed`, `completed_at`, `score`, `offer_id`. Coupon is **not** copied yet.
    - Records `quiz_completed` analytics event.
- `UnlockOffer` action:
    - Idempotent — if `offer_unlocked_at` already set, returns the existing coupon without resending SMS.
    - Sets `offer_unlocked_at`, copies coupon from `offers.coupon_code` to `quiz_sessions.coupon_code` (snapshot).
    - Calls `SmsGateway::sendOfferUnlock` synchronously outside the transaction via `DB::afterCommit`.
    - SMS failure logs and continues — does not block unlock.
    - Sets `offer_sms_sent_at` on success.
    - Records `offer_unlocked` + `offer_sms_sent` analytics events.
- `GET /session` for a completed session returns `{ score, band_message, offer: { title, tagline, terms, revealed: bool, coupon_code: string|null, smartlink } }` — `coupon_code` is null until unlock.
- Band messages: `0..3 → "Good try!"`, `4..7 → "Well played!"`, `8..10 → "Champion!"`.

**Tests:** Feature — score 0 unlocks offer slot 1; scores 2–10 unlock matching slots; double-unlock sends SMS once; SMS failure doesn't block unlock; `GET /session` before unlock doesn't leak coupon.

**Demo:** Full curl flow scoring 5/10 → `/session` shows offer 5 preview → `/session/unlock-offer` → laravel.log has SMS entry with title + coupon.

---

### Task 11: Analytics events pipeline

**Objective:** `analytics_events` table + `RecordEvent` action wired into every hook.

**Guidance:**

- Real columns: `event` (indexed), `guest_id` nullable (indexed), `session_id` nullable, `created_at` (indexed). Composite index on `(event, created_at)` for dashboard queries.
- `payload` JSON holds everything else: `ip`, `user_agent`, and event-specific extras (score, offer_id, question_id, is_correct, timed_out, etc.).
- Events emitted from actions/controllers:
    - `qr_scanned` — `POST /track/qr-scan` (public, IP + UA captured).
    - `otp_sent` — after successful send.
    - `otp_verified` — on verify success.
    - `quiz_started` — on session creation.
    - `question_answered`, `question_timeout` — on record answer.
    - `quiz_completed` — on finalize.
    - `offer_unlocked`, `offer_sms_sent` — on unlock and SMS send respectively.
    - `app_link_clicked` — optional client-tracked event; validated via `AnalyticsEvent` enum allowlist.
- `RecordEvent::record(string $event, array $context = [])` — swallows write errors and logs; never blocks the request.
- Client-provided events accepted only via `POST /api/v1/quiz/track` (auth:sanctum + `abilities:quiz`) and validated against enum.

**Tests:** Feature — each API path records the expected event with correct payload; unknown event from client returns 422.

**Demo:** After running a full quiz flow, `SELECT event, COUNT(*) FROM analytics_events GROUP BY event` shows the funnel.

---

### Task 12: Admin auth (login/logout/me)

**Objective:** Sanctum-based login for admins, issuing a token with ability equal to the user's role.

**Guidance:**

- `POST /api/v1/admin/auth/login` — validates email + password, throttled via `admin_login` rate limiter.
- On success, deletes any existing tokens for the user (single-session), creates a new token with ability `[$user->role->value]` (e.g. `['admin']` or `['viewer']`), expiring in 12 hours (`->expiresAt(now()->addHours(12))`).
- Response: `{ token, user, expires_at }`.
- `POST /api/v1/admin/auth/logout` — `auth:sanctum` + `ability:admin,viewer` — deletes current token.
- `GET /api/v1/admin/auth/me` — same middleware — returns user resource.
- Password reset, passkeys, 2FA are **out of scope** — can layer in via Fortify later.

**Tests:** Feature — wrong password 422; correct login issues token with correct ability; expired token 401; `admin` token accesses `abilities:admin` route; `viewer` token blocked from `abilities:admin` route but allowed on `ability:admin,viewer`.

**Demo:** `curl` login → receive token → `curl` `/admin/auth/me` with Bearer token returns user.

---

### Task 13: Admin — question management + XLSX import

**Objective:** CRUD for questions + `POST /questions/import` for XLSX/CSV bulk upload.

**Guidance:**

- Routes under `/api/v1/admin/questions` with `auth:sanctum` + `ability:admin,viewer` (reads) or `abilities:admin` (writes).
- Form Requests enforce exactly 4 options (`option_a` through `option_d`, non-empty) and `correct_option` in `A|B|C|D`.
- `QuestionImport` implements `ToModel`, `WithHeadingRow`, `WithValidation`, `WithBatchInserts` from `maatwebsite/excel`.
- Import runs in a transaction. On any row failure, the whole import rolls back and returns `{ imported: 0, failed: [{ row, errors }] }`.
- Provide `GET /questions/template` streaming a small XLSX with headers and one example row.
- Toggle active via `PATCH /questions/{id}/toggle-active` or via `PUT` payload.

**Tests:** Feature — viewer can list but not create; admin can create/edit/deactivate; valid XLSX imports; invalid row returns per-row error and no partial commit; validation rejects 3 options or 2 correct answers.

**Demo:** Log in as admin → POST an XLSX → `GET /questions` shows imported rows.

---

### Task 14: Admin — offer + settings management

**Objective:** Offer edit (10 fixed slots) + campaign settings edit.

**Guidance:**

- `GET /api/v1/admin/offers` returns all 10 rows ordered by `score_slot`.
- `PUT /api/v1/admin/offers/{slot}` updates title, tagline, terms, coupon_code, valid_from, valid_to, active. `score_slot` immutable.
- Validation blocks slot outside 1–10.
- `GET /api/v1/admin/settings` returns all settings. `PUT /api/v1/admin/settings` accepts partial payload; each key validated (dates for `start_at`/`end_at`, ints for `questions_per_quiz`/`timer_seconds`/`timer_grace_seconds`/`attempts_per_number`, URL for `app_smartlink_url`).
- `SettingsRepository::put` invalidates the cache; next read repopulates.

**Tests:** Feature — viewer read-only on both; invalid slot rejected; invalid setting types rejected; settings update reflected in `/api/v1/quiz/campaign` immediately.

**Demo:** Update `campaign.timer_seconds` to 30 and see it in `/api/v1/quiz/campaign`.

---

### Task 15: Admin — participants list + dashboard + export

**Objective:** Reports for the admin panel.

**Guidance:**

- `GET /participants` — paginated. Filters: `date_from`, `date_to`, `score`, `offer_id`, `status`. Response includes name, mobile, played_at, score, offer title, coupon code (post-unlock only), unlocked_at.
- `GET /participants/export` — streams XLSX/CSV using the same filters via `ParticipantsExport` (maatwebsite/excel `FromQuery` + `WithMapping` + `WithHeadings`).
- `GET /dashboard` — accepts `date_from`, `date_to`; returns daily funnel: `{ date, scans, otp_sent, otp_verified, quizzes_completed, offers_unlocked }`. Query `analytics_events` grouped by `DATE(created_at)` and `event`. Cache result for 60 s.
- Both `viewer` and `admin` can read and export.

**Tests:** Feature — filter narrows results correctly; export produces expected rows for the filter; dashboard math matches seed data.

**Demo:** Seed a few sessions → `GET /dashboard` shows funnel counts; export returns a downloadable file.

---

### Task 16: Admin — user management

**Objective:** CRUD for admin/viewer users.

**Guidance:**

- Routes under `/api/v1/admin/users` — all `abilities:admin`.
- Fields: `name`, `email` (unique), `password`, `role`.
- `PUT /users/{id}` allows email + role updates. Password update only via a dedicated `PUT /users/{id}/password` endpoint (payload `password` + `password_confirmation`).
- Prevent deleting the last remaining admin (validation guard).

**Tests:** Feature — viewer forbidden; admin can create/update/delete; can't demote or delete the last admin; email uniqueness enforced.

**Demo:** Admin creates a viewer user; viewer logs in and can read reports but not create questions.

---

### Task 17: Integration test — full happy path + hostile inputs

**Objective:** End-to-end Pest test covering the entire funnel + hostile-input suite.

**Guidance:**

- `RefreshDatabase`, `Carbon::setTestNow` inside the campaign window, `SmsGateway` fake bound in the container.
- Happy path: register → verify → play 10 questions (some correct, some wrong, one timed out) → finalize → unlock → assert score, offer, coupon, SMS sent, all analytics events recorded in order.
- Hostile inputs: double-submit, expired timer, closed campaign returns closed state, one-attempt lockout (second registration for same mobile returns existing state), invalid OTP, lockout after 5 wrong attempts.
- Ensure `php artisan test --compact` is green.

**Tests:** As described above.

**Demo:** `php artisan test --compact` — green suite.

---

## 9. Cross-cutting Concerns

### 9.1 Response conventions

- All responses JSON, versioned under `/api/v1/`.
- Success responses use Eloquent API Resources.
- Error responses follow Laravel's default validation error shape (`{ message, errors: {} }`) for 422; explicit shape for 4xx business errors: `{ message, code }`.
- Rate-limit exceeded → 429 with `Retry-After` header.
- Campaign closed → 200 with `{ status: "campaign_closed" }` (not an error state).

### 9.2 Coding standards

- Follow `laravel-best-practices` skill throughout.
- Follow `testing-best-practices` skill for all tests.
- All PHP files pass `vendor/bin/pint --dirty --format agent`.
- All PHP files pass `phpstan analyse` (level per project's `phpstan.neon`).
- Curly braces for all control structures (per project PHP rules).
- Constructor property promotion for typed dependencies.
- Explicit return types and parameter type hints everywhere.
- Enums use TitleCase keys.
- API Resources for every response.

### 9.3 Security notes

- Correct answers never leave the server before submit — verified by tests that inspect response shape.
- OTP hashes stored in cache, never plain text.
- Tokens signed by Sanctum's default mechanism; admin tokens expire in 12 h.
- Login and OTP endpoints rate-limited by both mobile/email and IP.
- All admin write routes require `abilities:admin` — no combined guest/admin surface.
- HTTPS assumed at infra layer (production).
- DPDP compliance: consent flag required on registration; only name and mobile stored.

### 9.4 Local development

- `SMS_DRIVER=log` in `.env` — OTPs written to `storage/logs/laravel.log`.
- Seeder creates one admin user via env-configured credentials (never seed in prod).
- 30 sample questions + 10 sample offers seeded for local dev only (behind a `--seed` guard).
- MySQL local via Laragon (project directory suggests it).

## 10. Open Questions / Deferred

- **Bluewaves API integration.** Real HTTP calls, credential storage (env), DLT template IDs, retry policy, and error handling all deferred until custom API spec is shared. Structure is in place.
- **Rich text for offer T&C.** Currently plain text/markdown; can upgrade to sanitized HTML if the admin frontend needs it.
- **Password reset for admins.** Not in scope; can wire Fortify's password-reset routes when needed.
- **OTP durability across cache flushes.** Cache-only for now. Add `otp_attempts` table if a persistent audit trail is required.
- **Provider retry / queueing for offer SMS.** Sync send as agreed. Wrapped in an action so migrating to a queued job later is a one-line change.
- **Multi-language.** English UI per PRD; no i18n plumbing added.

## 11. Definition of Done

- All 17 tasks implemented and their tests passing.
- `php artisan test --compact` green.
- `vendor/bin/pint --format agent` clean.
- `vendor/bin/phpstan analyse` clean.
- `.env.example` documents `DB_CONNECTION`, `SMS_DRIVER`, and Bluewaves placeholder keys.
- Seeder produces a working local dataset for QA (30 questions, 10 offers, 1 admin user).
- README section for the admin API and guest API endpoints (produced only if explicitly requested).
