# Gifts (CRUD) — Design

## Data Model

### `gifts` table

| Column | Type | Notes |
|--------|------|-------|
| `id` | bigIncrements | Primary key |
| `name` | string | Required; display name of the reward |
| `image` | string | Relative path under `storage/app/public`; empty → `noimage.png` placeholder via accessor |
| `points` | decimal(8,1) | Loyalty-point redemption cost; defaults to 0 |
| `limit` | integer/numeric | Per-customer redemption cap; 0 = unlimited |
| `quantity` | integer/numeric | Total stock issued; 0 = unlimited (enforced by consuming gate) |
| `used` | integer | Cumulative redeemed count; managed by `customers-loyalty`, not user-submitted |
| `active` | boolean | Redeemable flag; `Gift::scopeActive()` filters `active = 1` |
| `created_at`, `updated_at` | timestamps | Standard Eloquent |
| `deleted_at` | timestamp (nullable) | Soft-delete sentinel (`SoftDeletes` trait) |

Migrations: `create_gifts_table` (initial schema, `points` decimal(8,1), `active`, `softDeletes`) + `add_total_used_to_gifts_table` (`used` counter).

### Computed / appended attribute

`quantity_available = quantity − used` — declared in `$appends`, returned by `getQuantityAvailableAttribute`; never stored. The out-of-stock guard (strict `=== 0`) lives in `customers-loyalty`, not in this unit.

### `Gift` model surface

```
SoftDeletes
$fillable:    [name, image, points, limit, quantity, used, active]
$appends:     [quantity_available]
accessors:    getImageAttribute   → asset(storage_url($value)) | asset('images/noimage.png')
              getQuantityAvailableAttribute → quantity − used
scope:        scopeActive($q)     → $q->where('active', 1)
```

### Related tables (owned by other units)

| Table | Owner | Relationship to gifts |
|-------|-------|-----------------------|
| `customer_gift` | `customers-loyalty` | Pivot recording per-customer redemptions; `used` incremented here; rows survive gift soft-delete |

---

## Internal Flows

### Create (inline widget → `ModelForm::store`)

```
POST settings/gifts
  │
  ├─ form()->rules() ─── name:required, image:max:1024|mimes:..., points/limit/quantity:nullable|numeric|min(0)
  │                       quantity rule: no min:{used} floor (new record has no id)
  │
  ├─ saving hook
  │     points  = points  ?: 0
  │     limit   = limit   ?: 0
  │     quantity= quantity ?: 0
  │     used    = id ? used : 0   ← id is null for new gift → forced to 0
  │
  ├─ Eloquent INSERT → gifts row created
  │
  ├─ saved hook (fires only when image was uploaded)
  │     Image::make(storage_path('app/public/' + original_image_path))
  │          ->fit(300, 300)
  │          ->save()
  │
  └─ 302 redirect back to settings/gifts
```

### Edit / Update (`form()->edit($id)` → `ModelForm::update`)

```
PUT settings/gifts/{id}
  │
  ├─ form()->rules() ─── same base rules PLUS:
  │                       quantity: append |min:{used}  (msg "Số lượng không được bé hơn tổng đã dùng")
  │                       (applied only when model->id exists)
  │
  ├─ saving hook
  │     points  = points  ?: 0
  │     limit   = limit   ?: 0
  │     quantity= quantity ?: 0
  │     used    = id ? used : 0   ← id exists → used preserved from stored value
  │     (`used` is not a posted field; rendered read-only/disabled in the edit form)
  │
  ├─ Eloquent UPDATE → gifts row updated
  │
  ├─ saved hook (fires only when image was uploaded)
  │     Image::make(...)->fit(300, 300)->save()
  │
  └─ 302 redirect back
```

### Delete (`ModelForm::destroy`)

```
DELETE settings/gifts/{id}
  │
  ├─ Gift::findOrFail($id)  ← 404 if missing or already soft-deleted
  ├─ $gift->delete()        ← SoftDeletes sets deleted_at; row hidden from grid
  │                            customer_gift pivot rows untouched
  └─ 302 framework redirect
```

### Grid render (`grid()`)

