# Gifts Scan — Technical Design

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

**Confidence scale:** 🟢 CONFIRMED · 🟡 INFERRED · 🔴 GAP

## Interface

A single GET action on `GiftController` under the admin group + `settings` prefix (`['web','admin']`). Declared before `resource settings/gifts` so it is not shadowed by the resource `show` route. 🟢 (`routes/web.php:86-91`, `GiftController.php:115-126`)

| Method | Path | Input | Output | Status codes |
|--------|------|-------|--------|--------------|
| GET | `settings/gifts/scan` | query `q` (optional), `active` (optional) | `application/json` `{ "data": [ …Gift ] }` | 200, 302 (unauthenticated) |

Controller symbol:

| Symbol | Signature | Return | Note |
|--------|-----------|--------|------|
| `GiftController::scan` | `()` | `JsonResponse` | `Gift::where('name','like',"%$q%")` (+ optional `active` filter) `->take(10)->get()`, wrapped in `{data: …}`. 🟢 (`:115-126`) |

## Main Flow 🟢 (`GiftController.php:115-126`)

1. Read `$q = request('q')` (no validation, no default). 🟢 (`:117`)
2. Build `$result = Gift::where('name','like',"%$q%")` — a substring match on `name`. 🟢 (`:118`)
3. If `request()->has('active')`, chain `->where('active', request('active'))`. 🟢 (`:119-121`)
4. `$result = $result->take(10)->get()` — materialise at most 10 rows. 🟢 (`:122`)
5. `return response()->json(['data' => $result])` — a 200 JSON object; `data` is the (possibly empty) array of serialized gifts. 🟢 (`:123-125`)

```mermaid
flowchart TD
    A["GET settings/gifts/scan?q=&active="] --> B["Gift where name LIKE %q%"]
    B --> C{"request has 'active'?"}
    C -->|yes| D["where active = request(active)"]
    C -->|no| E["(no active filter)"]
    D --> F["take(10)->get()"]
    E --> F
    F --> G["json {data: [...]}"]
```

## Alternative Flows

- **Empty / missing `q`:** `LIKE '%%'` matches everything → the first 10 live gifts are returned (not `[]`). Contract for the empty query is incidental, unconfirmed. 🔴 (`:118`)
- **`active` param present with value 0:** filters to inactive gifts (`where active = 0`); present with 1 → active only; absent → no status filter. 🟢/🟡 (`:119-121`; presence semantics per Laravel 5.6 `has()` — confirm empty-string behaviour)
- **No matches:** `get()` returns an empty collection → `{data: []}` (still 200). 🟢 (`:122-125`)
- **Soft-deleted gifts:** excluded by the model's default scope. 🟢 (`Gift.php:10-14`)
- **Unauthenticated:** admin group middleware → `302 auth/login`. 🟢 (`routes/web.php:24-28`)

## Dependencies

- **`Gift` model** — `SoftDeletes` default scope, `$fillable`, appended `quantity_available`, image accessor; supplies the serialized payload. 🟢 (`Gift.php`)
- **Consumers:** the loyalty redemption **picker** (rendered on the customer statistics/redeem screens) reads `{data:[…]}` to let the operator choose a gift; the authoritative availability check happens later in `customers-loyalty`. 🟢 (`gifts` flowchart:39-40)

## Identified Design Decisions

| Decision | Evidence in code | Confidence |
|----------|------------------|------------|
| Name-only substring lookup (no code/id path) — gifts have no code column | `where('name','like',"%$q%")` | 🟢 (`:118`) |
| `active` filter is opt-in, not a default `active()` scope — the picker chooses whether to include inactive gifts | `if (request()->has('active'))` | 🟢 (`:119-121`) |
| Hard cap 10, no pagination/total — an autocomplete list, not a report | `->take(10)` | 🟢 (`:122`) |
| `{data:[…]}` envelope rather than a bare array | `response()->json(['data' => …])` | 🟡 (`:123-125`) |
| Full-record serialization (incl. `quantity_available`, image) so the picker shows stock/thumbnail in one round-trip | `Gift` `$appends` + image accessor | 🟢 (`Gift.php:16-27`) |

## Internal State

Stateless — a single query per request, no session or cross-request state. 🟢 (`GiftController.php:115-126`)

## Observability

None. The lookup emits no log/metric/trace. 🔴 (`GiftController.php:115-126`, absence)

## Risks and Gaps

- 🔴 **Empty/missing `q` returns the first 10 gifts** (`LIKE '%%'`) rather than `[]`; the intended empty-query contract is unconfirmed.
- 🟢 **Verified not a practical risk (2026-09-23).** The endpoint's own contract is opt-in on `active` and never filters `quantity_available`, but the only consumer (`customer-statis.blade.php`'s redeem-points picker) already calls it with `url: '/settings/gifts/scan?active=1'` (`:328`), so inactive gifts never reach the dropdown at all; and its `templateResult` callback adds a `.disable-div` class (`pointer-events: none; opacity: 0.6`) to any option with `quantity_available <= 0` (`:289,338`), so out-of-stock gifts are shown greyed-out but cannot be clicked/selected. The redemption gate (`checkGiftAvailable`) remains the authoritative server-side check regardless. `questions.md#question-7` closed — no code change needed.
- 🟡 **Leading-wildcard `LIKE '%q%'` on `name`** cannot use an index → per-keystroke full scan that degrades as the catalogue grows. (`:118`)
- 🟡 **User `%`/`_` act as LIKE wildcards** (parameter-bound, so no injection). (`:118`)
- 🟡 **Envelope shape inconsistency** across scan endpoints (`{data:[…]}` here vs bare array in `customers-scan` vs `is_barcode` union in `pos-scan`). (`:123-125`)
- 🟡 **No per-record authorization.** Any authenticated admin can query gifts. (ADR-0009)
- 🔴 **No observability** on this lookup path (see above).
