diff --git a/src/Catalog.php b/src/Catalog.php index a57c20e..4590014 100644 --- a/src/Catalog.php +++ b/src/Catalog.php @@ -129,6 +129,163 @@ public function deleteItem(string $sku): int ); } + // --- public storefront reads (ADR 0023) ------------------------------ + + /** At or below this available quantity, an in-stock item reads as "low". */ + private const LOW_STOCK_THRESHOLD = 5.0; + + /** The columns a `sort` value may map to — the ORDER BY allow-list (no binding possible). */ + private const SORT = [ + 'featured' => 'i.featured DESC, i.name ASC', + 'name' => 'i.name ASC', + 'price_asc' => 'i.price ASC, i.name ASC', + 'price_desc' => 'i.price DESC, i.name ASC', + ]; + + /** Items per storefront page. */ + private const PER_PAGE = 24; + + /** + * A public, paginated listing for the storefront (ADR 0023). **Active items + * only**, with **coarse** availability (in_stock/low/out — never a raw count), + * and a public-safe shape (no cost, on_hand, reserved, location, or the active + * flag). `sort` is an allow-list; `category` is a slug resolved to an id; + * `q` is a bound, escaped LIKE; `page` is a bounded int. + * + * @param array{category?:?string,q?:?string,sort?:?string,page?:int} $filters + * @return array{items:list,total:int,page:int,per_page:int,pages:int} + */ + public function publicList(array $filters): array + { + $s = $this->storage(); + + [$where, $params] = $this->publicWhere($filters); + $order = self::SORT[$filters['sort'] ?? ''] ?? self::SORT['featured']; + $page = max(1, (int) ($filters['page'] ?? 1)); + $offset = ($page - 1) * self::PER_PAGE; + + $countRow = $s->selectOne('SELECT COUNT(*) AS n FROM ' . Schema::ITEM . ' i' . $where, $params); + $total = $countRow === null ? 0 : (int) $countRow['n']; + + // LIMIT/OFFSET are bounded ints built here, never bound params (some MySQL + // setups reject bound LIMIT); every value in $params is bound. + $rows = $s->select( + 'SELECT i.sku_code, i.name, i.price, i.unit, i.description, i.image_media_id, i.category_id, i.featured, + c.name AS category, + COALESCE(st.on_hand, 0) - COALESCE(rv.reserved, 0) AS available + FROM ' . Schema::ITEM . ' i + LEFT JOIN ' . Schema::CATEGORY . ' c ON c.id = i.category_id + LEFT JOIN (SELECT sku_code, SUM(on_hand) AS on_hand FROM ' . Schema::STOCK . ' GROUP BY sku_code) st ON st.sku_code = i.sku_code + LEFT JOIN (SELECT sku_code, SUM(qty) AS reserved FROM ' . Schema::RESERVATION . ' GROUP BY sku_code) rv ON rv.sku_code = i.sku_code' + . $where . ' ORDER BY ' . $order . ' LIMIT ' . self::PER_PAGE . ' OFFSET ' . $offset, + $params, + ); + + return [ + 'items' => array_map($this->hydratePublic(...), $rows), + 'total' => $total, + 'page' => $page, + 'per_page' => self::PER_PAGE, + 'pages' => $total === 0 ? 0 : (int) ceil($total / self::PER_PAGE), + ]; + } + + /** + * One public item by SKU, or null when it is absent **or not active** — so an + * inactive/hidden SKU is indistinguishable from a missing one on the + * storefront (no leak, and the caller renders a uniform 404). + * + * @return array{sku_code:string,name:string,price:string,unit:?string,description:?string,image_media_id:?int,category_id:?int,category:?string,featured:bool,availability:string}|null + */ + public function publicGet(string $sku): ?array + { + $row = $this->storage()->selectOne( + 'SELECT i.sku_code, i.name, i.price, i.unit, i.description, i.image_media_id, i.category_id, i.featured, + c.name AS category, + COALESCE(st.on_hand, 0) - COALESCE(rv.reserved, 0) AS available + FROM ' . Schema::ITEM . ' i + LEFT JOIN ' . Schema::CATEGORY . ' c ON c.id = i.category_id + LEFT JOIN (SELECT sku_code, SUM(on_hand) AS on_hand FROM ' . Schema::STOCK . ' GROUP BY sku_code) st ON st.sku_code = i.sku_code + LEFT JOIN (SELECT sku_code, SUM(qty) AS reserved FROM ' . Schema::RESERVATION . ' GROUP BY sku_code) rv ON rv.sku_code = i.sku_code + WHERE i.sku_code = :sku AND i.active = 1', + ['sku' => trim($sku)], + ); + return $row === null ? null : $this->hydratePublic($row); + } + + /** + * The category tree for storefront navigation — public fields only. + * + * @return list + */ + public function publicCategories(): array + { + return array_map( + static fn (array $c): array => ['id' => $c['id'], 'name' => $c['name'], 'slug' => $c['slug'], 'parent_id' => $c['parent_id']], + $this->allCategories(), + ); + } + + /** + * The active-only WHERE for the public reads, with an optional category (slug → + * id) and an escaped, bound search over name/description. + * + * @param array{category?:?string,q?:?string,sort?:?string,page?:int} $filters + * @return array{0:string,1:array} + */ + private function publicWhere(array $filters): array + { + $where = ' WHERE i.active = 1'; + $params = []; + + $categorySlug = isset($filters['category']) ? trim((string) $filters['category']) : ''; + if ($categorySlug !== '') { + $cat = $this->storage()->selectOne('SELECT id FROM ' . Schema::CATEGORY . ' WHERE slug = :slug', ['slug' => $categorySlug]); + // An unknown category yields an id that matches nothing (empty result), + // never an unfiltered listing. + $where .= ' AND i.category_id = :cat'; + $params['cat'] = $cat === null ? 0 : (int) $cat['id']; + } + + $q = isset($filters['q']) ? trim((string) $filters['q']) : ''; + if ($q !== '') { + $q = mb_substr($q, 0, 100); + // Escape LIKE wildcards so a `%`/`_` in the term is a literal, not a + // match-all; the term itself is bound. + $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q) . '%'; + $where .= " AND (i.name LIKE :q ESCAPE '\\\\' OR i.description LIKE :q2 ESCAPE '\\\\')"; + $params['q'] = $like; + $params['q2'] = $like; + } + + return [$where, $params]; + } + + /** + * Shape a joined public row: coarse availability, no leak fields. + * + * @param array $row + * @return array{sku_code:string,name:string,price:string,unit:?string,description:?string,image_media_id:?int,category_id:?int,category:?string,featured:bool,availability:string} + */ + private function hydratePublic(array $row): array + { + $available = (float) $row['available']; + $status = $available <= 0.0 ? 'out' : ($available <= self::LOW_STOCK_THRESHOLD ? 'low' : 'in_stock'); + + 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'], + 'category' => $row['category'] === null ? null : (string) $row['category'], + 'featured' => (bool) $row['featured'], + 'availability' => $status, + ]; + } + // --- categories ------------------------------------------------------ /** diff --git a/src/CatalogReadAdapter.php b/src/CatalogReadAdapter.php new file mode 100644 index 0000000..47dbddd --- /dev/null +++ b/src/CatalogReadAdapter.php @@ -0,0 +1,34 @@ +catalog->publicList($filters); + } + + public function get(string $sku): ?array + { + return $this->catalog->publicGet($sku); + } + + public function categories(): array + { + return $this->catalog->publicCategories(); + } +} diff --git a/src/CatalogReadPort.php b/src/CatalogReadPort.php new file mode 100644 index 0000000..a1b04fa --- /dev/null +++ b/src/CatalogReadPort.php @@ -0,0 +1,46 @@ +services()->get(CatalogReadPort::class)` — `null` when no inventory + * is installed, so it degrades to an empty catalog rather than a 500 — and never + * touches Inventory's tables. + * + * Everything here is **public-safe by construction**: active items only, + * **coarse** availability (`in_stock` / `low` / `out`, never a raw count), and no + * cost, on-hand, reserved, or location detail. The port cannot be used to read + * hidden items or exact stock. + */ +interface CatalogReadPort +{ + /** + * A paginated public listing. `sort` is one of `featured|name|price_asc| + * price_desc` (anything else → the default); `category` is a category **slug**; + * `q` is a free-text search over name/description; `page` is 1-based. + * + * @param array{category?:?string,q?:?string,sort?:?string,page?:int} $filters + * @return array{items:list,total:int,page:int,per_page:int,pages:int} + */ + public function list(array $filters): array; + + /** + * One public item by SKU, or null when it is absent **or not active** (so a + * hidden SKU is indistinguishable from a missing one — the caller 404s both). + * + * @return array{sku_code:string,name:string,price:string,unit:?string,description:?string,image_media_id:?int,category_id:?int,category:?string,featured:bool,availability:string}|null + */ + public function get(string $sku): ?array; + + /** + * The category tree (two levels) for storefront navigation — public fields. + * + * @return list + */ + public function categories(): array; +} diff --git a/src/Guide.php b/src/Guide.php index fdff29c..8cf03ae 100644 --- a/src/Guide.php +++ b/src/Guide.php @@ -55,6 +55,8 @@ public static function text(): string - `inventory_item_set` — create/update a SKU's item (name, price, category, unit, image, flags). Only the fields you send change. - `inventory_item_get` — a SKU's item record, or none. + - `inventory_items` — list items (all, or matching a `q` search over SKU + and name) to manage the catalog. - `inventory_category_set` — create (omit `id`) or rename/reparent (with `id`) a category. `inventory_category_get` / `inventory_categories` read them. diff --git a/src/InventoryPlugin.php b/src/InventoryPlugin.php index 37b882c..318faf0 100644 --- a/src/InventoryPlugin.php +++ b/src/InventoryPlugin.php @@ -57,6 +57,10 @@ public function register(PluginContext $context): void // stock synchronously without touching Inventory's tables (ADR 0019). $context->services()->provide(ReservationPort::class, new ReservationAdapter($ledger, $reservations)); + // Publish the public catalog read contract (ADR 0023) so a storefront can + // render items — active-only, coarse availability — without touching tables. + $context->services()->provide(CatalogReadPort::class, new CatalogReadAdapter($catalog)); + // Admin page: an overview plus the receive/adjust/count/transfer forms (H3). // Gated on this plugin's own wildcard-immune capability (ADR 0020) — moving // stock in the UI needs `nimbuscms.inventory:write`, exactly like the MCP diff --git a/src/InventoryToolset.php b/src/InventoryToolset.php index b20b7fa..6e26436 100644 --- a/src/InventoryToolset.php +++ b/src/InventoryToolset.php @@ -124,6 +124,11 @@ protected function tools(): array 'properties' => ['sku' => $sku], ], $this->itemGet(...)), + new PluginTool('items', 'read', 'List sellable items (all, or those whose SKU or name matches a search), for managing the catalog.', [ + 'type' => 'object', + 'properties' => ['q' => ['type' => 'string', 'description' => 'Optional search over SKU and name.']], + ], $this->items(...)), + new PluginTool('category_set', 'write', 'Create a category (omit id) or rename/reparent one (with id). Two levels only.', [ 'type' => 'object', 'required' => ['name'], @@ -304,6 +309,16 @@ private function itemGet(array $a, TokenPrincipal $p, EntryOpContext $c): array return ['sku' => $sku, 'item' => $this->catalog->getItem($sku)]; } + /** + * @param array $a + * @return array + */ + private function items(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $items = $this->catalog->allItems($this->nullableStr($a, 'q')); + return ['items' => $items, 'count' => count($items)]; + } + /** * @param array $a * @return array diff --git a/tests/CatalogTest.php b/tests/CatalogTest.php index 718f174..c98c260 100644 --- a/tests/CatalogTest.php +++ b/tests/CatalogTest.php @@ -8,17 +8,21 @@ use Nimbus\Plugin\PluginStorage; use NimbusCMS\Inventory\Catalog; use NimbusCMS\Inventory\CategoryInUse; +use NimbusCMS\Inventory\Ledger; use NimbusCMS\Inventory\Schema; use PHPUnit\Framework\TestCase; /** * The item master service (ADR 0022). These prove the security controls the * review pinned at the write boundary: raw storage, price validation, the field - * allow-list (no over-posting), the media-id soft ref, and category integrity. + * allow-list (no over-posting), the media-id soft ref, and category integrity — + * and (ADR 0023) the public storefront reads: active-only, coarse availability, + * a public-safe shape, and allow-listed sort / bound search. */ final class CatalogTest extends TestCase { private Catalog $catalog; + private Ledger $ledger; protected function setUp(): void { @@ -29,14 +33,23 @@ protected function setUp(): void 'user' => getenv('TEST_DB_USER') ?: 'root', 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', ]); - foreach (Schema::items() as $sql) { + foreach ([...Schema::items(), ...Schema::all(), ...Schema::reservations()] as $sql) { $db->execute($sql); } - $db->execute('TRUNCATE ' . Schema::ITEM); - $db->execute('TRUNCATE ' . Schema::CATEGORY); + foreach ([Schema::ITEM, Schema::CATEGORY, Schema::STOCK, Schema::MOVEMENT, Schema::LOCATION, Schema::RESERVATION] as $t) { + $db->execute('TRUNCATE ' . $t); + } $storage = new PluginStorage($db); $this->catalog = new Catalog(static fn (): PluginStorage => $storage); + $this->ledger = new Ledger(static fn (): PluginStorage => $storage); + } + + /** Give a SKU on-hand stock so its public availability is computable. */ + private function stock(string $sku, string $qty): void + { + $now = $this->now(); + $this->ledger->receive($sku, $this->ledger->ensureLocation('main', 'Main', $now), $qty, 'each', 'seed', $now); } private function now(): string @@ -156,4 +169,83 @@ public function test_slugs_are_allow_listed_and_unique(): void self::assertSame('fruit-veg', $this->catalog->getCategory($a)['slug']); self::assertSame('fruit-veg-2', $this->catalog->getCategory($b)['slug'], 'a clashing slug is suffixed'); } + + // --- public storefront reads (ADR 0023) ------------------------------ + + public function test_public_reads_exclude_inactive_items(): void + { + $this->catalog->saveItem('shown', ['name' => 'Shown', 'price' => '1.00', 'active' => true], $this->now()); + $this->catalog->saveItem('hidden', ['name' => 'Hidden', 'price' => '1.00', 'active' => false], $this->now()); + + $skus = array_column($this->catalog->publicList([])['items'], 'sku_code'); + self::assertContains('shown', $skus); + self::assertNotContains('hidden', $skus, 'an inactive item is invisible on the storefront'); + + // And a direct fetch of the hidden SKU is indistinguishable from missing. + self::assertNull($this->catalog->publicGet('hidden')); + self::assertNull($this->catalog->publicGet('no-such-sku')); + self::assertNotNull($this->catalog->publicGet('shown')); + } + + public function test_availability_is_coarse_and_leaks_no_counts(): void + { + $this->catalog->saveItem('plenty', ['name' => 'Plenty', 'price' => '1.00'], $this->now()); + $this->catalog->saveItem('few', ['name' => 'Few', 'price' => '1.00'], $this->now()); + $this->catalog->saveItem('none', ['name' => 'None', 'price' => '1.00'], $this->now()); + $this->stock('plenty', '50'); + $this->stock('few', '3'); + // 'none' has no stock at all. + + $byId = []; + foreach ($this->catalog->publicList([])['items'] as $it) { + $byId[$it['sku_code']] = $it; + } + + self::assertSame('in_stock', $byId['plenty']['availability']); + self::assertSame('low', $byId['few']['availability']); + self::assertSame('out', $byId['none']['availability']); + + // The public shape carries no raw stock, reserved, cost, or active flag. + foreach (['on_hand', 'reserved', 'available', 'cost', 'active'] as $leak) { + self::assertArrayNotHasKey($leak, $byId['plenty'], "public item must not carry {$leak}"); + } + } + + public function test_an_unknown_sort_falls_back_and_never_errors(): void + { + $this->catalog->saveItem('a', ['name' => 'Apple', 'price' => '2.00'], $this->now()); + $this->catalog->saveItem('b', ['name' => 'Banana', 'price' => '1.00'], $this->now()); + + // A hostile sort value is not interpolated — it simply falls back. + $out = $this->catalog->publicList(['sort' => 'price; DROP TABLE inventory_item']); + self::assertSame(2, $out['total']); + + $asc = array_column($this->catalog->publicList(['sort' => 'price_asc'])['items'], 'sku_code'); + self::assertSame(['b', 'a'], $asc, 'price_asc orders cheapest first'); + } + + public function test_search_is_bound_and_matches_name(): void + { + $this->catalog->saveItem('a', ['name' => 'Green Apple', 'price' => '1.00'], $this->now()); + $this->catalog->saveItem('b', ['name' => 'Banana', 'price' => '1.00'], $this->now()); + + $hits = array_column($this->catalog->publicList(['q' => 'apple'])['items'], 'sku_code'); + self::assertSame(['a'], $hits); + + // A LIKE wildcard in the term is a literal, not a match-all. + self::assertSame([], $this->catalog->publicList(['q' => '%'])['items'], 'a bare % matches nothing literally'); + } + + public function test_category_filter_resolves_a_slug(): void + { + $fruit = $this->catalog->saveCategory(null, 'Fruit', null, $this->now()); + $this->catalog->saveItem('a', ['name' => 'Apple', 'price' => '1.00', 'category_id' => (string) $fruit], $this->now()); + $this->catalog->saveItem('b', ['name' => 'Bread', 'price' => '1.00'], $this->now()); + + $hits = array_column($this->catalog->publicList(['category' => 'fruit'])['items'], 'sku_code'); + self::assertSame(['a'], $hits); + + // An unknown category yields nothing, never the unfiltered list. + self::assertSame([], $this->catalog->publicList(['category' => 'no-such'])['items']); + } } diff --git a/tests/InventoryToolsetTest.php b/tests/InventoryToolsetTest.php index c58943c..b261121 100644 --- a/tests/InventoryToolsetTest.php +++ b/tests/InventoryToolsetTest.php @@ -85,8 +85,8 @@ public function test_the_tools_are_namespaced_and_split_read_from_write(): void 'inventory_receive', 'inventory_adjust', 'inventory_count', 'inventory_transfer', 'inventory_reserve', 'inventory_release', 'inventory_issue', 'inventory_stock', 'inventory_movements', - 'inventory_item_set', 'inventory_item_get', 'inventory_category_set', - 'inventory_category_get', 'inventory_categories', + 'inventory_item_set', 'inventory_item_get', 'inventory_items', + 'inventory_category_set', 'inventory_category_get', 'inventory_categories', ], $names); } @@ -95,7 +95,7 @@ public function test_a_read_only_token_sees_only_the_read_tools(): void $names = array_column($this->toolset->definitions($this->principal('nimbuscms.inventory:read')), 'name'); self::assertSame([ 'inventory_stock', 'inventory_movements', 'inventory_item_get', - 'inventory_category_get', 'inventory_categories', + 'inventory_items', 'inventory_category_get', 'inventory_categories', ], $names); } diff --git a/tests/PackageIntegrationTest.php b/tests/PackageIntegrationTest.php index b2b67b0..6758a48 100644 --- a/tests/PackageIntegrationTest.php +++ b/tests/PackageIntegrationTest.php @@ -11,6 +11,7 @@ use Nimbus\Plugin\PluginCapabilities; use Nimbus\Plugin\PluginLoader; use Nimbus\Plugin\ServiceRegistry; +use NimbusCMS\Inventory\CatalogReadPort; use NimbusCMS\Inventory\InventoryPlugin; use NimbusCMS\Inventory\ReservationPort; use PHPUnit\Framework\TestCase; @@ -103,6 +104,7 @@ public function test_discovery_registers_the_migration_capability_toolset_and_gu self::assertCount(1, $mcpToolsets->all(), 'its MCP toolset'); self::assertNotSame([], $skills->documents(), 'its agent guide'); self::assertInstanceOf(ReservationPort::class, $services->get(ReservationPort::class), 'it publishes the reservation port'); + self::assertInstanceOf(CatalogReadPort::class, $services->get(CatalogReadPort::class), 'it publishes the catalog read port'); } public function test_disabling_the_package_registers_nothing(): void