diff --git a/src/Catalog.php b/src/Catalog.php new file mode 100644 index 0000000..a57c20e --- /dev/null +++ b/src/Catalog.php @@ -0,0 +1,481 @@ + $fields + */ + public function saveItem(string $sku, array $fields, string $now): void + { + $sku = trim($sku); + if ($sku === '') { + throw new \InvalidArgumentException('A SKU code is required.'); + } + + $existing = $this->getItem($sku); + + // Build the row from the allow-list only, defaulting to the existing row + // (update) or the column defaults (create). + $name = $this->name($fields, $existing); + $price = $this->price($fields, $existing); + $unit = $this->optStr($fields, 'unit', $existing, 32); + $description = $this->description($fields, $existing); + $imageId = $this->optId($fields, 'image_media_id', $existing); + $categoryId = $this->categoryRef($fields, $existing); + $active = $this->flag($fields, 'active', $existing, true); + $featured = $this->flag($fields, 'featured', $existing, false); + + $this->storage()->execute( + 'INSERT INTO ' . Schema::ITEM . ' + (sku_code, name, price, unit, description, image_media_id, category_id, active, featured, created_at, updated_at) + VALUES (:sku, :name, :price, :unit, :description, :image, :category, :active, :featured, :created, :updated) + ON DUPLICATE KEY UPDATE + name = :name2, price = :price2, unit = :unit2, description = :description2, + image_media_id = :image2, category_id = :category2, active = :active2, + featured = :featured2, updated_at = :updated2', + [ + 'sku' => $sku, 'name' => $name, 'price' => $price, 'unit' => $unit, + 'description' => $description, 'image' => $imageId, 'category' => $categoryId, + 'active' => $active, 'featured' => $featured, 'created' => $now, 'updated' => $now, + 'name2' => $name, 'price2' => $price, 'unit2' => $unit, 'description2' => $description, + 'image2' => $imageId, 'category2' => $categoryId, 'active2' => $active, + 'featured2' => $featured, 'updated2' => $now, + ], + ); + } + + /** + * One item by SKU, or null. Values are returned raw (unescaped) — the render + * layer escapes; this is the source of truth, not a view. + * + * @return array{sku_code:string,name:string,price:string,unit:?string,description:?string,image_media_id:?int,category_id:?int,active:bool,featured:bool,created_at:string,updated_at:string}|null + */ + public function getItem(string $sku): ?array + { + $row = $this->storage()->selectOne( + 'SELECT sku_code, name, price, unit, description, image_media_id, category_id, active, featured, created_at, updated_at + FROM ' . Schema::ITEM . ' WHERE sku_code = :sku', + ['sku' => trim($sku)], + ); + return $row === null ? null : $this->hydrateItem($row); + } + + /** + * Items for the admin list, most-recent first, optionally filtered to those + * whose SKU or name contains `$q` (bound LIKE — no string-built SQL). + * + * @return list + */ + public function allItems(?string $q = null): array + { + $q = $q !== null ? trim($q) : ''; + $where = $q === '' ? '' : ' WHERE sku_code LIKE :q OR name LIKE :q2'; + $params = $q === '' ? [] : ['q' => '%' . $q . '%', 'q2' => '%' . $q . '%']; + + $rows = $this->storage()->select( + 'SELECT sku_code, name, price, unit, description, image_media_id, category_id, active, featured, created_at, updated_at + FROM ' . Schema::ITEM . $where . ' ORDER BY updated_at DESC, sku_code', + $params, + ); + return array_map($this->hydrateItem(...), $rows); + } + + /** Delete an item by SKU; returns the number of rows removed (0 if none). */ + public function deleteItem(string $sku): int + { + return $this->storage()->execute( + 'DELETE FROM ' . Schema::ITEM . ' WHERE sku_code = :sku', + ['sku' => trim($sku)], + ); + } + + // --- categories ------------------------------------------------------ + + /** + * Create (id null) or rename/reparent (id given) a category. `parentId` must + * reference an existing top-level category and may not be the category itself; + * a category that is a parent of others cannot be given a parent (two levels). + * + * @return int the category id + */ + public function saveCategory(?int $id, string $name, ?int $parentId, string $now): int + { + $name = trim($name); + if ($name === '') { + throw new \InvalidArgumentException('A category name is required.'); + } + if (mb_strlen($name) > 120) { + throw new \InvalidArgumentException('A category name must be 120 characters or fewer.'); + } + + $parentId = $this->validParent($id, $parentId); + $slug = $this->uniqueSlug($this->slugify($name), $id); + + if ($id === null) { + return $this->storage()->insert( + 'INSERT INTO ' . Schema::CATEGORY . ' (name, slug, parent_id, created_at, updated_at) + VALUES (:name, :slug, :parent, :created, :updated)', + ['name' => $name, 'slug' => $slug, 'parent' => $parentId, 'created' => $now, 'updated' => $now], + ); + } + + $affected = $this->storage()->execute( + 'UPDATE ' . Schema::CATEGORY . ' SET name = :name, slug = :slug, parent_id = :parent, updated_at = :now WHERE id = :id', + ['name' => $name, 'slug' => $slug, 'parent' => $parentId, 'now' => $now, 'id' => $id], + ); + if ($affected === 0 && $this->getCategory($id) === null) { + throw new \InvalidArgumentException("No category with id {$id}."); + } + return $id; + } + + /** + * @return array{id:int,name:string,slug:string,parent_id:?int,created_at:string,updated_at:string}|null + */ + public function getCategory(int $id): ?array + { + $row = $this->storage()->selectOne( + 'SELECT id, name, slug, parent_id, created_at, updated_at FROM ' . Schema::CATEGORY . ' WHERE id = :id', + ['id' => $id], + ); + return $row === null ? null : $this->hydrateCategory($row); + } + + /** + * Every category, ordered as a two-level tree (each top-level followed by its + * children), for the admin list and the (later) storefront nav. + * + * @return list + */ + public function allCategories(): array + { + $rows = array_map( + $this->hydrateCategory(...), + $this->storage()->select('SELECT id, name, slug, parent_id, created_at, updated_at FROM ' . Schema::CATEGORY . ' ORDER BY name'), + ); + + $tops = array_values(array_filter($rows, static fn (array $c): bool => $c['parent_id'] === null)); + $children = []; + foreach ($rows as $c) { + if ($c['parent_id'] !== null) { + $children[$c['parent_id']][] = $c; + } + } + + $ordered = []; + foreach ($tops as $top) { + $ordered[] = $top; + foreach ($children[$top['id']] ?? [] as $child) { + $ordered[] = $child; + } + } + return $ordered; + } + + /** + * Delete a category. Blocked ({@see CategoryInUse}) while a child category or + * any item still references it — the caller reparents/reassigns first. + */ + public function deleteCategory(int $id): void + { + $s = $this->storage(); + $childCount = $s->selectOne('SELECT COUNT(*) AS n FROM ' . Schema::CATEGORY . ' WHERE parent_id = :id', ['id' => $id]); + if ($childCount !== null && (int) $childCount['n'] > 0) { + throw new CategoryInUse($id, 'child categories'); + } + $itemCount = $s->selectOne('SELECT COUNT(*) AS n FROM ' . Schema::ITEM . ' WHERE category_id = :id', ['id' => $id]); + if ($itemCount !== null && (int) $itemCount['n'] > 0) { + throw new CategoryInUse($id, 'items'); + } + $s->execute('DELETE FROM ' . Schema::CATEGORY . ' WHERE id = :id', ['id' => $id]); + } + + // --- validation / hydration ----------------------------------------- + + /** + * @param array $fields + * @param array|null $existing + */ + private function name(array $fields, ?array $existing): string + { + if (!array_key_exists('name', $fields)) { + if ($existing !== null) { + return (string) $existing['name']; + } + throw new \InvalidArgumentException('An item name is required.'); + } + $name = trim((string) $fields['name']); + if ($name === '') { + throw new \InvalidArgumentException('An item name is required.'); + } + if (mb_strlen($name) > 200) { + throw new \InvalidArgumentException('An item name must be 200 characters or fewer.'); + } + return $name; + } + + /** + * A non-negative decimal with up to 2 places, validated as a string (no float, + * no bcmath) exactly like the ledger validates quantities. + * + * @param array $fields + * @param array|null $existing + */ + private function price(array $fields, ?array $existing): string + { + if (!array_key_exists('price', $fields)) { + return $existing !== null ? (string) $existing['price'] : '0.00'; + } + $price = trim((string) $fields['price']); + if ($price === '') { + return '0.00'; + } + if (preg_match('/^\d+(\.\d{1,2})?$/', $price) !== 1) { + throw new \InvalidArgumentException("\"{$price}\" is not a valid price (a non-negative amount with up to 2 decimal places)."); + } + return $price; + } + + /** + * @param array $fields + * @param array|null $existing + */ + private function description(array $fields, ?array $existing): ?string + { + if (!array_key_exists('description', $fields)) { + return $existing !== null ? ($existing['description'] === null ? null : (string) $existing['description']) : null; + } + // Plain text v1 (no HTML): stored raw and byte-exact — the render layer + // escapes. Capped to a sane length so a write can't be used to bloat a row. + $desc = (string) $fields['description']; + if (mb_strlen($desc) > 5000) { + throw new \InvalidArgumentException('A description must be 5000 characters or fewer.'); + } + $desc = trim($desc); + return $desc === '' ? null : $desc; + } + + /** + * @param array $fields + * @param array|null $existing + */ + private function optStr(array $fields, string $key, ?array $existing, int $max): ?string + { + if (!array_key_exists($key, $fields)) { + return $existing !== null ? ($existing[$key] === null ? null : (string) $existing[$key]) : null; + } + $v = trim((string) $fields[$key]); + if ($v === '') { + return null; + } + if (mb_strlen($v) > $max) { + throw new \InvalidArgumentException("\"{$key}\" must be {$max} characters or fewer."); + } + return $v; + } + + /** + * A nullable positive id (image media ref). Empty/absent → null; a + * non-positive or non-numeric value is rejected rather than silently stored. + * + * @param array $fields + * @param array|null $existing + */ + private function optId(array $fields, string $key, ?array $existing): ?int + { + if (!array_key_exists($key, $fields)) { + return $existing !== null ? ($existing[$key] === null ? null : (int) $existing[$key]) : null; + } + $raw = trim((string) $fields[$key]); + if ($raw === '') { + return null; + } + if (preg_match('/^\d+$/', $raw) !== 1 || (int) $raw < 1) { + throw new \InvalidArgumentException("\"{$key}\" must be a positive whole number or blank."); + } + return (int) $raw; + } + + /** + * The category a new/updated item points at: null, or an id that must exist in + * this install (a soft ref, checked at write so a dangling id can't be stored). + * + * @param array $fields + * @param array|null $existing + */ + private function categoryRef(array $fields, ?array $existing): ?int + { + $id = $this->optId($fields, 'category_id', $existing); + if ($id !== null && $this->getCategory($id) === null) { + throw new \InvalidArgumentException("No category with id {$id}."); + } + return $id; + } + + /** + * @param array $fields + * @param array|null $existing + */ + private function flag(array $fields, string $key, ?array $existing, bool $default): int + { + if (!array_key_exists($key, $fields)) { + if ($existing !== null) { + return (bool) $existing[$key] ? 1 : 0; + } + return $default ? 1 : 0; + } + return $this->truthy($fields[$key]) ? 1 : 0; + } + + private function truthy(mixed $v): bool + { + if (is_bool($v)) { + return $v; + } + if (is_int($v)) { + return $v === 1; + } + if (is_string($v)) { + return in_array(strtolower(trim($v)), ['1', 'true', 'on', 'yes'], true); + } + return false; + } + + /** + * Resolve and validate a proposed parent for category `$id` (null on create): + * it must exist, be top-level, and not be the category itself. + */ + private function validParent(?int $id, ?int $parentId): ?int + { + if ($parentId === null) { + return null; + } + if ($id !== null && $parentId === $id) { + throw new \InvalidArgumentException('A category cannot be its own parent.'); + } + $parent = $this->getCategory($parentId); + if ($parent === null) { + throw new \InvalidArgumentException("No category with id {$parentId} to be a parent."); + } + if ($parent['parent_id'] !== null) { + throw new \InvalidArgumentException('Categories are only two levels deep — the chosen parent is already a child.'); + } + // A category that already has children can't itself become a child. + if ($id !== null) { + $hasChildren = $this->storage()->selectOne('SELECT COUNT(*) AS n FROM ' . Schema::CATEGORY . ' WHERE parent_id = :id', ['id' => $id]); + if ($hasChildren !== null && (int) $hasChildren['n'] > 0) { + throw new \InvalidArgumentException('This category has children, so it must stay top-level.'); + } + } + return $parentId; + } + + /** Lowercase, allow-list to `[a-z0-9-]`, collapse and trim dashes. */ + private function slugify(string $name): string + { + $slug = strtolower($name); + $slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? ''; + $slug = trim($slug, '-'); + return $slug === '' ? 'category' : $slug; + } + + /** Ensure the slug is unique (excluding this category), suffixing -2, -3, … */ + private function uniqueSlug(string $base, ?int $excludeId): string + { + $slug = $base; + $n = 1; + while (true) { + $row = $this->storage()->selectOne( + 'SELECT id FROM ' . Schema::CATEGORY . ' WHERE slug = :slug' . ($excludeId !== null ? ' AND id <> :id' : ''), + $excludeId !== null ? ['slug' => $slug, 'id' => $excludeId] : ['slug' => $slug], + ); + if ($row === null) { + return $slug; + } + $n++; + $slug = $base . '-' . $n; + } + } + + /** + * @param array $row + * @return array{sku_code:string,name:string,price:string,unit:?string,description:?string,image_media_id:?int,category_id:?int,active:bool,featured:bool,created_at:string,updated_at:string} + */ + private function hydrateItem(array $row): array + { + return [ + 'sku_code' => (string) $row['sku_code'], + 'name' => (string) $row['name'], + 'price' => (string) $row['price'], + 'unit' => $row['unit'] === null ? null : (string) $row['unit'], + 'description' => $row['description'] === null ? null : (string) $row['description'], + 'image_media_id' => $row['image_media_id'] === null ? null : (int) $row['image_media_id'], + 'category_id' => $row['category_id'] === null ? null : (int) $row['category_id'], + 'active' => (bool) $row['active'], + 'featured' => (bool) $row['featured'], + 'created_at' => (string) $row['created_at'], + 'updated_at' => (string) $row['updated_at'], + ]; + } + + /** + * @param array $row + * @return array{id:int,name:string,slug:string,parent_id:?int,created_at:string,updated_at:string} + */ + private function hydrateCategory(array $row): array + { + return [ + 'id' => (int) $row['id'], + 'name' => (string) $row['name'], + 'slug' => (string) $row['slug'], + 'parent_id' => $row['parent_id'] === null ? null : (int) $row['parent_id'], + 'created_at' => (string) $row['created_at'], + 'updated_at' => (string) $row['updated_at'], + ]; + } + + private function storage(): PluginStorage + { + return ($this->storage)(); + } +} diff --git a/src/CatalogAdmin.php b/src/CatalogAdmin.php new file mode 100644 index 0000000..4ab1b08 --- /dev/null +++ b/src/CatalogAdmin.php @@ -0,0 +1,257 @@ +` block because the admin CSP is nonce-only for `style-src`. + */ +final class CatalogAdmin +{ + private const NOTICES = [ + 'item-saved' => ['ok', 'Item saved.'], + 'item-deleted' => ['ok', 'Item deleted.'], + 'cat-saved' => ['ok', 'Category saved.'], + 'cat-deleted' => ['ok', 'Category deleted.'], + 'cat-inuse' => ['err', 'That category is still in use — reassign its items or child categories first.'], + 'badprice' => ['err', 'Enter a valid price — a non-negative amount with up to 2 decimal places.'], + 'badcat' => ['err', 'Check the category details and try again.'], + 'invalid' => ['err', 'Check the details and try again.'], + ]; + + public function __construct(private Catalog $catalog) + { + } + + /** + * @param string $csrf CSRF token for the forms + * @param ?string $notice a fixed notice code from the ?ok=/?err= redirect + * @param ?string $edit a SKU to load into the item form (from ?edit=) + * @param ?string $editCat a category id to load into the category form (from ?editcat=) + * @param string $nonce the request CSP nonce + */ + public function render(string $csrf = '', ?string $notice = null, ?string $edit = null, ?string $editCat = null, string $nonce = ''): string + { + $categories = $this->catalog->allCategories(); + $catName = []; + foreach ($categories as $c) { + $catName[$c['id']] = $c['name']; + } + + $editItem = ($edit !== null && trim($edit) !== '') ? $this->catalog->getItem(trim($edit)) : null; + $editCatId = ($editCat !== null && preg_match('/^\d+$/', trim($editCat)) === 1) ? (int) trim($editCat) : null; + $editCategory = $editCatId !== null ? $this->catalog->getCategory($editCatId) : null; + + $html = $this->styles($nonce) + . '

Catalog

' . $this->notice($notice) + . '

The sellable item behind each SKU — name, price, category and image — the source of truth a storefront reads. ' + . 'The same records an agent manages over the inventory_item_* tools.

'; + + // Item editor + list. + $html .= '

' . ($editItem !== null ? 'Edit item' : 'Add an item') . '

'; + $html .= $this->itemForm($csrf, $categories, $editItem); + $html .= '

Items

' . $this->itemList($catName); + + // Category editor + list. + $html .= '

' . ($editCategory !== null ? 'Edit category' : 'Add a category') . '

'; + $html .= $this->categoryForm($csrf, $categories, $editCategory); + $html .= '

Categories

' . $this->categoryList($csrf, $categories, $catName); + + return $html; + } + + /** + * @param list $categories + * @param array{sku_code:string,name:string,price:string,unit:?string,description:?string,image_media_id:?int,category_id:?int,active:bool,featured:bool,created_at:string,updated_at:string}|null $item + */ + private function itemForm(string $csrf, array $categories, ?array $item): string + { + // Null-safe locals up front, so the markup never indexes a possibly-null + // $item (a create renders blank fields; an edit renders the stored values). + $editing = $item !== null; + $e = fn (string $s): string => $this->e($s); + $sku = $editing ? $item['sku_code'] : ''; + $name = $editing ? $item['name'] : ''; + $price = $editing ? $item['price'] : ''; + $unit = ($editing && $item['unit'] !== null) ? $item['unit'] : ''; + $desc = ($editing && $item['description'] !== null) ? $item['description'] : ''; + $image = ($editing && $item['image_media_id'] !== null) ? (string) $item['image_media_id'] : ''; + $catId = $editing ? $item['category_id'] : null; + $active = !$editing || $item['active']; + $featured = $editing && $item['featured']; + + $skuField = $editing + ? '' + . '

' . $e($sku) . '

' + : '
' + . '
'; + + return '
' + . '' + . $skuField + . '
' + . '
' + . '
' + . '
' + . '
' + . '
' + . '
' + . '
' + . '
' + . $this->categorySelect('category_id', $categories, $catId, true) + . '
' + . '
' + . '
' + . '
' + . '
' + . '
' + . '' + . '' + . '
' + . '
' + . ($editing ? ' Cancel' : '') + . '
' + . ($editing + ? '
' + . '' + . '' + . '
' + : ''); + } + + /** @param array $catName category id → name, for display */ + private function itemList(array $catName): string + { + $items = $this->catalog->allItems(); + if ($items === []) { + return '

No items yet. Add one above, or use the inventory_item_set tool.

'; + } + + $html = '
' + . ''; + foreach ($items as $it) { + $cat = $it['category_id'] !== null ? ($catName[$it['category_id']] ?? '#' . $it['category_id']) : '—'; + $badges = ($it['active'] ? 'Active' : 'Hidden') + . ($it['featured'] ? ' Featured' : ''); + $html .= '' + . '' + . '' + . '' + . '' + . ''; + } + return $html . '
SKUNamePriceCategoryStatus
' . $this->e($it['sku_code']) . '' . $this->e($it['name']) . '' . $this->e($it['price']) . ($it['unit'] !== null ? ' / ' . $this->e($it['unit']) . '' : '') . '' . $this->e($cat) . '' . $badges . 'Edit
'; + } + + /** + * @param list $categories + * @param array{id:int,name:string,slug:string,parent_id:?int,created_at:string,updated_at:string}|null $cat + */ + private function categoryForm(string $csrf, array $categories, ?array $cat): string + { + // A category being edited can't be its own parent; only top-level + // categories (other than this one) can be parents. + $parents = array_values(array_filter( + $categories, + static fn (array $c): bool => $c['parent_id'] === null && ($cat === null || $c['id'] !== $cat['id']), + )); + + return '
' + . '' + . ($cat !== null ? '' : '') + . '
' + . '
' + . '
' + . $this->categorySelect('parent_id', $parents, $cat['parent_id'] ?? null, true, 'cx-catparent') + . '

Leave as “Top level” for a main category. Categories are two levels deep.

' + . '
' + . ($cat !== null ? ' Cancel' : '') + . '
'; + } + + /** + * @param list $categories + * @param array $catName + */ + private function categoryList(string $csrf, array $categories, array $catName): string + { + if ($categories === []) { + return '

No categories yet.

'; + } + $html = '
' + . ''; + foreach ($categories as $c) { + $label = $c['parent_id'] !== null + ? ' ' . $this->e($c['name']) . ' in ' . $this->e($catName[$c['parent_id']] ?? '#' . $c['parent_id']) . '' + : '' . $this->e($c['name']) . ''; + $html .= '' + . '' + . ''; + } + return $html . '
NameSlug
' . $label . '' . $this->e($c['slug']) . 'Edit ' + . '
' + . '' + . '' + . '
'; + } + + /** + * A category `'; + if ($allowNone) { + $out .= ''; + } + foreach ($categories as $c) { + $prefix = $c['parent_id'] !== null ? '— ' : ''; + $sel = ($selected !== null && $selected === $c['id']) ? ' selected' : ''; + $out .= ''; + } + return $out . ''; + } + + private function styles(string $nonce): string + { + $css = '.cx-intro{margin:-8px 0 20px}' + . '.cx-mt15{margin-top:1.5rem}.cx-mt2{margin-top:2rem}' + . '.cx-r{text-align:right}' + . '.cx-form{max-width:520px;margin-bottom:1.5rem}' + . '.cx-row{display:flex;gap:1rem;flex-wrap:wrap}.cx-half{flex:1 1 160px}' + . '.cx-checks{display:flex;gap:1.25rem;flex-wrap:wrap;margin:.25rem 0 1rem}' + . '.cx-check{display:flex;align-items:center;gap:.4rem;font-weight:500}' + . '.cx-actions{display:flex;gap:.5rem;flex-wrap:wrap;align-items:center}' + . '.cx-fixed{margin:.2rem 0 0}.cx-hint{margin:.3rem 0 0;font-size:.85rem}' + . '.cx-delete{margin-top:-.75rem;margin-bottom:1.5rem}' + . '.cx-inline{display:inline}' + . '.cx-child{opacity:.6}.cx-parent{font-size:.85rem}'; + return ''; + } + + private function notice(?string $notice): string + { + if ($notice === null || !isset(self::NOTICES[$notice])) { + return ''; + } + [$kind, $msg] = self::NOTICES[$notice]; + return '
' . $this->e($msg) . '
'; + } + + public function e(string $v): string + { + return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/src/CategoryInUse.php b/src/CategoryInUse.php new file mode 100644 index 0000000..17bdd19 --- /dev/null +++ b/src/CategoryInUse.php @@ -0,0 +1,18 @@ +migrations()->register('001_ledger', Schema::all()); $context->migrations()->register('002_reservations', Schema::reservations()); + $context->migrations()->register('003_items', Schema::items()); // Grantable, wildcard-immune: nimbuscms.inventory:read / :write. Moving // stock is moving money — a content *:write token can never reach it. @@ -45,8 +49,9 @@ public function register(PluginContext $context): void }; $ledger = new Ledger($storage, $emit); $reservations = new Reservations($storage, $ledger); + $catalog = new Catalog($storage); - $context->mcp()->register(new InventoryToolset($ledger, $reservations)); + $context->mcp()->register(new InventoryToolset($ledger, $reservations, $catalog)); // Publish the reservation contract so Commerce (or any plugin) can reserve // stock synchronously without touching Inventory's tables (ADR 0019). @@ -147,6 +152,79 @@ public function register(PluginContext $context): void } }); + // Catalog admin (ADR 0022): manage the sellable item behind each SKU and the + // category taxonomy. Its own page so merchandising stays separate from the + // stock workbench; same `nimbuscms.inventory:write` gate + CSRF as the ledger. + $context->adminPages()->register( + 'catalog', + 'Catalog', + '📇', + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new CatalogAdmin($catalog))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('editcat'), $nonce), + self::ID . ':write', + ); + $context->adminPages()->action('catalog', 'item-save', static function (Request $r) use ($catalog): Response { + $sku = trim((string) ($r->input('sku') ?? '')); + if ($sku === '') { + return Response::redirect('/admin/catalog?err=invalid'); + } + // Build the field set from a known allow-list only — never the raw + // request — so no unexpected key (a forged flag/timestamp) is assigned. + $fields = [ + 'name' => (string) ($r->input('name') ?? ''), + 'price' => (string) ($r->input('price') ?? ''), + 'unit' => (string) ($r->input('unit') ?? ''), + 'description' => (string) ($r->input('description') ?? ''), + 'image_media_id' => (string) ($r->input('image_media_id') ?? ''), + 'category_id' => (string) ($r->input('category_id') ?? ''), + 'active' => $r->input('active') !== null, + 'featured' => $r->input('featured') !== null, + ]; + try { + $catalog->saveItem($sku, $fields, date('Y-m-d H:i:s')); + return Response::redirect('/admin/catalog?ok=item-saved'); + } catch (\InvalidArgumentException $e) { + return Response::redirect('/admin/catalog?err=' . (str_contains($e->getMessage(), 'price') ? 'badprice' : 'invalid')); + } catch (\Throwable) { + return Response::redirect('/admin/catalog?err=invalid'); + } + }); + $context->adminPages()->action('catalog', 'item-delete', static function (Request $r) use ($catalog): Response { + $sku = trim((string) ($r->input('sku') ?? '')); + if ($sku !== '') { + $catalog->deleteItem($sku); + } + return Response::redirect('/admin/catalog?ok=item-deleted'); + }); + $context->adminPages()->action('catalog', 'category-save', static function (Request $r) use ($catalog): Response { + $name = trim((string) ($r->input('name') ?? '')); + $idIn = trim((string) ($r->input('id') ?? '')); + $parIn = trim((string) ($r->input('parent_id') ?? '')); + $id = ($idIn !== '' && ctype_digit($idIn)) ? (int) $idIn : null; + $parent = ($parIn !== '' && ctype_digit($parIn)) ? (int) $parIn : null; + try { + $catalog->saveCategory($id, $name, $parent, date('Y-m-d H:i:s')); + return Response::redirect('/admin/catalog?ok=cat-saved'); + } catch (\InvalidArgumentException) { + return Response::redirect('/admin/catalog?err=badcat'); + } catch (\Throwable) { + return Response::redirect('/admin/catalog?err=invalid'); + } + }); + $context->adminPages()->action('catalog', 'category-delete', static function (Request $r) use ($catalog): Response { + $idIn = trim((string) ($r->input('id') ?? '')); + if ($idIn === '' || !ctype_digit($idIn)) { + return Response::redirect('/admin/catalog?err=invalid'); + } + try { + $catalog->deleteCategory((int) $idIn); + return Response::redirect('/admin/catalog?ok=cat-deleted'); + } catch (CategoryInUse) { + return Response::redirect('/admin/catalog?err=cat-inuse'); + } catch (\Throwable) { + return Response::redirect('/admin/catalog?err=invalid'); + } + }); + // Teach any MCP agent how to drive the ledger (ADR 0013). $context->skills()->register('Inventory', Guide::text()); } diff --git a/src/InventoryToolset.php b/src/InventoryToolset.php index 6f8a5b7..b20b7fa 100644 --- a/src/InventoryToolset.php +++ b/src/InventoryToolset.php @@ -12,11 +12,15 @@ /** * Inventory over MCP — the agent-driven onboarding the initiative is built around. * - * Six tools under the `inventory` namespace: four writes (receive, adjust, count, - * transfer) and two reads (stock, movements). The {@see PluginToolset} base gates + * Tools under the `inventory` namespace: the ledger writes (receive, adjust, + * count, transfer) and reservation writes (reserve, release, issue); the reads + * (stock, movements); and the **item master** (ADR 0022) — `item_set`/`item_get` + * and `category_set`/`category_get`/`categories`, so an agent can manage the + * sellable catalog, not just the numbers. The {@see PluginToolset} base gates * every one on the plugin's own `nimbuscms.inventory` capability (ADR 0015/0016) — * a write tool needs `:write`, unreachable by a content token — so this class - * writes no authorization code. + * writes no authorization code. (Deletes stay admin-only in Slice 1 — a recorded + * deferral of an MCP delete tool.) * * Two things the handlers guarantee, per the security review: * - **`actor` and `occurred_at` are server-set** — the token's name and the @@ -26,7 +30,7 @@ */ final class InventoryToolset extends PluginToolset { - public function __construct(private Ledger $ledger, private Reservations $reservations) + public function __construct(private Ledger $ledger, private Reservations $reservations, private Catalog $catalog) { } @@ -97,6 +101,49 @@ protected function tools(): array 'required' => ['sku'], 'properties' => ['sku' => $sku, 'limit' => ['type' => 'integer', 'description' => 'Max rows (1–500, default 50).']], ], $this->movements(...)), + + new PluginTool('item_set', 'write', 'Create or update a SKU\'s sellable item record (name, price, category, …). Only the fields you send change; the rest keep their stored value.', [ + 'type' => 'object', + 'required' => ['sku', 'name'], + 'properties' => [ + 'sku' => $sku, + 'name' => ['type' => 'string', 'description' => 'Display name of the item.'], + 'price' => ['type' => 'string', 'description' => 'A non-negative amount, up to 2 decimals (e.g. "3.49"). Defaults to 0.'], + 'unit' => ['type' => 'string', 'description' => 'Selling unit (e.g. "each", "kg", "500g pack"). Optional.'], + 'description' => ['type' => 'string', 'description' => 'Plain-text description (no HTML). Optional.'], + 'image_media_id' => ['type' => 'integer', 'description' => 'A media-library id for the item image. Optional.'], + 'category_id' => ['type' => 'integer', 'description' => 'An existing category id. Optional.'], + 'active' => ['type' => 'boolean', 'description' => 'Whether the item is sellable/visible (default true).'], + 'featured' => ['type' => 'boolean', 'description' => 'Whether to feature the item (default false).'], + ], + ], $this->itemSet(...)), + + new PluginTool('item_get', 'read', 'The sellable item record for a SKU (name, price, category, flags), or none.', [ + 'type' => 'object', + 'required' => ['sku'], + 'properties' => ['sku' => $sku], + ], $this->itemGet(...)), + + new PluginTool('category_set', 'write', 'Create a category (omit id) or rename/reparent one (with id). Two levels only.', [ + 'type' => 'object', + 'required' => ['name'], + 'properties' => [ + 'id' => ['type' => 'integer', 'description' => 'Existing category id to update; omit to create.'], + 'name' => ['type' => 'string', 'description' => 'Category name.'], + 'parent_id' => ['type' => 'integer', 'description' => 'An existing top-level category id to nest under. Omit for a top-level category.'], + ], + ], $this->categorySet(...)), + + new PluginTool('category_get', 'read', 'One category by id.', [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => ['id' => ['type' => 'integer', 'description' => 'The category id.']], + ], $this->categoryGet(...)), + + new PluginTool('categories', 'read', 'All categories, ordered as a two-level tree.', [ + 'type' => 'object', + 'properties' => new \stdClass(), + ], $this->categories(...)), ]; } @@ -232,6 +279,67 @@ private function movements(array $a, TokenPrincipal $p, EntryOpContext $c): arra return ['sku' => $sku, 'movements' => $this->ledger->movementsFor($sku, $limit)]; } + /** + * @param array $a + * @return array + */ + private function itemSet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $sku = $this->str($a, 'sku'); + // saveItem reads only its own allow-listed keys from the arguments — + // an unknown key (or a forged sku_code/timestamp) is ignored. + $this->catalog->saveItem($sku, $a, $this->now()); + return ['ok' => true, 'item' => $this->catalog->getItem($sku)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function itemGet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $sku = $this->str($a, 'sku'); + return ['sku' => $sku, 'item' => $this->catalog->getItem($sku)]; + } + + /** + * @param array $a + * @return array + */ + private function categorySet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $id = $this->nullableInt($a, 'id'); + $parentId = $this->nullableInt($a, 'parent_id'); + $newId = $this->catalog->saveCategory($id, $this->str($a, 'name'), $parentId, $this->now()); + return ['ok' => true, 'category' => $this->catalog->getCategory($newId)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function categoryGet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $id = $this->nullableInt($a, 'id'); + if ($id === null) { + return ['ok' => false, 'error' => 'invalid', 'message' => '"id" is required.']; + } + return ['id' => $id, 'category' => $this->catalog->getCategory($id)]; + } + + /** + * @param array $a + * @return array + */ + private function categories(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return ['categories' => $this->catalog->allCategories()]; + } + // --- helpers --------------------------------------------------------- /** @@ -303,6 +411,22 @@ private function strOr(array $a, string $key, string $default): string return $v ?? $default; } + /** @param array $a */ + private function nullableInt(array $a, string $key): ?int + { + $v = $a[$key] ?? null; + if ($v === null || $v === '') { + return null; + } + if (is_int($v)) { + return $v; + } + if (is_string($v) && preg_match('/^\d+$/', trim($v)) === 1) { + return (int) trim($v); + } + throw new \InvalidArgumentException("\"{$key}\" must be a whole number."); + } + /** @param array $a */ private function nullableStr(array $a, string $key): ?string { diff --git a/src/Schema.php b/src/Schema.php index 93906d2..49c2737 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -27,8 +27,14 @@ * a new domain's reason must not be an Inventory migration. * 4. `lot_id` / `unit_id` are nullable from day one, so lots and serials slot in * later without re-folding the ledger. - * 5. Catalog (item/SKU) lives in *content* (collections), not here — the ledger - * references a SKU only by its opaque `sku_code`. + * 5. A SKU may **optionally** carry a sellable *item* record (name, price, + * category, …) in {@see ITEM} — the retail counterpart to a content catalog, + * for goods you stock and sell as-is (ADR 0022). It is additive: the ledger + * still references a SKU only by its opaque `sku_code`, a SKU can have stock + * with no item (and vice-versa), and a pure-ledger user's tables are + * untouched. This supersedes the plugin's original "catalog lives only in + * content" stance — content collections remain the catalog for *editorial* + * items (a menu, a showcase); the item master is for *operational* stock. */ final class Schema { @@ -36,6 +42,8 @@ final class Schema public const LOCATION = 'inventory_location'; public const STOCK = 'inventory_stock'; public const RESERVATION = 'inventory_reservation'; + public const ITEM = 'inventory_item'; + public const CATEGORY = 'inventory_category'; /** * The reservation overlay (Commerce slice 1). A soft hold on stock — @@ -62,6 +70,48 @@ public static function reservations(): array ]; } + /** + * The optional item master (ADR 0022): a SKU's sellable attributes, keyed by + * the same opaque `sku_code` the ledger uses — still **no** foreign key into + * core (`image_media_id` is a soft ref resolved defensively at render). A + * lightweight two-level category taxonomy backs storefront browsing; a child + * category (one whose `parent_id` is set) can never itself be a parent, which + * fixes the depth at two and makes cycles structurally impossible. + * + * @return list each statement individually idempotent (ADR 0005) + */ + public static function items(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS ' . self::CATEGORY . ' ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + slug VARCHAR(140) NOT NULL, + parent_id BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY uq_category_slug (slug), + INDEX idx_category_parent (parent_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + + 'CREATE TABLE IF NOT EXISTS ' . self::ITEM . ' ( + sku_code VARCHAR(80) NOT NULL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + price DECIMAL(18,2) NOT NULL DEFAULT 0, + unit VARCHAR(32) NULL, + description TEXT NULL, + image_media_id BIGINT UNSIGNED NULL, + category_id BIGINT UNSIGNED NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + featured TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_item_category (category_id), + INDEX idx_item_active (active) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + ]; + } + /** @return list each statement individually idempotent (ADR 0005) */ public static function all(): array { diff --git a/tests/CatalogAdminTest.php b/tests/CatalogAdminTest.php new file mode 100644 index 0000000..1500554 --- /dev/null +++ b/tests/CatalogAdminTest.php @@ -0,0 +1,111 @@ + getenv('TEST_DB_HOST') ?: 'db', + 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306), + 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test', + 'user' => getenv('TEST_DB_USER') ?: 'root', + 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', + ]); + foreach (Schema::items() as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::ITEM); + $db->execute('TRUNCATE ' . Schema::CATEGORY); + + $storage = new PluginStorage($db); + $this->catalog = new Catalog(static fn (): PluginStorage => $storage); + $this->admin = new CatalogAdmin($this->catalog); + } + + public function test_the_item_and_category_forms_are_present(): void + { + $html = $this->admin->render('tok'); + self::assertStringContainsString('action="/admin/catalog/item-save"', $html); + self::assertStringContainsString('action="/admin/catalog/category-save"', $html); + self::assertStringContainsString('name="_token" value="tok"', $html); + } + + public function test_an_items_values_are_escaped_on_render_not_live_markup(): void + { + // The store keeps author input raw (proven in CatalogTest); the page must + // escape it — this is the standing contract the storefront (Slice 2) shares. + $this->catalog->saveItem('xss', ['name' => '', 'price' => '1.00'], self::T); + $html = $this->admin->render('tok'); + + self::assertStringNotContainsString('', $html); + self::assertStringContainsString('<script>', $html); + } + + public function test_editing_an_item_prefills_the_form(): void + { + $this->catalog->saveItem('milk', ['name' => 'Whole Milk', 'price' => '1.20', 'unit' => 'litre'], self::T); + $html = $this->admin->render('tok', null, 'milk'); + + self::assertStringContainsString('value="Whole Milk"', $html); + self::assertStringContainsString('value="1.20"', $html); + self::assertStringContainsString('action="/admin/catalog/item-delete"', $html, 'editing offers a delete'); + } + + public function test_a_category_can_be_chosen_and_children_are_shown_nested(): void + { + $top = $this->catalog->saveCategory(null, 'Grocery', null, self::T); + $this->catalog->saveCategory(null, 'Fruit', $top, self::T); + $html = $this->admin->render('tok'); + + self::assertStringContainsString('