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
+24
View File
@@ -40,5 +40,29 @@ export function isValidEmail(email: string): boolean {
return labels.length >= 2 && labels.every((label) => DOMAIN_LABEL_RE.test(label));
}
// antd's preset Tag colours. Kept as the single source of truth for tag
// colours so the admin palette picker and the auto-assignment below can never
// drift apart — the frontend renders whatever string lands in tags.color.
export const TAG_COLORS = [
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
'green', 'cyan', 'blue', 'geekblue', 'purple'
];
// Tags get a colour the moment they're created inline from the item form, with
// no prompt. Deriving it from the name (rather than picking at random or
// round-robining on insert order) means the same tag name always lands on the
// same colour, so a tag deleted and re-added doesn't silently change colour.
// The admin can still override it afterwards.
export function tagColorFor(name: string): string {
const normalized = name.trim().toLowerCase();
// djb2 — cheap, well-spread for short strings, and stable across Node
// versions. `| 0` keeps it in int32 range instead of drifting into float.
let hash = 5381;
for (let i = 0; i < normalized.length; i++) {
hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0;
}
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length];
}
export const MARKETING_CONSENT_TEXT =
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';