# Gifts (CRUD) — Requirements

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

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

## Overview

`gifts-crud` is the back-office editor for the loyalty-reward catalogue (`resource settings/gifts`, `GiftController`). Like the other `settings/*` editors it is an **Encore\Admin** `ModelForm` scaffold with a two-column screen (grid + inline create form) and an edit form — but unlike `brands`/`units`/`categories` it carries **real business fields** (`points` cost, per-customer `limit`, `quantity` stock, `used` counter, `active` flag, `image`), lifecycle **hooks** (default-zeroing on save, image resize after save), **soft deletes**, and **no row-action locks** — every row is editable *and* deletable. 🟢 (`routes/web.php:91`, `GiftController.php`, `Gift.php`)

Only `index`, `edit`, `grid`, and `form` are overridden; `store`, `update`, and `destroy` come from the `ModelForm` trait and are driven by `form()`. The `scan` JSON lookup action on the same controller is documented separately as the **`gifts-scan`** unit. 🟢 (`GiftController.php:17,115-126`)

A gift is redeemed for loyalty points elsewhere: the redemption gate and the transactional spend live in `customers-loyalty` (`Customer::checkGiftAvailable` + `CustomerController::redeemRewardPoints`). This unit only manages the catalogue rows those flows read and increment. 🟢 (`gifts` flowchart:39-41)

## Responsibilities

- Render a two-column management screen: left = gift grid, right = an inline create form posting to `settings/gifts`. 🟢 (`:19-41`)
- List gifts in a grid: `id` (sortable), `name`, `image` (thumbnail), `points`, `limit`, a computed `used/quantity` stock column, and an `active` status label; create button / export / row selector / filter / pagination all disabled. 🟢 (`:51-74`)
- Allow edit **and** delete on **every** row (the row-action closure is empty — no locks). 🟢 (`:72`)
- Provide the create/edit form: `name` (required), `image` (≤1 MB, image mimes, resized to 300×300), `points`/`limit`/`quantity` (nullable numeric ≥ 0), a read-only `used` display, and an `active` switch. 🟢 (`:76-113`)
- On save: default `points`/`limit`/`quantity` to `0` when blank; force `used = 0` for a new gift (on edit the hook is `used = id ? used : 0`, keeping the existing value). ✅ Verified 2026-09-25 (`questions.md#question-16`) via a real HTTP edit request (real admin session, `used` omitted from the submitted fields exactly as a browser would): the stored `used` was confirmed unchanged after the edit. 🟢 (`:99-104`)
- After save with an uploaded image: crop/resize it to a 300×300 square. 🟢 (`:106-111`)
- Refuse to drop `quantity` below the number already redeemed (`used`) when editing. 🟢 (`:84-92`)

## Business Rules

- **A gift is `name` + optional `image` + `points` (redemption cost) + `limit` (per-customer cap) + `quantity` (stock) + `used` (redeemed counter) + `active` (redeemable flag).** 🟢 (`Gift.php:16`; `create_gifts_table` + `add_total_used_to_gifts_table` migrations)
- **`points` is the loyalty cost to redeem**, stored `decimal(8,1)` (one decimal place). 🟢 (`create_gifts_table:20`)
- **`limit` is the per-customer redemption cap; `0` means unlimited.** The cap is *enforced* in `customers-loyalty` (`checkGiftAvailable`), not here — this unit only stores it. 🟢 (`gifts` flowchart:40)
- **`quantity_available = quantity − used`** is a computed, appended attribute (never stored). The out-of-stock test (strict `=== 0`) lives in the redemption gate, not here. 🟡 (`Gift.php:18,25-27`; `gifts` flowchart:41)
- **`quantity` cannot be lowered below `used` on edit.** The `quantity` rule appends `|min:{used}` only when the model already has an id, with message "Số lượng không được bé hơn tổng đã dùng" ("Quantity cannot be less than total already used"). 🟢 (`:84-92`)
- **Blank numeric fields default to 0 on save; `used` is forced to 0 for a new gift.** `saving` hook: `points/limit/quantity ?: 0`, `used = id ? used : 0`. 🟢 (`:99-104`)
- **An uploaded image is normalised to a 300×300 square** after save via `Image::make(...)->fit(300,300)->save()`. 🟢 (`:106-111`)
- **`active` toggles redeemability.** `Gift::scopeActive()` filters `active = 1`; the inline create form defaults `active` on. 🟢 (`Gift.php:30-32`; `:36`)
- **Gifts are soft-deleted and freely editable/deletable — no row locks.** The empty row-action closure means every row keeps its edit and delete actions; `Gift` uses `SoftDeletes`, so a deleted gift is hidden but its redemption history (`customer_gift` pivot) survives. This contrasts with `brands`/`units`/`categories`, which lock deletes and the id-1 edit. 🟢 (`:72`; `Gift.php:10-14`)
- **`image` accessor returns a usable URL or a placeholder.** `getImageAttribute` returns `asset(storage_url($value))` or `asset('images/noimage.png')` when empty. 🟢 (`Gift.php:21-23`)

## Functional Requirements

