# Gifts Scan Design

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

## Data Model

The unit consumes the `gifts` table exclusively through the `Gift` Eloquent model. No writes are performed.

| Column | Type | Notes |
|--------|------|-------|
| `id` | integer PK | |
| `name` | string | Substring-searched by this unit |
| `image` | string | Resolved to a full URL by the model's image accessor (or `noimage.png` placeholder) |
| `points` | decimal | Serialized in the response |
| `limit` | integer | Serialized in the response |
| `quantity` | integer | Stock ceiling; used with `used` to derive availability |
| `used` | integer | Count of redemptions; used with `quantity` to derive availability |
| `active` | tinyint (0/1) | Optional filter criterion |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |
| `deleted_at` | timestamp \| null | Soft-delete marker — the `Gift` default scope excludes non-null rows |

**Derived / appended field:**

| Field | Derivation | Source |
|-------|------------|--------|
| `quantity_available` | `quantity − used` | `Gift.$appends`, `Gift.php:16-27` |

The `Gift` model declares `SoftDeletes`; its default scope automatically excludes deleted rows from every query this unit issues. (`Gift.php:10-14`)

## Internal Flows

### Main flow — `GiftController::scan` (`GiftController.php:115-126`)

1. Read `$q = request('q')` — no validation, no default; `null` when absent. (`:117`)
2. Build `$result = Gift::where('name','like',"%$q%")` — a leading-and-trailing wildcard substring match on `name` only. (`: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])` — always 200; `data` is the serialized collection (empty `[]` on no match). (`: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["response()->json(['data' => $result])  HTTP 200"]
```

### Alternative / edge-case paths

| Condition | Behaviour |
|-----------|-----------|
| `q` absent or empty string | `LIKE '%%'` — matches all non-deleted gifts; first 10 returned (not `[]`). Intended contract unconfirmed. 🔴 (`:118`) |
| `active=1` present | Only active gifts returned |
| `active=0` present | Only inactive gifts returned |
| `active` absent | No status filter; both active and inactive gifts may appear |
| Zero matches | `get()` returns empty collection → `{data:[]}`, still HTTP 200 |
| Soft-deleted gift | Excluded by `Gift` default scope before the query reaches the DB |
| Unauthenticated request | Admin group middleware intercepts → `302` to `auth/login` (`routes/web.php:24-28`) |

### Route resolution

`GET settings/gifts/scan` is registered on line `routes/web.php:90`, **before** `resource settings/gifts` on `:91`. This ordering is load-bearing: without it, the resource router would match `scan` as the `{gift}` segment of the resource `show` route (`settings/gifts/{gift}`), silently serving the wrong action.

## Technical Decisions

| Decision | Rationale / Evidence | Confidence |
|----------|----------------------|------------|
| Name-only substring search (`LIKE %q%`) | Gifts have no code/barcode column; the picker narrows by human-readable name | 🟢 (`:118`) |
| `active` filter is opt-in, not a default scope | Lets callers explicitly request inactive gifts when needed; the picker chooses `?active=1` itself | 🟢 (`:119-121`) |
| Hard cap of 10, no pagination or total count | Autocomplete list, not a report; bounding payload size is the goal | 🟢 (`:122`) |
| `{data:[…]}` envelope rather than a bare array | Differs from `customers-scan` (bare array) and `pos-scan` (`{is_barcode, data}` union); consumers must read `.data` | 🟡 (`:123-125`) |
| Full-record serialization including `quantity_available` and image URL | Allows the picker to display stock count and thumbnail in one round-trip with no second call | 🟢 (`Gift.php:16-27`) |
| Route declared before `resource settings/gifts` | Prevents the resource `show` route from shadowing the `scan` action | 🟢 (`routes/web.php:90-91`) |
| No server-side availability enforcement at this endpoint | Authoritative gate (`checkGiftAvailable` / `redeemRewardPoints`) lives in `customers-loyalty`; this unit only supplies candidates | 🟢 (`gifts` flowchart:39-41) |

## Notes

**Performance constraint — leading wildcard LIKE:** The pattern `LIKE '%q%'` cannot be satisfied by a standard B-tree index on `name`. Every request triggers a full scan of the `gifts` table. Degrades per-keystroke as the catalogue grows. (`GiftController.php:118`) 🟡

**LIKE wildcard injection:** User-supplied `%` and `_` characters are treated as LIKE wildcards. The query is parameter-bound so SQL injection is not possible, but a literal `%` in the search term matches everything and a `_` matches any single character. (`GiftController.php:118`) 🟡

**Empty-query contract gap:** An absent or empty `q` produces `LIKE '%%'`, returning the first 10 non-deleted gifts rather than an empty array. Whether the caller relies on or guards against this behaviour is undocumented. 🔴 (`GiftController.php:118`)

**Consumer-side availability enforcement (verified 2026-09-23):** The only known consumer (`customer-statis.blade.php`) always calls `?active=1` (`:328`) and applies `pointer-events: none; opacity: 0.6` to any option with `quantity_available <= 0` (`:289,338`). The endpoint's omission of an availability filter is therefore not a practical gap for the current consumer, but it is a contract assumption rather than an enforced constraint.

**No observability:** The endpoint emits no log entries, metrics, or traces. 🔴 (`GiftController.php:115-126`, absence)
