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:
@@ -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;
|
||||
`);
|
||||
};
|
||||
Reference in New Issue
Block a user