feat(api): categories, tags, and storefront item filters (#23)

Adds a self-referencing categories tree, a tag registry with
deterministic colours, and item_tags, plus admin CRUD for both.

GET /api/items now accepts category, tags, min_price and max_price.
Category matching walks the subtree with a recursive CTE so selecting a
parent includes everything filed beneath it; tags match with AND via a
count check, since ANY() alone would return items carrying only one of
them. Malformed filter params return 400 rather than being ignored, so a
broken link doesn't quietly list the whole catalogue.

GET /api/filters serves the drawer its tree, tags, and price bounds in
one request.

Item image/tag aggregation moves from LEFT JOIN + GROUP BY to scalar
subqueries. Joining two one-to-many relations multiplies their rows, so
an item with 2 images and 3 tags would have repeated every image three
times once tags were added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:13:51 -05:00
co-authored by Claude Opus 5
parent 766358a9fe
commit 9222e97deb
14 changed files with 1288 additions and 29 deletions
@@ -0,0 +1,60 @@
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES categories(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Siblings can't share a name. Two partial indexes rather than one plain
-- unique constraint, because a NULL parent_id (a root category) compares
-- unequal to every other NULL and would let duplicate roots straight in.
CREATE UNIQUE INDEX IF NOT EXISTS categories_child_name_uniq
ON categories (parent_id, lower(name)) WHERE parent_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS categories_root_name_uniq
ON categories (lower(name)) WHERE parent_id IS NULL;
CREATE INDEX IF NOT EXISTS categories_parent_id_idx ON categories (parent_id);
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_uniq ON tags (lower(name));
CREATE TABLE IF NOT EXISTS item_tags (
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (item_id, tag_id)
);
-- The item_id half is covered by the primary key; filtering by tag needs
-- the other direction.
CREATE INDEX IF NOT EXISTS item_tags_tag_id_idx ON item_tags (tag_id);
-- Deleting a category leaves its items in place and merely uncategorized,
-- rather than taking inventory down with it.
ALTER TABLE items ADD COLUMN IF NOT EXISTS category_id INTEGER
REFERENCES categories(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS items_category_id_idx ON items (category_id);
`);
};
// Unlike the baseline migration, this one is safe to reverse: it drops only
// what it created. Item rows themselves are untouched — they just lose their
// category assignment along with the column.
exports.down = (pgm) => {
pgm.sql(`
DROP INDEX IF EXISTS items_category_id_idx;
ALTER TABLE items DROP COLUMN IF EXISTS category_id;
DROP TABLE IF EXISTS item_tags;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS categories;
`);
};