| ID | Requirement | Priority | Acceptance criterion |
|----|-------------|----------|----------------------|
| RF-01 | List gifts at `GET settings/gifts` (grid + inline create form) | Must | The page shows the id/name/image/points/limit/`used`-`quantity`/active grid and a "new gift" box. 🟢 |
| RF-02 | Create a gift via the inline form (`POST settings/gifts`) | Must | Submitting a valid gift persists it; blank points/limit/quantity default to 0 and used starts at 0. 🟢 |
| RF-03 | Edit a gift (`GET settings/gifts/{id}/edit`, `PUT settings/gifts/{id}`) | Must | Fields update; `quantity` cannot be set below `used`. 🟢 |
| RF-04 | Delete a gift (`DELETE settings/gifts/{id}`) | Should | The gift is soft-deleted and disappears from the grid; redemption history is retained. 🟢 |
| RF-05 | Validate image (≤1 MB, image mimes) and resize to 300×300 | Should | A non-image or >1 MB upload is rejected; a valid image is cropped to 300×300. 🟢 |
| RF-06 | Enforce `quantity ≥ used` on edit | Must | Editing with `quantity < used` fails with the localized message. 🟢 |
| RF-07 | Require an authenticated admin session | Must | Anonymous request → `302` to `auth/login`. 🟢 (`routes/web.php:24-28`) |

## Non-Functional Requirements

| Type | Inferred requirement | Evidence in code | Confidence |
|------|----------------------|------------------|------------|
| Security | Admin authentication required (admin route group middleware `['web','admin']`) | `routes/web.php:24-28,86-92` | 🟢 |
| Integrity | `quantity` guarded against dropping below `used`; `used`/stock defaults zeroed on save | `GiftController.php:84-104` | 🟢 |
| Integrity | Soft delete preserves redemption history (`customer_gift` pivot rows) even after a gift is removed | `Gift.php:10-14` | 🟡 |
| Usability | Uploaded images normalised to 300×300 for a consistent grid/picker thumbnail | `GiftController.php:106-111` | 🟢 |
| Usability | Unpaginated single-screen grid — assumes a small gift catalogue | `GiftController.php:67-71` | 🟡 |
| Observability | None — framework CRUD emits no domain log/metric | `GiftController.php` (absence) | 🔴 |

> Inferred from code. Validate with the operations team.

## Acceptance Criteria

```gherkin
Given an authenticated administrator
When he accesses GET settings/gifts
Then he receives HTTP 200 with the gifts grid (id/name/image/points/limit/used-quantity/status) and the inline "new gift" form

Given the inline new-gift form
When he submits a valid name with points/limit/quantity blank
Then a new gift is persisted with points/limit/quantity = 0 and used = 0

Given an existing gift with used = 5
When the administrator tries to save quantity = 3
Then validation fails with "Số lượng không được bé hơn tổng đã dùng" and nothing is changed

Given the gift form with a valid image upload (<=1MB, image mime)
When the gift is saved
Then the image is resized/cropped to 300x300 and stored

Given an existing gift
When the administrator triggers delete on the row
Then the gift is soft-deleted and disappears from the grid, preserving the redemption history

Given a request with no authenticated admin session
When settings/gifts is accessed
Then it receives HTTP 302 redirecting to auth/login
```

## Priority (MoSCoW)

| Requirement | MoSCoW | Justification |
|-------------|--------|---------------|
| List + create gifts (RF-01, RF-02) | Must | The loyalty catalogue that redemption reads |
| Edit gift + quantity≥used guard (RF-03, RF-06) | Must | Correcting catalogue data without corrupting stock accounting |
| Admin authentication (RF-07) | Must | Enforced by the route group |
| Delete gift (RF-04) | Should | Retiring a reward; soft delete keeps history |
| Image validation + resize (RF-05) | Should | Consistent thumbnails; guards against oversized/non-image uploads |

> Priority inferred from gifts being the master data the loyalty redemption flow depends on.

## Code Traceability

| File | Function / Class | Coverage |
|------|------------------|----------|
| `app/Http/Controllers/GiftController.php:19-41` | `GiftController::index` (two-column grid + inline form) | 🟢 |
| `app/Http/Controllers/GiftController.php:43-49` | `GiftController::edit` | 🟢 |
| `app/Http/Controllers/GiftController.php:51-74` | `GiftController::grid` (columns, computed stock/active displays, empty row-action closure) | 🟢 |
| `app/Http/Controllers/GiftController.php:76-113` | `GiftController::form` (fields, quantity≥used rule, saving/saved hooks) | 🟢 |
| `app/Models/Gift.php:10-32` | `Gift` model (SoftDeletes, fillable, `quantity_available` accessor, image accessor, `active` scope) | 🟢 |
| `database/migrations/2022_05_18_122119_create_gifts_table.php` | `gifts` schema (points decimal(8,1), limit, quantity, active, softDeletes) | 🟢 |
| `database/migrations/2022_05_25_121331_add_total_used_to_gifts_table.php` | `gifts.used` counter | 🟢 |
| `routes/web.php:91` | `resource settings/gifts` | 🟢 |
