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:
@@ -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;
|
||||
`);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>): 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 };
|
||||
}
|
||||
@@ -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}`;
|
||||
+94
-15
@@ -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<number[]> {
|
||||
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<void> {
|
||||
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');
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+18
-13
@@ -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<string, unknown>);
|
||||
} 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]);
|
||||
});
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,8 @@ export async function migrate(): Promise<void> {
|
||||
export async function resetDb(): Promise<void> {
|
||||
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
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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é'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user