# Aeren Shop — Admin Product Management Plan

## 1. Goal
Enable an admin to fully manage the shop catalogue (currently hardcoded in `app/Models/Product.php`) without touching code, while keeping the public `/collection` page working unchanged in look and behavior.

Scope: admin auth + product CRUD + categories + images + storefront integration. No cart/checkout/orders in this phase (listed as Phase 2).

## 2. Current State (Verified)
- Stack: plain PHP 8+, no Composer, no DB, custom MVC.
  - Front controller: `index.php` → `App\Core\Router` → `*Controller@method` → `App\Core\View::render()` → `app/Views/pages/*.php` inside `app/Views/layout/main.php`.
  - Router supports only `GET`/`POST`, exact-path matching, `Controller@method` strings. No route params (`/admin/products/1/edit` not supported today).
  - Config: `app/config.php` (site meta, `url()`, `asset()`, `e()` helpers).
  - Catalogue: `App\Models\Product::all()` / `::find()` / `::categories()` — static array with fields: `id, name, category, price, size, tag, rating, reviews, scent, description, image`.
  - Storefront: `CollectionController@collection` (filter by `?category=`) + `app/Views/pages/collection.php` (masonry cards, lightbox, filter buttons).
  - No auth, no session handling, no storage layer, images are remote `picsum.photos` URLs.

Implication: we need DB + auth + admin routing + image uploads before CRUD is useful.

## 3. Feature List

### 3.1 Admin Foundation (must-have)
1. **Admin auth**
   - Login/logout at `/admin/login`, session-based, password hashed (`password_hash` / `password_verify`).
   - Single role `admin` for now (schema-ready for roles).
   - Guard: all `/admin/*` routes redirect to login when not authenticated.
   - CSRF token on all POST forms.
2. **Admin layout + dashboard**
   - Separate layout `app/Views/layout/admin.php` (sidebar: Products, Categories, Dashboard, View Shop, Logout).
   - Dashboard: total products, products per category, low-stock/out-of-stock count, latest products.

### 3.2 Product Management (must-have, MVP)
3. **Product list** (`/admin/products`)
   - Table: thumbnail, name, category, price, stock/status, tag, updated date, actions (Edit/Delete).
   - Search by name/scent, filter by category + status, sort by name/price/updated, pagination (e.g. 20/page).
4. **Create product** (`/admin/products/create`)
   - Fields: `name*, category_id*, price*, size, scent, description, tag, image*, status (active/draft/archived), stock_qty, sku (unique, optional auto-gen), rating/reviews (admin-editable or hidden — see decision below)`.
   - Validation with inline errors + sticky form values.
5. **Edit product** (`/admin/products/edit?id=...`)
   - Same form as create, prefilled. Image replace + remove. Optimistic-locking optional (skip for MVP, use `updated_at` check only if easy).
6. **Delete / archive**
   - Soft-delete preferred (`deleted_at` or `status='archived'`) + hard delete option with confirm modal. Block delete if referenced by future orders.
7. **Image management**
   - Upload JPG/WebP/PNG (max ~2MB), server-side resize to max 1400px, store under `assets/uploads/products/`.
   - Keep remote-URL fallback field for now (migration period). Validate MIME + extension, randomize filename, delete old file on replace.
8. **Status / visibility**
   - `active` → shows on `/collection`; `draft`/`archived` → hidden from storefront, visible in admin with badge.

### 3.3 Category Management (must-have, lightweight)
9. **Category CRUD** (`/admin/categories`)
   - Fields: `name* (unique), slug* (auto from name), description, sort_order, active`.
   - Product form uses `<select>` from this table instead of hardcoded `Product::categories()`.
   - Prevent delete of category with products (or reassign flow).

### 3.4 Storefront Alignment (must-have)
10. **DB-backed catalogue, same UI**
    - `Product::all()` / `::find()` / `::categories()` keep signatures but read from PDO. `CollectionController` unchanged if possible.
    - Only `status='active'` products shown publicly. Same filter-by-category behavior, now slug-driven.
11. **Product detail (recommended MVP+)**
    - `/collection?product=<id>` modal exists via lightbox today; add dedicated `/product?id=` or `/p/<slug>` page reusing card data + SEO meta. Optional if time is tight.

### 3.5 Phase 2 (explicitly out of MVP, plan for)
- Stock/inventory history, low-stock alerts.
- Bulk actions (activate/archive/delete), CSV import/export.
- Rich text / scent notes editor, multiple images per product, image alt text.
- SEO fields (`slug, meta_title, meta_description`), friendly URLs.
- Reviews moderation (if `rating/reviews` stay user-driven).
- Orders, cart, discounts, coupons, newsletter list admin.
- Audit log (`admin_id, action, entity, entity_id, created_at`).
- Roles/permissions (editor vs admin).

