# Gifts (CRUD) — Technical Design

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

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

## Interface

Standard Laravel resource under the admin group + `settings` prefix (`['web','admin']`, empty admin prefix). The controller overrides `index`/`edit`; `store`/`update`/`destroy`/`show`/`create` are the `ModelForm` trait defaults driven by `form()`. The `scan` action on the same controller is the separate `gifts-scan` unit. 🟢 (`routes/web.php:86-91`, `GiftController.php:17`)

| Method | Path | Input | Output | Status codes |
|--------|------|-------|--------|--------------|
| GET | `settings/gifts` | — | `text/html` (grid + inline create form) | 200, 302 (unauthenticated) |
| POST | `settings/gifts` | `name` (required), `image`, `points`, `limit`, `quantity`, `active` | `302` back to the list | 302, 422 (validation) |
| GET | `settings/gifts/{id}/edit` | path `id` | `text/html` (edit form) | 200, 404 |
| PUT/PATCH | `settings/gifts/{id}` | same fields (+`quantity ≥ used`) | `302` back to the list | 302, 404, 422 |
| DELETE | `settings/gifts/{id}` | path `id` | `302`/framework response | 302, 404 |

Controller symbols:

| Symbol | Signature | Return | Note |
|--------|-----------|--------|------|
| `GiftController::index` | `()` | `Content` | Two-column: `grid()->render()` + a `Widgets\Form` action=`settings/gifts` (name/image/points/limit/quantity/active-switch default 1). 🟢 (`:19-41`) |
| `GiftController::edit` | `($id)` | `Content` | `form()->edit($id)`. 🟢 (`:43-49`) |
| `GiftController::grid` | `()` | `Grid` | id/name/image-thumb/points/limit/`used`-`quantity`/active-label; create/export/selector/filter/pagination disabled; **empty** row-action closure (edit + delete on all rows). 🟢 (`:51-74`) |
| `GiftController::form` | `()` | `Form` | Fields + `quantity ≥ used` rule + `saving`/`saved` hooks. 🟢 (`:76-113`) |

## Main Flow 🟢 (`GiftController.php`)

1. `GET settings/gifts` → `index()` builds `Admin::content` with header `Quà tặng` ("Gift"), a two-column row: left `column(6, grid()->render())`, right `column(6, …)` an inline `Widgets\Form` (action `admin_base_path('settings/gifts')`) with `text('name')`, `file('image')`, `text('points')`, `text('limit')`, `text('quantity')`, `switch('active')->default(1)`, wrapped in a green `Box(admin.new)`. 🟢 (`:19-41`)
2. Submitting the inline form → `POST settings/gifts` → `ModelForm::store()` validates & maps fields via `form()`, runs the `saving` hook (zero-defaults + `used=0` for a new gift), persists, then the `saved` hook resizes any uploaded image to 300×300, then redirects back. 🟢 (`:76-113`)
3. `GET settings/gifts/{id}/edit` → `edit()` renders `form()->edit($id)` (header `Quà tặng`), showing the read-only `used` display. 🟢 (`:43-49,93-95`)
4. Submitting the edit → `PUT settings/gifts/{id}` → `ModelForm::update()`; the `quantity` rule now enforces `min:{used}`; hooks run as in store. The `saving` hook is `used = id ? used : 0` (`:103`); `used` is not a posted field (only a disabled display, `:93-95`). ✅ Verified 2026-09-25 (`questions.md#question-16`) via a real HTTP edit request against the running app (real admin session, `used` correctly omitted from the submission): the stored `used` value is preserved unchanged on edit. 🟢 (`:84-111`)
5. `DELETE settings/gifts/{id}` → `ModelForm::destroy()` → `Gift` `SoftDeletes` sets `deleted_at`; the row leaves the grid, redemption history is retained. 🟢 (`Gift.php:10-14`)
6. `grid()` renders id(sortable)/name/image-thumbnail/points/limit/`used-slash-quantity`/active-label with create/export/selector/filter/pagination disabled and an **empty** row-action closure — so edit and delete stay on every row. 🟢 (`:51-74`)

## Alternative Flows

