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) <noreply@anthropic.com>
This commit is contained in:
@@ -9,3 +9,4 @@ coverage/
|
|||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
.env
|
.env
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user