**Open decision:** `rating`/`reviews` are currently static marketing numbers. Options: (a) keep editable by admin (simplest, no schema change in behavior), (b) hide from admin form and compute from future reviews table. Recommend (a) for MVP, migrate to (b) in Phase 2.

## 4. Data Design

Use SQLite (zero-setup, fits current hosting) or MySQL — same PDO code. Recommend SQLite file `app/storage/aeren.sqlite` (git-ignored) for dev, MySQL via env for prod.

```sql
CREATE TABLE admins (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email VARCHAR(190) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  name VARCHAR(100) NOT NULL DEFAULT 'Admin',
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE categories (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name VARCHAR(100) NOT NULL UNIQUE,
  slug VARCHAR(120) NOT NULL UNIQUE,
  description TEXT NULL,
  sort_order INT NOT NULL DEFAULT 0,
  is_active TINYINT NOT NULL DEFAULT 1,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE products (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  category_id INT NOT NULL REFERENCES categories(id),
  name VARCHAR(160) NOT NULL,
  slug VARCHAR(180) NOT NULL UNIQUE,
  price DECIMAL(10,2) NOT NULL,
  size VARCHAR(120) NULL,
  scent VARCHAR(160) NULL,
  description TEXT NULL,
  tag VARCHAR(60) NULL,
  image VARCHAR(255) NULL,          -- local path e.g. uploads/products/xxx.webp
  image_url VARCHAR(255) NULL,      -- fallback remote URL during migration
  sku VARCHAR(60) NULL UNIQUE,
  stock_qty INT NOT NULL DEFAULT 0,
  status VARCHAR(20) NOT NULL DEFAULT 'active', -- active|draft|archived
  rating DECIMAL(2,1) NOT NULL DEFAULT 0,
  reviews INT NOT NULL DEFAULT 0,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_products_status ON products(status);
CREATE INDEX idx_products_category ON products(category_id);
```

Seed: migrate the 9 static products + 4 categories into seed script; default admin created via CLI script (not via web form).

## 5. Routes (fits current Router — query-string params, no new router syntax needed)

```
GET  /admin/login              Auth@login
POST /admin/login              Auth@authenticate
POST /admin/logout             Auth@logout  (or GET with CSRF — prefer POST form in sidebar)

GET  /admin                    Admin@dashboard  (redirect to /admin/products)
GET  /admin/products           ProductAdmin@index   (?q=&category=&status=&sort=&page=)
GET  /admin/products/create    ProductAdmin@create
POST /admin/products/create    ProductAdmin@store
GET  /admin/products/edit      ProductAdmin@edit    (?id=)
POST /admin/products/edit      ProductAdmin@update  (?id=)
POST /admin/products/delete    ProductAdmin@destroy (?id=)

GET  /admin/categories         CategoryAdmin@index
POST /admin/categories         CategoryAdmin@store   (single-page list+add; edit via ?id=)
POST /admin/categories/delete  CategoryAdmin@destroy
```

No router upgrade needed: reuse `$_GET['id']`. If friendly URLs are wanted later, extend `Router` with `{id}` placeholders then.

## 6. File Changes

```
app/Core/Database.php        NEW — PDO singleton (reads DB_* from env/config.php), query helpers
app/Core/Auth.php            NEW — session start, login check, requireAdmin(), csrf_token()/verify()
app/Core/Validator.php       NEW — tiny required/numeric/length/unique helpers (or inline in controller for MVP)
app/Controllers/AuthController.php        NEW
app/Controllers/AdminController.php       NEW — dashboard
app/Controllers/ProductAdminController.php NEW — CRUD
app/Controllers/CategoryAdminController.php NEW — CRUD
app/Models/Product.php       EDIT — PDO-backed all()/find(), add create/update/delete, ACTIVE-only for storefront
app/Models/Category.php      NEW — all/find/create/update/delete
app/Models/Admin.php         NEW — findByEmail(), verify()
app/Views/layout/admin.php   NEW — admin chrome (no storefront header/footer)
app/Views/pages/admin/login.php, dashboard.php, products/index.php, products/form.php, categories/index.php  NEW
app/storage/ + assets/uploads/products/   NEW (gitignore sqlite + keep .gitkeep)
app/config.php               EDIT — DB dsn/creds, upload limits, session name
index.php                    EDIT — register admin routes
scripts/seed.php             NEW — migrate static catalogue + create admin (CLI only)
```