```
index() builds Admin::content (header: "Quà tặng")
  ├─ left column(6):  grid()->render()
  │     columns: id(sortable) / name / image(thumbnail) / points / limit
  │              / display("used/quantity" computed string) / active(label)
  │     disableCreateButton()
  │     disableExport()
  │     disableRowSelector()
  │     disableFilter()
  │     disablePagination()
  │     actions(function(){})   ← empty closure; retains default edit + delete on every row
  │
  └─ right column(6): Widgets\Form (action: admin_base_path('settings/gifts'))
        text('name'), file('image'), text('points'), text('limit'),
        text('quantity'), switch('active')->default(1)
        wrapped in green Box with admin.new label
```

---

## Technical Decisions

| Decision | Rationale / Evidence |
|----------|----------------------|
| Encore\Admin `ModelForm` scaffold — only `index`, `edit`, `grid`, `form` overridden | `store`/`update`/`destroy`/`show`/`create` come from the trait, driven by `form()`; reduces bespoke code (`GiftController.php:17`) |
| Inline create widget inside `index()` rather than a separate create page | `disableCreateButton()` + right-column `Widgets\Form`; quicker workflow for small catalogues (`GiftController.php:28-39,67`) |
| No row-action locks — edit and delete available on every row | Empty `$grid->actions(function(){})` closure; contrasts with `brands`/`units`/`categories` which lock certain rows (`GiftController.php:72`) |
| Soft delete (`SoftDeletes`) rather than hard delete | Retiring a gift preserves `customer_gift` pivot history; redemption records remain auditable (`Gift.php:10-14`) |
| Stock split: stored `quantity` + `used`, computed `quantity_available` | Keeps a single source of truth for stock and redeemed count; computed accessor avoids denormalisation drift (`Gift.php:18,25-27`) |
| Quantity floor `min:{used}` dynamically applied only on edit | Prevents corrupting stock accounting while allowing free entry on create; message is localised (`GiftController.php:84-92`) |
| `used` field is not user-submitted; edit form renders it read-only/disabled | Prevents admin override of the counter maintained by `customers-loyalty`; the `saving` hook uses the stored value when `id` exists (`GiftController.php:93-95,103`) |
| Server-side image normalisation to 300×300 square via `Intervention\Image::fit` | Enforces a uniform thumbnail size for the grid and gift picker regardless of upload dimensions; fires in the `saved` hook after the path is known (`GiftController.php:106-111`) |
| Unpaginated, unfiltered grid | `disableFilter()` + `disablePagination()` — assumes a small, manageable gift catalogue (`GiftController.php:67-71`) |
| `points` stored as `decimal(8,1)` | Allows fractional point costs (one decimal place) without floating-point imprecision |

---

## Notes

### Constraints and pitfalls

- **Inline widget vs `form()` divergence.** The right-column `Widgets\Form` in `index()` declares fields with no explicit validation rules and `active` defaulting on (`switch()->default(1)`). The authoritative validation and hooks live entirely in `form()` (invoked by `ModelForm::store`). The widget is presentation only; any mismatch in field types or defaults between the two surfaces is a UX inconsistency, not a contract break — but it is a latent source of confusion (`GiftController.php:31-36` vs `:76-97`).

- **Image path coupling.** The `saved` hook reconstructs the disk path as `storage_path('app/public/' + model->getOriginal('image'))`. This assumes the stored column value is a path relative to `storage/app/public` and that the public disk is located there. A disk reconfiguration would silently skip the resize without error (`GiftController.php:108`).

- **Out-of-stock does not clamp `used ≤ quantity`.** This unit enforces `quantity ≥ used` only at edit time. The redemption gate in `customers-loyalty` uses a strict `=== 0` check on `quantity_available`; a race condition that pushes `used` above `quantity` would yield a negative `quantity_available`, which is not `=== 0` and therefore would not be caught as out-of-stock (`Gift.php:25-27`).

- **No per-record authorization.** Any authenticated admin can create, edit, or delete any gift; there is no ownership or role check beyond the admin route-group middleware (ADR-0009).

- **No observability.** Gift mutations (create, update, delete) and the image-resize step emit no log entries, metrics, or traces. Failures in the `saved` hook's `Intervention\Image` call would surface only as unhandled exceptions (`GiftController.php`, absence).

- **`scan` action is a separate unit.** `GiftController::scan` (`GET settings/gifts/scan`, lines 115-126) belongs to the `gifts-scan` unit and must be registered before the resource route to avoid being shadowed by `settings/gifts/{id}`.
