feat(api): categories, tags, and storefront item filters (#23)

Adds a self-referencing categories tree, a tag registry with
deterministic colours, and item_tags, plus admin CRUD for both.

GET /api/items now accepts category, tags, min_price and max_price.
Category matching walks the subtree with a recursive CTE so selecting a
parent includes everything filed beneath it; tags match with AND via a
count check, since ANY() alone would return items carrying only one of
them. Malformed filter params return 400 rather than being ignored, so a
broken link doesn't quietly list the whole catalogue.

GET /api/filters serves the drawer its tree, tags, and price bounds in
one request.

Item image/tag aggregation moves from LEFT JOIN + GROUP BY to scalar
subqueries. Joining two one-to-many relations multiplies their rows, so
an item with 2 images and 3 tags would have repeated every image three
times once tags were added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:13:51 -05:00
co-authored by Claude Opus 5
parent 766358a9fe
commit 9222e97deb
14 changed files with 1288 additions and 29 deletions
+114
View File
@@ -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');
});
});
+31
View File
@@ -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é'));
});
});