- **Quantity below used (edit):** `quantity` rule `|min:{used}` fails → redirect back with "Số lượng không được bé hơn tổng đã dùng". 🟢 (`:84-92`)
- **Blank numerics (create/edit):** `saving` hook coerces `points/limit/quantity` to `0`; a new gift's `used` is forced to `0`. 🟢 (`:99-104`)
- **Image upload:** validated `max:1024|mimes:jpeg,png,jpg,gif,svg,webp`; on success the `saved` hook fits it to 300×300. A missing image falls back to the `noimage.png` placeholder via the model accessor. 🟢 (`:80-81,106-111`; `Gift.php:21-23`)
- **Missing `name`:** `rules('required')` fails → redirect back with errors. 🟢 (`:79`)
- **Unknown id on edit/update/delete:** framework `findOrFail` → `404`. 🟡 (`ModelForm` default)
- **Unauthenticated:** admin group middleware → `302 auth/login`. 🟢 (`routes/web.php:24-28`)

## Dependencies

- **`Encore\Admin\Controllers\ModelForm` trait** — supplies `store`/`update`/`destroy`/`create`/`show`, driven by `form()`. 🟢 (`:17`)
- **`Encore\Admin` grid/form/widgets/layout** — the whole UI. 🟢 (`:6-13`)
- **`Intervention\Image` (`\Image` facade)** — the `saved` hook's `Image::make(...)->fit(300,300)->save()` resize. 🟢 (`:108-109`)
- **Local `storage/app/public` disk** — image files read/written under `storage_path('app/public/…')`; served via `storage_url`. 🟢 (`:108`; `Gift.php:22`)
- **`Gift` model** — `SoftDeletes`, `$fillable`, appended `quantity_available`, image accessor, `active` scope. 🟢 (`Gift.php`)
- **Consumers:** `customers-loyalty` (redemption gate reads `points`/`limit`/`quantity_available`/`active` and increments `used`), `gifts-scan` (name/active lookup), `customers-statistics` (gift picker). 🟢 (`gifts` flowchart:39-41)

## Identified Design Decisions

| Decision | Evidence in code | Confidence |
|----------|------------------|------------|
| Framework-scaffolded CRUD (Encore\Admin `ModelForm`) rather than a hand-written controller | `use ModelForm;` + only index/edit/grid/form overridden | 🟢 (`:17`) |
| Inline create widget in a two-column screen instead of a separate create page | `index()` right column + `disableCreateButton()` | 🟢 (`:28-39,67`) |
| Gifts are fully editable **and** deletable on every row (empty row-action closure) — unlike the locked `brands`/`units`/`categories` | `$grid->actions(function (...) {});` | 🟢 (`:72`) |
| Soft delete rather than hard delete — retire a reward while keeping redemption history | `use SoftDeletes;` on `Gift` | 🟢 (`Gift.php:10-14`) |
| Stock accounting split: stored `quantity`+`used`, computed `quantity_available` | `getQuantityAvailableAttribute` + `$appends` | 🟢 (`Gift.php:18,25-27`) |
| Server-side image normalisation to a fixed 300×300 square | `saved` hook `->fit(300,300)` | 🟢 (`:106-111`) |
| Quantity floor at `used` to protect stock accounting | dynamic `min:{used}` rule when editing | 🟢 (`:84-92`) |
| Unpaginated/unfiltered grid (small catalogue assumption) | `disableFilter()`, `disablePagination()` | 🟡 (`:70-71`) |

## Internal State

None request-spanning. Persisted `gifts` rows (`id`, `name`, `image`, `points` decimal(8,1), `limit`, `quantity`, `used`, `active`, timestamps, `deleted_at`). `quantity_available` is derived, not stored. 🟢 (`Gift.php`; gifts migrations)

## Observability

None. The scaffolded CRUD and the image-resize hook emit no domain-level log/metric/trace. 🔴 (`GiftController.php`, absence)

## Risks and Gaps

- 🟡 **Inline create widget vs `form()` divergence.** The inline `Widgets\Form` declares `points`/`limit`/`quantity` as `text()` with no rules and `active` defaulting on, while the authoritative validation/hooks live in `form()` (used by `ModelForm::store`). The widget is only the input UI; the effective contract is `form()`. Confirm the create screen and edit screen present consistent field types/defaults. (`:31-36` vs `:76-97`)
- 🟡 **Image path coupling.** The `saved` hook reads `storage_path('app/public/'.$form->model()->getOriginal('image'))`; it assumes the raw stored path and the `public` disk layout. A disk/config change would silently skip the resize. (`:108`)
- 🟡 **Out-of-stock uses strict `=== 0`** (in the consuming redemption gate) so a negative `quantity_available` would slip through; this unit does not clamp `used ≤ quantity` beyond the edit-time floor. (`Gift.php:25-27`; `gifts` flowchart:41)
- 🟡 **No per-record authorization.** Any authenticated admin can create/edit/delete gifts. (ADR-0009)
- 🔴 **No observability** on gift mutations or the image-resize step (see above).
