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/backend/migrations/1786974336000_add-categories-and-tags.js b/backend/migrations/1786974336000_add-categories-and-tags.js new file mode 100644 index 0000000..3684c3e --- /dev/null +++ b/backend/migrations/1786974336000_add-categories-and-tags.js @@ -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; + `); +}; diff --git a/backend/src/app.ts b/backend/src/app.ts index e0a4df0..7d3b533 100755 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -6,6 +6,9 @@ import { router as cartCheckoutRouter, webhookRouter as cartCheckoutWebhookRoute import adminRouter from './routes/admin'; import adminCustomersRouter from './routes/adminCustomers'; import adminSettingsRouter from './routes/adminSettings'; +import adminCategoriesRouter from './routes/adminCategories'; +import adminTagsRouter from './routes/adminTags'; +import filtersRouter from './routes/filters'; import customersRouter from './routes/customers'; import publicRouter from './routes/public'; import cartRouter from './routes/cart'; @@ -35,10 +38,13 @@ app.get('/api/config', (_req, res) => { }); app.use('/api/items', itemsRouter); +app.use('/api/filters', filtersRouter); app.use('/api/cart', cartRouter); app.use('/api/checkout/cart', cartCheckoutRouter); app.use('/api/admin/customers', adminCustomersRouter); app.use('/api/admin/settings', adminSettingsRouter); +app.use('/api/admin/categories', adminCategoriesRouter); +app.use('/api/admin/tags', adminTagsRouter); app.use('/api/admin', adminRouter); app.use('/api/customers/me/addresses', shippingAddressesRouter); app.use('/api/customers', customersRouter); diff --git a/backend/src/itemFilters.ts b/backend/src/itemFilters.ts new file mode 100644 index 0000000..a075fac --- /dev/null +++ b/backend/src/itemFilters.ts @@ -0,0 +1,145 @@ +// Parsing and SQL construction for the storefront's category / tag / price +// filters. Kept apart from the route so the rules can be unit-tested without a +// database, and so items.ts stays a thin handler. + +export class FilterError extends Error {} + +export interface ItemFilters { + categoryId: number | null; + tagIds: number[]; + minPriceCents: number | null; + maxPriceCents: number | null; +} + +export interface BuiltFilter { + clauses: string[]; + params: unknown[]; +} + +// Deliberately excludes a leading sign and any decimal point: every filter +// value is a non-negative integer (an id, or a price in cents), so '-1' and +// '10.5' are caller mistakes worth surfacing rather than silently coercing. +const NON_NEGATIVE_INTEGER = /^\d+$/; + +function singleValue(value: unknown, name: string): string | null { + if (value === undefined || value === null) { + return null; + } + // Express parses `?category=1&category=2` into an array. Picking one silently + // would make a malformed link look like it worked, so refuse it instead. + if (Array.isArray(value)) { + throw new FilterError(`${name} may only be given once`); + } + if (typeof value !== 'string') { + throw new FilterError(`invalid ${name}`); + } + return value; +} + +function parseNonNegativeInteger(raw: string, name: string): number { + if (!NON_NEGATIVE_INTEGER.test(raw)) { + throw new FilterError(`invalid ${name}`); + } + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed)) { + throw new FilterError(`invalid ${name}`); + } + return parsed; +} + +function parseId(raw: string, name: string): number { + const parsed = parseNonNegativeInteger(raw, name); + if (parsed < 1) { + throw new FilterError(`invalid ${name}`); + } + return parsed; +} + +function parsePrice(value: unknown, name: string): number | null { + const raw = singleValue(value, name); + if (raw === null || raw === '') { + return null; + } + return parseNonNegativeInteger(raw, name); +} + +export function parseItemFilters(query: Record): ItemFilters { + const categoryRaw = singleValue(query.category, 'category'); + const categoryId = categoryRaw === null || categoryRaw === '' ? null : parseId(categoryRaw, 'category'); + + const tagsRaw = singleValue(query.tags, 'tags'); + const tagIds: number[] = []; + if (tagsRaw) { + for (const part of tagsRaw.split(',')) { + const trimmed = part.trim(); + if (trimmed === '') { + continue; + } + const id = parseId(trimmed, 'tags'); + // Duplicates would inflate the required-match count below and make the + // filter match nothing at all. + if (!tagIds.includes(id)) { + tagIds.push(id); + } + } + } + + const minPriceCents = parsePrice(query.min_price, 'min_price'); + const maxPriceCents = parsePrice(query.max_price, 'max_price'); + if (minPriceCents !== null && maxPriceCents !== null && minPriceCents > maxPriceCents) { + throw new FilterError('min_price may not exceed max_price'); + } + + return { categoryId, tagIds, minPriceCents, maxPriceCents }; +} + +// Returns WHERE fragments plus their parameters, with placeholders numbered +// from `startIndex` so the caller can splice these in after its own params. +export function buildItemFilterSql(filters: ItemFilters, startIndex: number): BuiltFilter { + const clauses: string[] = []; + const params: unknown[] = []; + let next = startIndex; + + if (filters.categoryId !== null) { + params.push(filters.categoryId); + // Selecting a category means "and everything filed beneath it", so walk the + // tree down from the chosen node. A recursive CTE keeps the tree + // un-denormalized: reparenting stays a single UPDATE with no stored paths + // to rewrite. + clauses.push(`i.category_id IN ( + WITH RECURSIVE subtree AS ( + SELECT id FROM categories WHERE id = $${next} + UNION ALL + SELECT c.id FROM categories c JOIN subtree s ON c.parent_id = s.id + ) + SELECT id FROM subtree + )`); + next++; + } + + if (filters.tagIds.length) { + params.push(filters.tagIds, filters.tagIds.length); + // AND, not OR: the item must carry every selected tag. Matching with + // `tag_id = ANY(...)` alone would return items holding just one of them, so + // the count of matched rows has to equal the number requested. + clauses.push( + `(SELECT COUNT(*) FROM item_tags it + WHERE it.item_id = i.id AND it.tag_id = ANY($${next}::int[])) = $${next + 1}` + ); + next += 2; + } + + if (filters.minPriceCents !== null) { + params.push(filters.minPriceCents); + clauses.push(`i.price_cents >= $${next}`); + next++; + } + + if (filters.maxPriceCents !== null) { + params.push(filters.maxPriceCents); + clauses.push(`i.price_cents <= $${next}`); + next++; + } + + return { clauses, params }; +} diff --git a/backend/src/itemSelect.ts b/backend/src/itemSelect.ts new file mode 100644 index 0000000..3b490ad --- /dev/null +++ b/backend/src/itemSelect.ts @@ -0,0 +1,43 @@ +// Shared item SELECT shapes for the public and admin routes. +// +// Images and tags are pulled as scalar subqueries rather than LEFT JOIN + +// GROUP BY. Joining two one-to-many relations in the same query multiplies +// their rows together — an item with 2 images and 3 tags would aggregate 6 +// rows, silently repeating every image three times. Subqueries keep each +// aggregate independent and drop the GROUP BY entirely. + +const IMAGES_SUBQUERY = ` + 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`; + +const TAGS_SUBQUERY = ` + 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`; + +const FROM_CLAUSE = ` + FROM items i + LEFT JOIN categories c ON c.id = i.category_id`; + +// The storefront gets an explicit column list — it has no business seeing +// paypal_order_id or reserved_until. +export const PUBLIC_ITEM_SELECT = ` + SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at, i.category_id, + c.name AS category_name, + ${IMAGES_SUBQUERY}, + ${TAGS_SUBQUERY} + ${FROM_CLAUSE}`; + +export const ADMIN_ITEM_SELECT = ` + SELECT i.*, + c.name AS category_name, + ${IMAGES_SUBQUERY}, + ${TAGS_SUBQUERY} + ${FROM_CLAUSE}`; diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index b542586..90488d6 100755 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -2,7 +2,10 @@ import { Router, Request, Response, NextFunction } from 'express'; import multer from 'multer'; import path from 'path'; import { randomUUID } from 'crypto'; +import { PoolClient } from 'pg'; import { pool } from '../db'; +import { ADMIN_ITEM_SELECT } from '../itemSelect'; +import { tagColorFor } from '../utils'; const router = Router(); @@ -52,31 +55,86 @@ const uploadImages = (req: Request, res: Response, next: NextFunction) => { }); }; -const SELECT_WITH_IMAGES = ` - SELECT i.*, - COALESCE( - json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order) - ORDER BY img.sort_order) FILTER (WHERE img.id IS NOT NULL), - '[]' - ) AS images - FROM items i - LEFT JOIN item_images img ON img.item_id = i.id -`; +// The multipart body carries category_id and tags as text fields. An absent +// field means "leave as-is" on update, which is why these return undefined +// rather than null for a missing value. +function readCategoryId(value: unknown): number | null | undefined { + if (value === undefined) return undefined; + if (value === null || value === '') return null; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) return undefined; + return parsed; +} + +function readTagNames(value: unknown): string[] | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || value.trim() === '') return []; + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) return undefined; + return parsed.filter((name): name is string => typeof name === 'string'); + } catch { + return undefined; + } +} + +// Tags typed into the item form may not exist yet. Look each name up +// case-insensitively — matching the unique index — and create the missing ones +// inside the caller's transaction so a later failure rolls them back too. +async function resolveTagIds(client: PoolClient, names: string[]): Promise { + const ids: number[] = []; + for (const raw of names) { + const name = raw.trim(); + if (!name) continue; + + const existing = await client.query(`SELECT id FROM tags WHERE lower(name) = lower($1)`, [name]); + if (existing.rows.length) { + if (!ids.includes(existing.rows[0].id)) ids.push(existing.rows[0].id); + continue; + } + + const inserted = await client.query( + `INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id`, + [name, tagColorFor(name)] + ); + ids.push(inserted.rows[0].id); + } + return ids; +} + +// Tags are replaced wholesale rather than merged — the form submits the full +// set it wants, so removing a chip has to actually remove the row. +async function setItemTags(client: PoolClient, itemId: number, tagIds: number[]): Promise { + await client.query(`DELETE FROM item_tags WHERE item_id = $1`, [itemId]); + for (const tagId of tagIds) { + await client.query(`INSERT INTO item_tags (item_id, tag_id) VALUES ($1, $2)`, [itemId, tagId]); + } +} router.get('/items', async (_req: Request, res: Response) => { - const { rows } = await pool.query(`${SELECT_WITH_IMAGES} GROUP BY i.id ORDER BY i.created_at DESC`); + const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ORDER BY i.created_at DESC`); res.json(rows); }); router.post('/items', uploadImages, async (req: Request, res: Response) => { const { name, description, price } = req.body; + + const categoryId = readCategoryId(req.body.category_id); + if (categoryId === undefined && req.body.category_id !== undefined) { + return res.status(400).json({ error: 'invalid category_id' }); + } + const tagNames = readTagNames(req.body.tags); + if (tagNames === undefined && req.body.tags !== undefined) { + return res.status(400).json({ error: 'invalid tags' }); + } + const files = (req.files as Express.Multer.File[]) || []; const client = await pool.connect(); try { await client.query('BEGIN'); const { rows } = await client.query( - `INSERT INTO items (name, description, price_cents) VALUES ($1, $2, $3) RETURNING *`, - [name, description, Math.round(parseFloat(price) * 100)] + `INSERT INTO items (name, description, price_cents, category_id) VALUES ($1, $2, $3, $4) RETURNING *`, + [name, description, Math.round(parseFloat(price) * 100), categoryId ?? null] ); const item = rows[0]; for (let i = 0; i < files.length; i++) { @@ -85,8 +143,11 @@ router.post('/items', uploadImages, async (req: Request, res: Response) => { [item.id, `/uploads/${files[i].filename}`, i] ); } + if (tagNames) { + await setItemTags(client, item.id, await resolveTagIds(client, tagNames)); + } await client.query('COMMIT'); - const { rows: full } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [item.id]); + const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]); res.json(full[0]); } catch (err) { await client.query('ROLLBACK'); @@ -99,6 +160,16 @@ router.post('/items', uploadImages, async (req: Request, res: Response) => { router.put('/items/:id', uploadImages, async (req: Request, res: Response) => { const { name, description, price } = req.body; + + const categoryId = readCategoryId(req.body.category_id); + if (categoryId === undefined && req.body.category_id !== undefined) { + return res.status(400).json({ error: 'invalid category_id' }); + } + const tagNames = readTagNames(req.body.tags); + if (tagNames === undefined && req.body.tags !== undefined) { + return res.status(400).json({ error: 'invalid tags' }); + } + const files = (req.files as Express.Multer.File[]) || []; const client = await pool.connect(); try { @@ -107,6 +178,14 @@ router.put('/items/:id', uploadImages, async (req: Request, res: Response) => { `UPDATE items SET name=$1, description=$2, price_cents=$3 WHERE id=$4`, [name, description, Math.round(parseFloat(price) * 100), req.params.id] ); + // Only touch the category when the field was actually submitted, so a + // caller that omits it doesn't silently uncategorize the item. + if (categoryId !== undefined) { + await client.query(`UPDATE items SET category_id=$1 WHERE id=$2`, [categoryId, req.params.id]); + } + if (tagNames) { + await setItemTags(client, Number(req.params.id), await resolveTagIds(client, tagNames)); + } if (files.length) { const { rows: existing } = await client.query( `SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM item_images WHERE item_id = $1`, @@ -121,7 +200,7 @@ router.put('/items/:id', uploadImages, async (req: Request, res: Response) => { } } await client.query('COMMIT'); - const { rows: full } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [req.params.id]); + const { rows: full } = await pool.query(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]); res.json(full[0]); } catch (err) { await client.query('ROLLBACK'); diff --git a/backend/src/routes/adminCategories.ts b/backend/src/routes/adminCategories.ts new file mode 100644 index 0000000..ca8c75d --- /dev/null +++ b/backend/src/routes/adminCategories.ts @@ -0,0 +1,159 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; + +const router = Router(); + +// Postgres unique-violation SQLSTATE — raised by the two partial indexes that +// stop siblings sharing a name. +const UNIQUE_VIOLATION = '23505'; + +// Walks down from a node, collecting it and every descendant. Used both for +// cycle detection on reparent and for reporting the blast radius of a delete. +const SUBTREE_CTE = ` + 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 + )`; + +function readName(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; +} + +// Distinguishes "not supplied" from "explicitly cleared to root". +function readParentId(value: unknown): number | null | undefined { + if (value === undefined) return undefined; + if (value === null || value === '') return null; + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) return undefined; + return parsed; +} + +async function parentExists(id: number): Promise { + const { rows } = await pool.query(`SELECT 1 FROM categories WHERE id = $1`, [id]); + return rows.length > 0; +} + +router.get('/', async (_req: Request, res: Response) => { + const { rows } = await pool.query( + `SELECT c.id, c.name, c.parent_id, c.sort_order, + (SELECT COUNT(*)::int FROM items i WHERE i.category_id = c.id) AS item_count + FROM categories c + ORDER BY c.sort_order, lower(c.name)` + ); + res.json(rows); +}); + +router.post('/', async (req: Request, res: Response) => { + const name = readName(req.body.name); + if (!name) { + return res.status(400).json({ error: 'name is required' }); + } + + const parentId = readParentId(req.body.parent_id); + if (parentId === undefined && req.body.parent_id !== undefined) { + return res.status(400).json({ error: 'invalid parent_id' }); + } + const parent = parentId ?? null; + if (parent !== null && !(await parentExists(parent))) { + return res.status(400).json({ error: 'parent category does not exist' }); + } + + const sortOrder = Number.isSafeInteger(req.body.sort_order) ? req.body.sort_order : 0; + + try { + const { rows } = await pool.query( + `INSERT INTO categories (name, parent_id, sort_order) VALUES ($1, $2, $3) + RETURNING id, name, parent_id, sort_order`, + [name, parent, sortOrder] + ); + res.status(201).json({ ...rows[0], item_count: 0 }); + } catch (err) { + if ((err as { code?: string }).code === UNIQUE_VIOLATION) { + return res.status(409).json({ error: 'a category with that name already exists here' }); + } + throw err; + } +}); + +router.put('/:id', async (req: Request, res: Response) => { + const id = Number(req.params.id); + const existing = await pool.query(`SELECT id, name, parent_id, sort_order FROM categories WHERE id = $1`, [id]); + if (!existing.rows.length) { + return res.status(404).json({ error: 'not found' }); + } + + let name = existing.rows[0].name; + if (req.body.name !== undefined) { + const parsed = readName(req.body.name); + if (!parsed) { + return res.status(400).json({ error: 'name is required' }); + } + name = parsed; + } + + let parent = existing.rows[0].parent_id; + if (req.body.parent_id !== undefined) { + const parsed = readParentId(req.body.parent_id); + if (parsed === undefined) { + return res.status(400).json({ error: 'invalid parent_id' }); + } + if (parsed !== null) { + if (!(await parentExists(parsed))) { + return res.status(400).json({ error: 'parent category does not exist' }); + } + // Moving a node beneath itself or one of its own descendants would + // detach that whole branch from the tree into an unreachable cycle. + const { rows: cycle } = await pool.query( + `${SUBTREE_CTE} SELECT 1 FROM subtree WHERE id = $2`, + [id, parsed] + ); + if (cycle.length) { + return res.status(400).json({ error: 'a category cannot be moved beneath itself' }); + } + } + parent = parsed; + } + + const sortOrder = Number.isSafeInteger(req.body.sort_order) + ? req.body.sort_order + : existing.rows[0].sort_order; + + try { + const { rows } = await pool.query( + `UPDATE categories SET name = $1, parent_id = $2, sort_order = $3 WHERE id = $4 + RETURNING id, name, parent_id, sort_order`, + [name, parent, sortOrder, id] + ); + res.json(rows[0]); + } catch (err) { + if ((err as { code?: string }).code === UNIQUE_VIOLATION) { + return res.status(409).json({ error: 'a category with that name already exists here' }); + } + throw err; + } +}); + +router.delete('/:id', async (req: Request, res: Response) => { + const id = Number(req.params.id); + const { rows: subtree } = await pool.query(`${SUBTREE_CTE} SELECT id FROM subtree`, [id]); + if (!subtree.length) { + return res.status(404).json({ error: 'not found' }); + } + + const ids = subtree.map((row: { id: number }) => row.id); + const { rows: affected } = await pool.query( + `SELECT COUNT(*)::int AS n FROM items WHERE category_id = ANY($1::int[])`, + [ids] + ); + + // The FK cascade takes the descendants; items fall back to NULL rather than + // being deleted along with their category. + await pool.query(`DELETE FROM categories WHERE id = $1`, [id]); + + res.json({ deleted_categories: ids.length, uncategorized_items: affected[0].n }); +}); + +export default router; diff --git a/backend/src/routes/adminTags.ts b/backend/src/routes/adminTags.ts new file mode 100644 index 0000000..f825902 --- /dev/null +++ b/backend/src/routes/adminTags.ts @@ -0,0 +1,104 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; +import { TAG_COLORS, tagColorFor } from '../utils'; + +const router = Router(); + +const UNIQUE_VIOLATION = '23505'; + +function readName(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed; +} + +// Colours are constrained to the antd preset palette the frontend can actually +// render — an arbitrary string would come out as an unstyled grey chip. +function readColor(value: unknown): string | null | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !TAG_COLORS.includes(value)) return null; + return value; +} + +router.get('/', async (_req: Request, res: Response) => { + const { rows } = await pool.query( + `SELECT t.id, t.name, t.color, + (SELECT COUNT(*)::int FROM item_tags it WHERE it.tag_id = t.id) AS item_count + FROM tags t + ORDER BY lower(t.name)` + ); + res.json(rows); +}); + +router.post('/', async (req: Request, res: Response) => { + const name = readName(req.body.name); + if (!name) { + return res.status(400).json({ error: 'name is required' }); + } + + const requestedColor = readColor(req.body.color); + if (requestedColor === null) { + return res.status(400).json({ error: 'invalid color' }); + } + const color = requestedColor ?? tagColorFor(name); + + try { + const { rows } = await pool.query( + `INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id, name, color`, + [name, color] + ); + res.status(201).json({ ...rows[0], item_count: 0 }); + } catch (err) { + if ((err as { code?: string }).code === UNIQUE_VIOLATION) { + return res.status(409).json({ error: 'a tag with that name already exists' }); + } + throw err; + } +}); + +router.put('/:id', async (req: Request, res: Response) => { + const id = Number(req.params.id); + const existing = await pool.query(`SELECT id, name, color FROM tags WHERE id = $1`, [id]); + if (!existing.rows.length) { + return res.status(404).json({ error: 'not found' }); + } + + let name = existing.rows[0].name; + if (req.body.name !== undefined) { + const parsed = readName(req.body.name); + if (!parsed) { + return res.status(400).json({ error: 'name is required' }); + } + name = parsed; + } + + let color = existing.rows[0].color; + if (req.body.color !== undefined) { + const parsed = readColor(req.body.color); + if (!parsed) { + return res.status(400).json({ error: 'invalid color' }); + } + color = parsed; + } + + try { + const { rows } = await pool.query( + `UPDATE tags SET name = $1, color = $2 WHERE id = $3 RETURNING id, name, color`, + [name, color, id] + ); + res.json(rows[0]); + } catch (err) { + if ((err as { code?: string }).code === UNIQUE_VIOLATION) { + return res.status(409).json({ error: 'a tag with that name already exists' }); + } + throw err; + } +}); + +router.delete('/:id', async (req: Request, res: Response) => { + // item_tags cascades; the items themselves are untouched. + await pool.query(`DELETE FROM tags WHERE id = $1`, [req.params.id]); + res.status(204).end(); +}); + +export default router; diff --git a/backend/src/routes/filters.ts b/backend/src/routes/filters.ts new file mode 100644 index 0000000..8a5d5cf --- /dev/null +++ b/backend/src/routes/filters.ts @@ -0,0 +1,36 @@ +import { Router, Request, Response } from 'express'; +import { pool } from '../db'; + +const router = Router(); + +// Everything the storefront's filter drawer needs, in one request: the whole +// category tree (flat — the frontend nests it), every tag with its colour, and +// the catalogue's price bounds for the slider. +router.get('/', async (_req: Request, res: Response) => { + const [categories, tags, price] = await Promise.all([ + pool.query( + `SELECT id, name, parent_id, sort_order FROM categories ORDER BY sort_order, lower(name)` + ), + pool.query( + `SELECT t.id, t.name, t.color, COUNT(it.item_id)::int AS item_count + FROM tags t + LEFT JOIN item_tags it ON it.tag_id = t.id + GROUP BY t.id + ORDER BY lower(t.name)` + ), + // An empty catalogue would otherwise hand the slider a null range. + pool.query( + `SELECT COALESCE(MIN(price_cents), 0)::int AS min_cents, + COALESCE(MAX(price_cents), 0)::int AS max_cents + FROM items` + ) + ]); + + res.json({ + categories: categories.rows, + tags: tags.rows, + priceRange: price.rows[0] + }); +}); + +export default router; diff --git a/backend/src/routes/items.ts b/backend/src/routes/items.ts index 6f6a575..222d026 100755 --- a/backend/src/routes/items.ts +++ b/backend/src/routes/items.ts @@ -1,26 +1,31 @@ import { Router, Request, Response } from 'express'; import { pool } from '../db'; +import { PUBLIC_ITEM_SELECT } from '../itemSelect'; +import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters'; const router = Router(); -const SELECT_WITH_IMAGES = ` - SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at, - COALESCE( - json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order) - ORDER BY img.sort_order) FILTER (WHERE img.id IS NOT NULL), - '[]' - ) AS images - FROM items i - LEFT JOIN item_images img ON img.item_id = i.id -`; +router.get('/', async (req: Request, res: Response) => { + let filters; + try { + filters = parseItemFilters(req.query as Record); + } catch (err) { + // A malformed filter is returned as an error rather than ignored, so a + // broken link shows itself instead of quietly listing the whole catalogue. + if (err instanceof FilterError) { + return res.status(400).json({ error: err.message }); + } + throw err; + } -router.get('/', async (_req: Request, res: Response) => { - const { rows } = await pool.query(`${SELECT_WITH_IMAGES} GROUP BY i.id ORDER BY i.created_at DESC`); + const { clauses, params } = buildItemFilterSql(filters, 1); + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params); res.json(rows); }); router.get('/:id', async (req: Request, res: Response) => { - const { rows } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [req.params.id]); + const { rows } = await pool.query(`${PUBLIC_ITEM_SELECT} WHERE i.id = $1`, [req.params.id]); if (!rows.length) return res.status(404).json({ error: 'not found' }); res.json(rows[0]); }); diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 29aa561..6a923b7 100755 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -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.'; diff --git a/backend/tests/integration/categoriesTags.integration.test.ts b/backend/tests/integration/categoriesTags.integration.test.ts new file mode 100644 index 0000000..2cd42e9 --- /dev/null +++ b/backend/tests/integration/categoriesTags.integration.test.ts @@ -0,0 +1,452 @@ +import request from 'supertest'; +import app from '../../src/app'; +import { pool } from '../../src/db'; +import { resetDb, closeDb } from './setup/testDb'; + +beforeEach(async () => { + await resetDb(); +}); + +afterAll(async () => { + await pool.end(); + await closeDb(); +}); + +async function createCategory(name: string, parentId: number | null = null): Promise { + const res = await request(app).post('/api/admin/categories').send({ name, parent_id: parentId }); + expect(res.status).toBe(201); + return res.body.id; +} + +async function createTag(name: string): Promise { + const res = await request(app).post('/api/admin/tags').send({ name }); + expect(res.status).toBe(201); + return res.body.id; +} + +async function createItem( + name: string, + priceCents: number, + categoryId: number | null = null, + tagIds: number[] = [] +): Promise { + const { rows } = await pool.query( + `INSERT INTO items (name, price_cents, category_id) VALUES ($1, $2, $3) RETURNING id`, + [name, priceCents, categoryId] + ); + const itemId = rows[0].id; + for (const tagId of tagIds) { + await pool.query(`INSERT INTO item_tags (item_id, tag_id) VALUES ($1, $2)`, [itemId, tagId]); + } + return itemId; +} + +describe('admin categories', () => { + it('creates a root category', async () => { + const res = await request(app).post('/api/admin/categories').send({ name: 'Furniture' }); + expect(res.status).toBe(201); + expect(res.body.name).toBe('Furniture'); + expect(res.body.parent_id).toBeNull(); + }); + + it('creates a nested category', async () => { + const furniture = await createCategory('Furniture'); + const res = await request(app).post('/api/admin/categories').send({ name: 'Tables', parent_id: furniture }); + expect(res.status).toBe(201); + expect(res.body.parent_id).toBe(furniture); + }); + + it('rejects two root categories with the same name', async () => { + await createCategory('Furniture'); + const res = await request(app).post('/api/admin/categories').send({ name: 'furniture' }); + expect(res.status).toBe(409); + }); + + it('rejects two siblings with the same name', async () => { + const furniture = await createCategory('Furniture'); + await createCategory('Tables', furniture); + const res = await request(app).post('/api/admin/categories').send({ name: 'Tables', parent_id: furniture }); + expect(res.status).toBe(409); + }); + + it('allows the same name under different parents', async () => { + const furniture = await createCategory('Furniture'); + const decor = await createCategory('Decor'); + await createCategory('Vintage', furniture); + const res = await request(app).post('/api/admin/categories').send({ name: 'Vintage', parent_id: decor }); + expect(res.status).toBe(201); + }); + + it('rejects a category with a blank name', async () => { + const res = await request(app).post('/api/admin/categories').send({ name: ' ' }); + expect(res.status).toBe(400); + }); + + it('rejects a parent that does not exist', async () => { + const res = await request(app).post('/api/admin/categories').send({ name: 'Orphan', parent_id: 9999 }); + expect(res.status).toBe(400); + }); + + it('lists categories with the number of items in each', async () => { + const furniture = await createCategory('Furniture'); + await createItem('Chair', 1000, furniture); + await createItem('Stool', 2000, furniture); + + const res = await request(app).get('/api/admin/categories'); + expect(res.status).toBe(200); + const found = res.body.find((c: { id: number }) => c.id === furniture); + expect(found.item_count).toBe(2); + }); + + it('renames a category', async () => { + const id = await createCategory('Furnature'); + const res = await request(app).put(`/api/admin/categories/${id}`).send({ name: 'Furniture' }); + expect(res.status).toBe(200); + expect(res.body.name).toBe('Furniture'); + }); + + it('reparents a category', async () => { + const furniture = await createCategory('Furniture'); + const tables = await createCategory('Tables'); + const res = await request(app).put(`/api/admin/categories/${tables}`).send({ parent_id: furniture }); + expect(res.status).toBe(200); + expect(res.body.parent_id).toBe(furniture); + }); + + it('refuses to make a category its own parent', async () => { + const id = await createCategory('Furniture'); + const res = await request(app).put(`/api/admin/categories/${id}`).send({ parent_id: id }); + expect(res.status).toBe(400); + }); + + it('refuses to move a category beneath its own descendant', async () => { + const furniture = await createCategory('Furniture'); + const tables = await createCategory('Tables', furniture); + const coffee = await createCategory('Coffee Tables', tables); + + const res = await request(app).put(`/api/admin/categories/${furniture}`).send({ parent_id: coffee }); + expect(res.status).toBe(400); + + // The tree must be untouched after a rejected move. + const check = await pool.query(`SELECT parent_id FROM categories WHERE id = $1`, [furniture]); + expect(check.rows[0].parent_id).toBeNull(); + }); + + it('deletes a category and reports what it affected', async () => { + const furniture = await createCategory('Furniture'); + const tables = await createCategory('Tables', furniture); + await createItem('Chair', 1000, furniture); + await createItem('Coffee table', 2000, tables); + + const res = await request(app).delete(`/api/admin/categories/${furniture}`); + expect(res.status).toBe(200); + expect(res.body.deleted_categories).toBe(2); + expect(res.body.uncategorized_items).toBe(2); + }); + + it('keeps items when their category is deleted, merely uncategorizing them', async () => { + const furniture = await createCategory('Furniture'); + const tables = await createCategory('Tables', furniture); + const itemId = await createItem('Coffee table', 2000, tables); + + await request(app).delete(`/api/admin/categories/${furniture}`); + + const res = await request(app).get(`/api/items/${itemId}`); + expect(res.status).toBe(200); + expect(res.body.category_id).toBeNull(); + }); +}); + +describe('admin tags', () => { + it('creates a tag with an auto-assigned colour', async () => { + const res = await request(app).post('/api/admin/tags').send({ name: 'vintage' }); + expect(res.status).toBe(201); + expect(res.body.name).toBe('vintage'); + expect(typeof res.body.color).toBe('string'); + expect(res.body.color.length).toBeGreaterThan(0); + }); + + it('honours an explicit colour on create', async () => { + const res = await request(app).post('/api/admin/tags').send({ name: 'vintage', color: 'purple' }); + expect(res.status).toBe(201); + expect(res.body.color).toBe('purple'); + }); + + it('rejects a colour outside the palette', async () => { + const res = await request(app).post('/api/admin/tags').send({ name: 'vintage', color: 'chartreuse' }); + expect(res.status).toBe(400); + }); + + it('rejects a duplicate tag name regardless of case', async () => { + await createTag('vintage'); + const res = await request(app).post('/api/admin/tags').send({ name: 'VINTAGE' }); + expect(res.status).toBe(409); + }); + + it('rejects a blank tag name', async () => { + const res = await request(app).post('/api/admin/tags').send({ name: ' ' }); + expect(res.status).toBe(400); + }); + + it('lists tags with the number of items carrying each', async () => { + const vintage = await createTag('vintage'); + await createTag('unused'); + await createItem('Chair', 1000, null, [vintage]); + + const res = await request(app).get('/api/admin/tags'); + expect(res.status).toBe(200); + const found = res.body.find((t: { id: number }) => t.id === vintage); + expect(found.item_count).toBe(1); + const unused = res.body.find((t: { name: string }) => t.name === 'unused'); + expect(unused.item_count).toBe(0); + }); + + it('overrides a tag colour', async () => { + const id = await createTag('vintage'); + const res = await request(app).put(`/api/admin/tags/${id}`).send({ color: 'geekblue' }); + expect(res.status).toBe(200); + expect(res.body.color).toBe('geekblue'); + }); + + it('renames a tag', async () => { + const id = await createTag('vintge'); + const res = await request(app).put(`/api/admin/tags/${id}`).send({ name: 'vintage' }); + expect(res.status).toBe(200); + expect(res.body.name).toBe('vintage'); + }); + + it('deleting a tag detaches it from items without deleting them', async () => { + const vintage = await createTag('vintage'); + const itemId = await createItem('Chair', 1000, null, [vintage]); + + const res = await request(app).delete(`/api/admin/tags/${vintage}`); + expect(res.status).toBe(204); + + const item = await request(app).get(`/api/items/${itemId}`); + expect(item.status).toBe(200); + expect(item.body.tags).toEqual([]); + }); +}); + +describe('admin item form', () => { + it('saves a category and creates unknown tags on the fly', async () => { + const furniture = await createCategory('Furniture'); + + const res = await request(app) + .post('/api/admin/items') + .field('name', 'Oak table') + .field('description', '') + .field('price', '340') + .field('category_id', String(furniture)) + .field('tags', JSON.stringify(['vintage', 'oak'])); + + expect(res.status).toBe(200); + expect(res.body.category_id).toBe(furniture); + expect(res.body.tags.map((t: { name: string }) => t.name).sort()).toEqual(['oak', 'vintage']); + }); + + it('reuses an existing tag rather than duplicating it', async () => { + await createTag('vintage'); + + await request(app) + .post('/api/admin/items') + .field('name', 'Oak table') + .field('description', '') + .field('price', '340') + .field('tags', JSON.stringify(['VINTAGE'])); + + const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM tags`); + expect(rows[0].n).toBe(1); + }); + + it('replaces an item\'s tags on update rather than appending', async () => { + const create = await request(app) + .post('/api/admin/items') + .field('name', 'Oak table') + .field('description', '') + .field('price', '340') + .field('tags', JSON.stringify(['vintage', 'oak'])); + + const res = await request(app) + .put(`/api/admin/items/${create.body.id}`) + .field('name', 'Oak table') + .field('description', '') + .field('price', '340') + .field('tags', JSON.stringify(['oak'])); + + expect(res.status).toBe(200); + expect(res.body.tags.map((t: { name: string }) => t.name)).toEqual(['oak']); + }); + + it('clears the category when an empty category_id is submitted', async () => { + const furniture = await createCategory('Furniture'); + const create = await request(app) + .post('/api/admin/items') + .field('name', 'Oak table') + .field('description', '') + .field('price', '340') + .field('category_id', String(furniture)); + + const res = await request(app) + .put(`/api/admin/items/${create.body.id}`) + .field('name', 'Oak table') + .field('description', '') + .field('price', '340') + .field('category_id', ''); + + expect(res.status).toBe(200); + expect(res.body.category_id).toBeNull(); + }); + + it('returns each image exactly once for an item that also has tags', async () => { + const create = await request(app) + .post('/api/admin/items') + .field('name', 'Oak table') + .field('description', '') + .field('price', '340') + .field('tags', JSON.stringify(['vintage', 'oak', 'restored'])); + + await pool.query( + `INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, '/uploads/a.jpg', 0), ($1, '/uploads/b.jpg', 1)`, + [create.body.id] + ); + + const res = await request(app).get(`/api/items/${create.body.id}`); + expect(res.body.images).toHaveLength(2); + expect(res.body.tags).toHaveLength(3); + }); +}); + +describe('GET /api/filters', () => { + it('returns the category tree, tags, and the catalogue price range', async () => { + const furniture = await createCategory('Furniture'); + await createCategory('Tables', furniture); + await createTag('vintage'); + await createItem('Cheap', 1200); + await createItem('Dear', 80000); + + const res = await request(app).get('/api/filters'); + expect(res.status).toBe(200); + expect(res.body.categories).toHaveLength(2); + expect(res.body.tags).toHaveLength(1); + expect(res.body.priceRange).toEqual({ min_cents: 1200, max_cents: 80000 }); + }); + + it('returns a zeroed price range for an empty catalogue', async () => { + const res = await request(app).get('/api/filters'); + expect(res.status).toBe(200); + expect(res.body.priceRange).toEqual({ min_cents: 0, max_cents: 0 }); + }); +}); + +describe('GET /api/items filtering', () => { + it('returns every item when nothing is filtered', async () => { + await createItem('A', 1000); + await createItem('B', 2000); + + const res = await request(app).get('/api/items'); + expect(res.body).toHaveLength(2); + }); + + it('matches a category and all of its descendants', async () => { + const furniture = await createCategory('Furniture'); + const tables = await createCategory('Tables', furniture); + const coffee = await createCategory('Coffee Tables', tables); + const decor = await createCategory('Decor'); + + await createItem('Deep', 1000, coffee); + await createItem('Mid', 1000, tables); + await createItem('Top', 1000, furniture); + await createItem('Elsewhere', 1000, decor); + + const res = await request(app).get(`/api/items?category=${furniture}`); + expect(res.body.map((i: { name: string }) => i.name).sort()).toEqual(['Deep', 'Mid', 'Top']); + }); + + it('excludes uncategorized items from a category filter', async () => { + const furniture = await createCategory('Furniture'); + await createItem('Filed', 1000, furniture); + await createItem('Loose', 1000, null); + + const res = await request(app).get(`/api/items?category=${furniture}`); + expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Filed']); + }); + + it('requires every listed tag, not merely one of them', async () => { + const vintage = await createTag('vintage'); + const oak = await createTag('oak'); + + await createItem('Both', 1000, null, [vintage, oak]); + await createItem('Only vintage', 1000, null, [vintage]); + await createItem('Only oak', 1000, null, [oak]); + + const res = await request(app).get(`/api/items?tags=${vintage},${oak}`); + expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Both']); + }); + + it('matches items carrying extra tags beyond those selected', async () => { + const vintage = await createTag('vintage'); + const oak = await createTag('oak'); + const rare = await createTag('rare'); + await createItem('Three tags', 1000, null, [vintage, oak, rare]); + + const res = await request(app).get(`/api/items?tags=${vintage},${oak}`); + expect(res.body).toHaveLength(1); + }); + + it('bounds the price range inclusively', async () => { + await createItem('Under', 900); + await createItem('Low edge', 1000); + await createItem('Middle', 3000); + await createItem('High edge', 5000); + await createItem('Over', 5100); + + const res = await request(app).get('/api/items?min_price=1000&max_price=5000'); + expect(res.body.map((i: { name: string }) => i.name).sort()).toEqual(['High edge', 'Low edge', 'Middle']); + }); + + it('combines category, tags, and price with AND', async () => { + const furniture = await createCategory('Furniture'); + const tables = await createCategory('Tables', furniture); + const vintage = await createTag('vintage'); + + await createItem('Match', 3000, tables, [vintage]); + await createItem('Wrong category', 3000, null, [vintage]); + await createItem('Wrong tag', 3000, tables, []); + await createItem('Wrong price', 9000, tables, [vintage]); + + const res = await request(app).get(`/api/items?category=${furniture}&tags=${vintage}&min_price=1000&max_price=5000`); + expect(res.body.map((i: { name: string }) => i.name)).toEqual(['Match']); + }); + + it('includes each item\'s tags and category name in the response', async () => { + const furniture = await createCategory('Furniture'); + const vintage = await createTag('vintage'); + await createItem('Chair', 1000, furniture, [vintage]); + + const res = await request(app).get('/api/items'); + expect(res.body[0].category_name).toBe('Furniture'); + expect(res.body[0].tags[0].name).toBe('vintage'); + expect(res.body[0].tags[0].color).toBeTruthy(); + }); + + it('rejects a malformed category rather than silently returning everything', async () => { + await createItem('A', 1000); + const res = await request(app).get('/api/items?category=furniture'); + expect(res.status).toBe(400); + }); + + it('rejects an inverted price range', async () => { + const res = await request(app).get('/api/items?min_price=5000&max_price=1000'); + expect(res.status).toBe(400); + }); + + it('returns an empty list for a category that matches nothing', async () => { + const empty = await createCategory('Empty'); + await createItem('A', 1000); + + const res = await request(app).get(`/api/items?category=${empty}`); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); +}); diff --git a/backend/tests/integration/setup/testDb.ts b/backend/tests/integration/setup/testDb.ts index d35605b..207dece 100755 --- a/backend/tests/integration/setup/testDb.ts +++ b/backend/tests/integration/setup/testDb.ts @@ -29,7 +29,8 @@ export async function migrate(): Promise { export async function resetDb(): Promise { await testPool.query(` TRUNCATE TABLE orders, checkout_items, checkouts, shipping_addresses, cart_items, carts, - customer_tokens, customer_sessions, customers, item_images, items + customer_tokens, customer_sessions, customers, item_tags, item_images, items, + tags, categories RESTART IDENTITY CASCADE `); } diff --git a/backend/tests/unit/itemFilters.test.ts b/backend/tests/unit/itemFilters.test.ts new file mode 100644 index 0000000..7015d50 --- /dev/null +++ b/backend/tests/unit/itemFilters.test.ts @@ -0,0 +1,114 @@ +import { parseItemFilters, FilterError, buildItemFilterSql } from '../../src/itemFilters'; + +describe('parseItemFilters', () => { + it('returns empty filters for an empty query', () => { + expect(parseItemFilters({})).toEqual({ + categoryId: null, + tagIds: [], + minPriceCents: null, + maxPriceCents: null + }); + }); + + it('parses a category id', () => { + expect(parseItemFilters({ category: '7' }).categoryId).toBe(7); + }); + + it('parses a comma-separated tag list', () => { + expect(parseItemFilters({ tags: '3,1,2' }).tagIds).toEqual([3, 1, 2]); + }); + + it('collapses duplicate tag ids', () => { + expect(parseItemFilters({ tags: '2,2,5' }).tagIds).toEqual([2, 5]); + }); + + it('treats an empty tag list as no tag filter', () => { + expect(parseItemFilters({ tags: '' }).tagIds).toEqual([]); + }); + + it('parses an inclusive price range in cents', () => { + const filters = parseItemFilters({ min_price: '1000', max_price: '5000' }); + expect(filters.minPriceCents).toBe(1000); + expect(filters.maxPriceCents).toBe(5000); + }); + + it('allows a price range open at one end', () => { + expect(parseItemFilters({ min_price: '1000' }).maxPriceCents).toBeNull(); + expect(parseItemFilters({ max_price: '5000' }).minPriceCents).toBeNull(); + }); + + it('allows a zero minimum price', () => { + expect(parseItemFilters({ min_price: '0' }).minPriceCents).toBe(0); + }); + + it('rejects a non-numeric category', () => { + expect(() => parseItemFilters({ category: 'furniture' })).toThrow(FilterError); + }); + + it('rejects a category id below 1', () => { + expect(() => parseItemFilters({ category: '0' })).toThrow(FilterError); + }); + + it('rejects a non-numeric tag id', () => { + expect(() => parseItemFilters({ tags: '1,vintage' })).toThrow(FilterError); + }); + + it('rejects a negative price', () => { + expect(() => parseItemFilters({ min_price: '-1' })).toThrow(FilterError); + }); + + it('rejects a fractional price', () => { + expect(() => parseItemFilters({ max_price: '10.5' })).toThrow(FilterError); + }); + + it('rejects an inverted price range', () => { + expect(() => parseItemFilters({ min_price: '5000', max_price: '1000' })).toThrow(FilterError); + }); + + it('accepts a price range where both ends are equal', () => { + expect(() => parseItemFilters({ min_price: '1000', max_price: '1000' })).not.toThrow(); + }); + + it('rejects a repeated query param rather than guessing which one to use', () => { + expect(() => parseItemFilters({ category: ['1', '2'] })).toThrow(FilterError); + }); +}); + +describe('buildItemFilterSql', () => { + it('produces no clauses and no params when nothing is filtered', () => { + const built = buildItemFilterSql(parseItemFilters({}), 1); + expect(built.clauses).toEqual([]); + expect(built.params).toEqual([]); + }); + + it('matches a category and all of its descendants', () => { + const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 1); + expect(built.clauses.join(' ')).toContain('RECURSIVE'); + expect(built.params).toEqual([4]); + }); + + it('requires every listed tag rather than any of them', () => { + const built = buildItemFilterSql(parseItemFilters({ tags: '1,2' }), 1); + // The count of matched tag rows must equal the number of tags requested — + // an ANY/IN match alone would return items carrying just one of them. + expect(built.clauses.join(' ')).toContain('COUNT(*)'); + expect(built.params).toEqual([[1, 2], 2]); + }); + + it('numbers placeholders from the given starting index', () => { + const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 3); + expect(built.clauses.join(' ')).toContain('$3'); + }); + + it('continues numbering across multiple filters', () => { + const built = buildItemFilterSql( + parseItemFilters({ category: '4', min_price: '100', max_price: '900' }), + 1 + ); + expect(built.params).toEqual([4, 100, 900]); + const sql = built.clauses.join(' '); + expect(sql).toContain('$1'); + expect(sql).toContain('$2'); + expect(sql).toContain('$3'); + }); +}); diff --git a/backend/tests/unit/tagColor.test.ts b/backend/tests/unit/tagColor.test.ts new file mode 100644 index 0000000..fe8b3c7 --- /dev/null +++ b/backend/tests/unit/tagColor.test.ts @@ -0,0 +1,31 @@ +import { tagColorFor, TAG_COLORS } from '../../src/utils'; + +describe('tagColorFor', () => { + it('returns a colour from the palette', () => { + expect(TAG_COLORS).toContain(tagColorFor('vintage')); + }); + + it('returns the same colour for the same name every time', () => { + expect(tagColorFor('vintage')).toBe(tagColorFor('vintage')); + }); + + it('ignores case and surrounding whitespace, matching how tag names are deduped', () => { + expect(tagColorFor(' Vintage ')).toBe(tagColorFor('vintage')); + }); + + it('gives different names different colours', () => { + // Not guaranteed for every possible pair, but a handful of realistic tag + // names should spread across the palette rather than collapsing onto one. + const names = ['vintage', 'handmade', 'oak', 'restored', 'rare', 'walnut']; + const distinct = new Set(names.map(tagColorFor)); + expect(distinct.size).toBeGreaterThan(1); + }); + + it('handles an empty name without throwing', () => { + expect(TAG_COLORS).toContain(tagColorFor('')); + }); + + it('handles a name of non-ASCII characters', () => { + expect(TAG_COLORS).toContain(tagColorFor('café')); + }); +}); 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..97e8fbc --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-categories-and-tags-design.md @@ -0,0 +1,279 @@ +# 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` + +`GET /api/admin/categories` returns each node with an `item_count`, which is what lets the admin UI +state the blast radius of a delete before calling it — the tree and the counts are already on the +client, so no extra preview endpoint is needed. `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` reports what it actually did, as +`{ deleted_categories, uncategorized_items }`. + +`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 chip row is marked `role="group" aria-label="Active filters"`, which keeps its "Clear all" +distinguishable from the identically-labelled control in the drawer. + +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. Expansion is controlled state rather than + `defaultExpandAll`: that prop is evaluated once at mount, so a branch created afterwards would + render collapsed and its children be unreachable. Creating or moving a node expands its parent. +- **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 +- A nested category is selectable, not just the roots +- Remove a chip and assert the grid widens; "Clear all" resets everything +- Reload a filtered URL and assert the filters are restored +- Tags render on the item card + +**E2E** (`frontend/tests/e2e/admin-taxonomy.spec.ts`) +- Create a category and a nested child, asserting the child stays visible +- Create a tag and confirm a colour was assigned +- The item form exposes category and tag fields + +Both e2e specs run against a database that is never reset between runs, so fixture names carry a +per-run suffix, and `filters.spec.ts` treats re-entry into its `beforeAll` as a no-op — a worker can +be handed the same spec file in more than one batch, and seeding twice would duplicate every item. + +## 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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d550dbc..d34d380 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,18 @@ -import { useEffect, useState, useCallback } from 'react'; -import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge } from 'antd'; -import { ShoppingCartOutlined } from '@ant-design/icons'; -import { Link } from 'react-router-dom'; -import { Item, fetchItems } from './api'; +import { useEffect, useState, useCallback, useMemo } from 'react'; +import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty } from 'antd'; +import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons'; +import { Link, useSearchParams } from 'react-router-dom'; +import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api'; import ItemCard from './components/ItemCard'; +import FilterDrawer from './components/FilterDrawer'; +import ActiveFilterChips from './components/ActiveFilterChips'; +import { + ItemFilters, + activeFilterCount, + filtersFromSearchParams, + filtersToSearchParams, + hasActiveFilters +} from './filters'; import { useThemeMode } from './theme/ThemeContext'; import { useCustomerAuth } from './customer/CustomerAuthContext'; import { useCart } from './cart/CartContext'; @@ -11,18 +20,63 @@ import { useCart } from './cart/CartContext'; const { Header, Content, Footer } = Layout; const { Title } = Typography; +// Dragging the price slider fires a change per pixel; without this every one +// would become its own request. +const FILTER_DEBOUNCE_MS = 250; + export default function App() { const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [options, setOptions] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); const { mode, toggle } = useThemeMode(); const { customer } = useCustomerAuth(); const { items: cartItems } = useCart(); const { token } = theme.useToken(); + // The URL is the single source of truth for filter state, so a reload, a + // shared link, and the back button all restore the same view. + const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]); + const filterKey = filtersToSearchParams(filters).toString(); + + const applyFilters = useCallback( + (next: ItemFilters) => { + // replace, not push: dragging a slider shouldn't bury the previous page + // under dozens of history entries. + setSearchParams(filtersToSearchParams(next), { replace: true }); + }, + [setSearchParams] + ); + + const clearFilters = useCallback(() => { + setSearchParams(new URLSearchParams(), { replace: true }); + }, [setSearchParams]); + const load = useCallback(() => { - fetchItems().then(setItems); + return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey))) + .then(setItems) + .finally(() => setLoading(false)); + }, [filterKey]); + + useEffect(() => { + setLoading(true); + const timer = setTimeout(load, FILTER_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [load]); + + useEffect(() => { + fetchFilterOptions().then(setOptions).catch(() => setOptions(null)); }, []); - useEffect(() => { load(); }, [load]); + // Adding to cart flips an item to reserved, and the filter options' price + // bounds shift as inventory changes. + const reload = useCallback(() => { + load(); + fetchFilterOptions().then(setOptions).catch(() => undefined); + }, [load]); + + const activeCount = activeFilterCount(filters); return ( @@ -54,11 +108,38 @@ export default function App() { - {!items.length ? : ( +
+ + +
+ + {loading && !items.length ? : null} + {!loading && !items.length ? ( + + {hasActiveFilters(filters) ? : null} + + ) : ( {items.map(item => ( - + ))} @@ -67,6 +148,16 @@ export default function App() {
Privacy Policy
+ + setDrawerOpen(false)} + options={options} + filters={filters} + onChange={applyFilters} + onClear={clearFilters} + resultCount={items.length} + />
); } diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx index d6ea2a7..c0cb761 100755 --- a/frontend/src/admin/Admin.tsx +++ b/frontend/src/admin/Admin.tsx @@ -1,17 +1,39 @@ import { useEffect, useState } from 'react'; import { Layout, Table, Button, Form, Input, InputNumber, Upload, Modal, - Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs + Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs, + TreeSelect, Select } from 'antd'; import { UploadOutlined, DeleteOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload/interface'; import MDEditor from '@uiw/react-md-editor'; import '@uiw/react-md-editor/markdown-editor.css'; import '@uiw/react-markdown-preview/markdown.css'; -import { Item, fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable } from '../api'; +import { + Item, Category, Tag as TagRecord, + fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable, + fetchAdminCategories, fetchAdminTags +} from '../api'; +import { buildCategoryTree, CategoryNode } from '../filters'; import { useThemeMode } from '../theme/ThemeContext'; import Customers from './Customers'; import Settings from './Settings'; +import Categories from './Categories'; +import Tags from './Tags'; + +interface CategoryTreeOption { + value: number; + title: string; + children?: CategoryTreeOption[]; +} + +function toCategoryTreeData(nodes: CategoryNode[]): CategoryTreeOption[] { + return nodes.map(node => ({ + value: node.id, + title: node.name, + children: node.children.length ? toCategoryTreeData(node.children) : undefined + })); +} const { Header, Content } = Layout; const { Title } = Typography; @@ -23,24 +45,41 @@ function Inventory() { const [form] = Form.useForm(); const [fileList, setFileList] = useState([]); const [description, setDescription] = useState(''); + const [categories, setCategories] = useState([]); + const [tags, setTags] = useState([]); const { mode } = useThemeMode(); const load = () => fetchAdminItems().then(setItems); - useEffect(() => { load(); }, []); + + // The item form needs the current category tree and tag list; both change + // from the sibling tabs, so they're refetched whenever the modal opens. + const loadOptions = () => Promise.all([ + fetchAdminCategories().then(setCategories), + fetchAdminTags().then(setTags) + ]); + + useEffect(() => { load(); loadOptions(); }, []); function openNew() { setEditingItem(null); form.resetFields(); setFileList([]); setDescription(''); + loadOptions(); setModalOpen(true); } function openEdit(item: Item) { setEditingItem(item); - form.setFieldsValue({ name: item.name, price: item.price_cents / 100 }); + form.setFieldsValue({ + name: item.name, + price: item.price_cents / 100, + category_id: item.category_id ?? undefined, + tags: item.tags.map(tag => tag.name) + }); setFileList([]); setDescription(item.description || ''); + loadOptions(); setModalOpen(true); } @@ -50,11 +89,16 @@ function Inventory() { fd.append('name', values.name); fd.append('description', description); fd.append('price', String(values.price)); + // An empty string clears the category server-side; undefined would be sent + // as the literal text "undefined". + fd.append('category_id', values.category_id == null ? '' : String(values.category_id)); + fd.append('tags', JSON.stringify(values.tags ?? [])); fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); }); await saveItem(editingItem?.id ?? null, fd); message.success(editingItem ? 'Item updated' : 'Item added'); setModalOpen(false); load(); + loadOptions(); } async function handleDelete(id: number) { @@ -87,6 +131,19 @@ function Inventory() { ) : null }, { title: 'Name', dataIndex: 'name' }, + { + title: 'Category', + dataIndex: 'category_name', + render: (name: string | null) => name || Uncategorized + }, + { + title: 'Tags', + dataIndex: 'tags', + render: (itemTags: Item['tags']) => + itemTags.length + ? itemTags.map(tag => {tag.name}) + : null + }, { title: 'Price', dataIndex: 'price_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` }, { title: 'Status', @@ -129,6 +186,27 @@ function Inventory() { + + + + + setName(event.target.value)} + onPressEnter={handleSave} + /> + + setName(event.target.value)} + onPressEnter={handleSave} + /> + {editing && ( + <> + +