Feature/categories and tags #24

Merged
bermudalamb merged 3 commits from feature/categories-and-tags into main 2026-08-17 09:49:37 -05:00
2 changed files with 261 additions and 0 deletions
Showing only changes of commit 766358a9fe - Show all commits
+1
View File
@@ -9,3 +9,4 @@ coverage/
playwright-report/
test-results/
.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