Keep `CollectionController` and `collection.php` view essentially untouched except category source + image path fallback (`image ?? image_url`).

## 7. Execution Plan (ordered, each step testable)

**Phase 0 — Prep (0.5 day)**
1. Backup static `Product.php` data as `scripts/legacy_products.php` for seeding.
2. Decide SQLite vs MySQL; add `DB_DSN/DB_USER/DB_PASS` to `config.php` via env with SQLite default.
3. Add `session_start()` + security headers bootstrap (in `index.php` or `config.php`).

**Phase 1 — Persistence (1 day)**
4. Create `Core/Database.php` (PDO, exceptions, prepared statements only).
5. Create migration SQL + `scripts/migrate.php`; run locally; verify tables.
6. Create `Models/Category.php`, `Models/Admin.php`; seed 4 categories.
7. Refactor `Models/Product.php` to PDO (keep method signatures; add `adminAll(filters)`, `create/update/delete`, storefront `all()` filters `status='active'`). Verify `/collection` renders identical output from DB.

**Phase 2 — Auth + Admin Shell (1 day)**
8. `Core/Auth.php` (session, `requireAdmin()`, CSRF helpers) + `AuthController` (login/logout, rate-limit: 5 fails → 5-min lockout, generic error message).
9. `Views/layout/admin.php` + `AdminController@dashboard` with counts. Guard all `/admin/*` (except login).
10. Manual test: unauthenticated `/admin/products` → redirect to login; login → dashboard.

**Phase 3 — Product CRUD (2 days)**
11. List: search/filter/sort/pagination (`ProductAdmin@index` + `products/index.php`).
12. Create/update with validation: required `name, category_id, price>=0`; lengths; unique `slug/sku`; image upload checks; CSRF; sticky form + error display.
13. Delete: confirm modal, soft-archive default, file cleanup on hard delete.
14. Image pipeline: `enctype=multipart`, MIME allowlist, 2MB limit, resize (GD), random filename, old-file unlink on replace. Fallback: if no upload, accept `image_url`.

**Phase 4 — Categories (0.5 day)**
15. `CategoryAdmin` list + inline add/edit, slug auto-gen, block delete when products exist (show count + reassign hint).

**Phase 5 — Storefront Cutover + Polish (0.5–1 day)**
16. Point `CollectionController` at DB data; image helper prefers local `image`, falls back to `image_url`; category filter uses slugs.
17. Empty states, flash messages (`?saved=1` toast or session flash), mobile check of admin tables, `e()` escaping audit on new views.
18. Seed script for prod + README section (migrate + create-admin commands).

**Phase 6 — Hardening + QA (0.5 day)**
19. Security pass: prepared statements, `htmlspecialchars` via `e()`, upload dir `.htaccess` (no PHP execution), CSRF on every POST, session regenerate on login.
20. QA checklist (§9) + fix pass. Tag release.

Estimated: **~5–6 dev-days** solo for MVP (auth + product/category CRUD + DB cutover).

## 8. Validation Rules (admin forms)
- `name`: required, 3–160 chars. `slug`: auto from name, unique.
- `category_id`: required, must exist + active.
- `price`: required, numeric, 0–9999.99. `stock_qty`: int ≥ 0.
- `size/scent/tag`: optional, max 120/160/60. `description`: optional, max ~2000, strip tags (allowlist `<p><br><ul><li><strong><em>` if rich text later).
- `status`: in `active|draft|archived`. `sku`: optional, unique, `A-Z0-9-` max 60.
- Image: optional on edit, required on create (unless `image_url` given); jpg/jpeg/png/webp, ≤2MB, min 400px wide.

## 9. Acceptance / QA Checklist
- [ ] Cannot reach any `/admin/*` page without login.
- [ ] Login with wrong password shows generic error, no user enumeration.
- [ ] Create product with image → appears on `/collection` (active) with correct card data.
- [ ] Draft/archived product hidden from `/collection`, visible in admin with badge.
- [ ] Edit price/name/image → storefront reflects change; old image file removed.
- [ ] Delete (archive) → hidden from storefront; category filter counts update.
- [ ] Search/filter/sort/pagination work; XSS payload in name renders escaped.
- [ ] Upload `.php` disguised as image rejected; oversized file rejected with message.
- [ ] CSRF: POST without token rejected.
- [ ] No PHP errors with `display_errors` off; 404 for unknown `?id=`.

## 10. Future (post-MVP)
Cart → checkout → orders table; multi-image gallery; CSV import/export; audit log; rich-text editor; SEO slugs (`/shop/<slug>`); review moderation replacing static ratings; refill/subscription logic matching the "Refills for life" band.
