Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions src/Catalog.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<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}>,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<array{id:int,name:string,slug:string,parent_id:?int}>
*/
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<string,mixed>}
*/
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<string,mixed> $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 ------------------------------------------------------

/**
Expand Down
34 changes: 34 additions & 0 deletions src/CatalogReadAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Inventory;

/**
* Inventory's implementation of {@see CatalogReadPort} — a thin delegate to the
* {@see Catalog} service's public reads, which own the public-safe query
* (active-only, coarse availability, allow-listed sort, bound search). This only
* forwards, so consuming the port grants no capability Inventory wouldn't, and
* the boundary (ADR 0005) holds: the consumer never sees a table.
*/
final class CatalogReadAdapter implements CatalogReadPort
{
public function __construct(private Catalog $catalog)
{
}

public function list(array $filters): array
{
return $this->catalog->publicList($filters);
}

public function get(string $sku): ?array
{
return $this->catalog->publicGet($sku);
}

public function categories(): array
{
return $this->catalog->publicCategories();
}
}
46 changes: 46 additions & 0 deletions src/CatalogReadPort.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace NimbusCMS\Inventory;

/**
* The read contract Inventory publishes for a storefront to render its catalog
* (ADR 0019 service ports; ADR 0023 themed pages). A presentation plugin (the
* Storefront) depends on this **interface**, obtains the live one at request time
* via `$ctx->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<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}>,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<array{id:int,name:string,slug:string,parent_id:?int}>
*/
public function categories(): array;
}
2 changes: 2 additions & 0 deletions src/Guide.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/InventoryPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/InventoryToolset.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -304,6 +309,16 @@ private function itemGet(array $a, TokenPrincipal $p, EntryOpContext $c): array
return ['sku' => $sku, 'item' => $this->catalog->getItem($sku)];
}

/**
* @param array<string,mixed> $a
* @return array<string,mixed>
*/
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<string,mixed> $a
* @return array<string,mixed>
Expand Down
Loading
Loading