From 766358a9feb4b5c58de8c8d8078c66cecd7a4738 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Mon, 17 Aug 2026 09:04:33 -0500 Subject: [PATCH] docs: design spec for categories and tags (#23) Records the resolved requirements for issue #23: manual category tree (no rule engine), single category per item with descendant matching, central tag registry with hashed-then-overridable colours, AND semantics for multi-tag filtering, and a drawer-plus-chips storefront filter UI. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + .../2026-08-17-categories-and-tags-design.md | 260 ++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-17-categories-and-tags-design.md diff --git a/.gitignore b/.gitignore index 2bdffaf..9812144 100755 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ coverage/ playwright-report/ test-results/ .env +.superpowers/ diff --git a/docs/superpowers/specs/2026-08-17-categories-and-tags-design.md b/docs/superpowers/specs/2026-08-17-categories-and-tags-design.md new file mode 100644 index 0000000..3fce74c --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-categories-and-tags-design.md @@ -0,0 +1,260 @@ +# Categories and Tags — Design + +**Issue:** [#23 — Categories and tags](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/23) +**Date:** 2026-08-17 +**Status:** Approved + +## Goal + +Give every item two new organizing dimensions, and let storefront visitors filter on them: + +1. **Category** — an admin-managed tree. Metadata only; no physical storage structure changes. +2. **Tags** — flexible, colour-coded labels. An item carries many; new tags are created on the fly. + +The storefront gains filters for category, tags, and price range. + +## Decisions + +Every decision below was settled with the issue author and is recorded on the issue +([round 1](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/23#issuecomment-183), +[round 2](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/23#issuecomment-184), +[round 3](https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs/issues/23#issuecomment-185)). + +| Area | Decision | +| --- | --- | +| Category assignment | Manual. The admin builds the tree and picks a node per item. **No rule engine.** | +| Category depth | Arbitrary, via self-referencing `parent_id` | +| Categories per item | Exactly one; `NULL` allowed and means Uncategorized | +| Category filtering | Selecting a node matches that node **and all its descendants** | +| Tags per item | Many | +| Tag registry | Central `tags` table — enables rename, recolour, delete | +| Tag creation | On the fly from the item form | +| Tag colours | Auto-assigned deterministically from the name, overridable from a palette | +| Tag filtering | **AND** — an item must carry every selected tag | +| Filter combination | Category AND tags AND price | +| Filter location | Server-side, via query params on `/api/items` | +| Storefront layout | Drawer + removable active-filter chips; drawer enters from the right at all sizes | +| Sold items | Continue to appear on the storefront, unchanged | + +The issue's phrase "rules that dictate how the app automatically organizes items" was explicitly +resolved to mean manual tree assignment, matching its own follow-on sentence that categories are +"only metadata for organizing the items into a tree-like structure." + +## Schema + +One new migration, created with `npm run migrate:create -- add-categories-and-tags`. + +```sql +CREATE TABLE 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 cannot share a name. Two partial indexes rather than one, because +-- NULL parent_id would otherwise defeat a plain unique constraint. +CREATE UNIQUE INDEX categories_child_name_uniq + ON categories (parent_id, lower(name)) WHERE parent_id IS NOT NULL; +CREATE UNIQUE INDEX categories_root_name_uniq + ON categories (lower(name)) WHERE parent_id IS NULL; + +CREATE TABLE tags ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + color TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX tags_name_uniq ON tags (lower(name)); + +CREATE TABLE 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) +); +CREATE INDEX item_tags_tag_id_idx ON item_tags (tag_id); + +ALTER TABLE items ADD COLUMN category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL; +CREATE INDEX items_category_id_idx ON items (category_id); +``` + +**Delete semantics.** Deleting a category cascades to its subcategories; items in any deleted node +fall back to `NULL` (Uncategorized) rather than being deleted. The admin confirm dialog states the +counts before proceeding. Deleting a tag removes its `item_tags` rows and nothing else. + +Unlike the baseline migration, this one has a real `down` that drops the three tables and the +`items.category_id` column. + +## Backend + +### Descendant matching + +A recursive CTE walks down from the selected node: + +```sql +WITH RECURSIVE subtree AS ( + SELECT id FROM categories WHERE id = $1 + UNION ALL + SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id +) +SELECT ... WHERE i.category_id IN (SELECT id FROM subtree) +``` + +Chosen over a materialized path column because nothing is denormalized: reparenting a subtree stays +a single `UPDATE parent_id`, with no descendant paths to rewrite and drift out of sync. The tree +will hold dozens of nodes, so the query cost is irrelevant. + +### Fixing the existing item SELECT first + +`SELECT_WITH_IMAGES` in both `routes/items.ts` and `routes/admin.ts` aggregates images through a +`LEFT JOIN` plus `GROUP BY`. Adding a second one-to-many join for tags to that shape fans out rows +and silently duplicates every image. Both are converted to scalar subqueries, which drops the +`GROUP BY` entirely: + +```sql +SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at, + i.category_id, c.name AS category_name, + COALESCE((SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path, + 'sort_order', img.sort_order) ORDER BY img.sort_order) + FROM item_images img WHERE img.item_id = i.id), '[]') AS images, + COALESCE((SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'color', t.color) + ORDER BY t.name) + FROM item_tags it JOIN tags t ON t.id = it.tag_id + WHERE it.item_id = i.id), '[]') AS tags +FROM items i +LEFT JOIN categories c ON c.id = i.category_id +``` + +This is a prerequisite, not opportunistic refactoring — the feature is incorrect without it. + +### Public endpoints + +**`GET /api/items`** gains query params, all optional and all combined with AND: + +| Param | Type | Meaning | +| --- | --- | --- | +| `category` | integer | Match this node and all descendants | +| `tags` | comma-separated integers | Item must carry **all** of them | +| `min_price` / `max_price` | integer cents | Inclusive bounds on `price_cents` | + +Tag AND is enforced with a count check rather than repeated joins: + +```sql +AND (SELECT COUNT(*) FROM item_tags it + WHERE it.item_id = i.id AND it.tag_id = ANY($1::int[])) = $2 +``` + +Malformed params (non-numeric, negative, inverted price range) return `400` rather than being +silently ignored, so a broken filter link is visible instead of quietly returning everything. + +**`GET /api/filters`** returns everything the drawer needs in one request: + +```json +{ + "categories": [{ "id": 1, "name": "Furniture", "parent_id": null, "sort_order": 0 }], + "tags": [{ "id": 1, "name": "vintage", "color": "magenta", "item_count": 4 }], + "priceRange": { "min_cents": 1200, "max_cents": 80000 } +} +``` + +Categories come back as a flat list; the frontend builds the tree. `priceRange` is computed across +all items and gives the slider its bounds. When there are no items, it returns `{min_cents: 0, max_cents: 0}`. + +### Admin endpoints + +Mounted under the existing `/api/admin` prefix, so they inherit the authentik forward-auth boundary +described in `.claude/project-context.md` with no nginx change. + +- `GET|POST /api/admin/categories`, `PUT|DELETE /api/admin/categories/:id` +- `GET|POST /api/admin/tags`, `PUT|DELETE /api/admin/tags/:id` + +`PUT /api/admin/categories/:id` accepts `name`, `parent_id`, and `sort_order`. Reparenting is +validated against cycles server-side — a node may not become its own descendant — returning `400`. +`DELETE` responds with the affected counts so the UI can confirm before committing. + +`POST`/`PUT /api/admin/items` gain two multipart fields: + +- `category_id` — integer or empty for Uncategorized +- `tags` — JSON array of tag names; unknown names are created inside the same transaction with a + hashed colour + +Multer's `fields: 8` cap in `routes/admin.ts` still has headroom: 3 text fields today, 5 after. + +### Tag colours + +A pure function hashes the tag name onto antd's preset palette, so a name always yields the same +colour and adjacent tags rarely collide. Lives in `backend/src/utils.ts` beside the existing helpers +and is unit-tested for determinism and range. An admin override simply writes `tags.color` directly. + +## Frontend + +### New files + +| File | Purpose | +| --- | --- | +| `src/filters.ts` | Filter state type, URL query-string serialization, category tree building | +| `src/components/FilterDrawer.tsx` | The drawer: category tree, tag pills, price slider | +| `src/components/ActiveFilterChips.tsx` | Removable chips plus "Clear all" | +| `src/admin/Categories.tsx` | Category tree management tab | +| `src/admin/Tags.tsx` | Tag list management tab | + +### Storefront + +`App.tsx` holds filter state, syncs it to the URL query string, and refetches items when it changes. +Filtered views are therefore shareable and the back button works. + +The closed state is a "Filters (N)" button with active-filter chips beside it — wrapping onto their +own line on mobile. The drawer enters from the right at every screen size (`placement="right"`, +near-full-width below the `md` breakpoint), with a sticky footer holding "Clear all" and +"Show N items". + +The tag section is labelled **"Tags — must have all"** so that selecting a second tag and watching +the grid shrink reads as intentional rather than broken. + +`ItemCard` renders its tags as colour-coded antd `Tag` chips. + +### Admin + +Two tabs added beside Inventory, Customers, and Settings: + +- **Categories** — antd `Tree` with drag-to-reparent, inline add/rename/delete, delete confirm + naming the affected subcategory and item counts +- **Tags** — list with rename, colour override from a palette, and delete showing the item count + +The item modal gains a category `TreeSelect` (with an explicit Uncategorized option) and a tags +`Select mode="tags"` for on-the-fly creation. + +### Import convention + +New frontend files use antd deep imports (`import Drawer from 'antd/lib/drawer'`) per the standing +user rule. Existing files keep their barrel imports — churning them is out of scope for this issue. + +## Testing + +**Unit** (`backend/tests/unit/`) +- Tag colour hash: deterministic, always within the palette, stable across calls +- Filter query-param parsing: valid values accepted, malformed values rejected + +**Integration** (`backend/tests/integration/categoriesTags.integration.test.ts`) +- Category CRUD; sibling name collision rejected +- Reparent that would create a cycle rejected with `400` +- Deleting a parent cascades to subcategories and uncategorizes their items without deleting them +- `?category=` matches descendants, not just the exact node +- `?tags=` requires **all** listed tags, not any +- `?min_price`/`?max_price` bound inclusively; inverted range rejected +- All three filters combined +- `GET /api/filters` shape, including the empty-catalogue price range +- Creating an item with a new tag name creates the tag; reusing a name does not duplicate it + +**E2E** (`frontend/tests/e2e/filters.spec.ts`) +- Open the drawer, filter by category, tags, and price; assert the grid narrows +- Remove a chip and assert the grid widens +- Reload a filtered URL and assert the filters are restored + +## Out of scope + +- Any automatic categorization rule engine (explicitly rejected — see Q1) +- Multiple categories per item (explicitly rejected — see Q2) +- Customer-facing tag creation; tags are admin-only +- Filtering or faceting in the admin inventory table