fix(admin): theme, American English, and inventory/reservation tooling (#27)
SonarQube Analysis / sonarqube (pull_request) Successful in 4m23s
Tests / backend-unit (pull_request) Successful in 1m6s
Tests / backend-integration (pull_request) Failing after 4m55s
Tests / frontend-e2e (pull_request) Failing after 12m41s

Seven reported items, of which the first four had two root causes.

The active tab was invisible in dark mode because colorPrimary was
hardcoded to #1a1a1a in both themes. The accent now inverts with the
theme, and colorTextLightSolid inverts with it, or a near-white accent
would get antd's default white label and disappear.

The Category tab, Tag tab, and item-form category selector ignored the
theme entirely. antd declares main: lib/index.js and module: es/index.js,
so importing from 'antd' resolves to the ES build while 'antd/lib/...'
loads the CommonJS one — two copies, two React contexts, and no
ConfigProvider for anything deep-imported. Switching those files to
antd/es/* keeps the deep-import convention and shares the instance. This
was introduced by my own use of the lib path; es is correct under Vite.
Two storefront components had the same latent bug.

"Colour" is now "Color".

The Customers tab shows how many items each customer is holding, as a
link opening the item list with a Release button. Release mirrors the
customer's own cart removal — drop the cart row, return the item to
available, guarded on 'reserved' so it can never resurrect a sold item —
and deliberately sends no email about an action the customer did not
take. The count is a subquery rather than another join, which would have
multiplied rows and inflated order_count and total_spent_cents.

The Inventory tab filters by category, tags, price, and status, reusing
the storefront's parser and query builder so the two cannot drift.
Reserved is one option in a Status filter rather than a standalone toggle.

Also fixes two defects the screenshots exposed: the reserved-count link
bubbled to the row handler and opened the customer drawer behind the
dialog, and .admin-category-node had no CSS at all, so the tree node name,
item count, and actions ran together as one string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 15:49:05 -05:00
co-authored by Claude Opus 5
parent b6ef2bb63c
commit f537314259
22 changed files with 1027 additions and 65 deletions
+24 -1
View File
@@ -9,8 +9,16 @@ export interface ItemFilters {
tagIds: number[];
minPriceCents: number | null;
maxPriceCents: number | null;
status: ItemStatus | null;
}
export type ItemStatus = 'available' | 'reserved' | 'sold';
// Matched exactly, not case-insensitively: `items.status` only ever holds these
// lowercase values, so accepting 'Reserved' would quietly return nothing rather
// than reporting that the filter was wrong.
const ITEM_STATUSES: readonly string[] = ['available', 'reserved', 'sold'];
export interface BuiltFilter {
clauses: string[];
params: unknown[];
@@ -90,7 +98,16 @@ export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
throw new FilterError('min_price may not exceed max_price');
}
return { categoryId, tagIds, minPriceCents, maxPriceCents };
const statusRaw = singleValue(query.status, 'status');
let status: ItemStatus | null = null;
if (statusRaw !== null && statusRaw !== '') {
if (!ITEM_STATUSES.includes(statusRaw)) {
throw new FilterError('invalid status');
}
status = statusRaw as ItemStatus;
}
return { categoryId, tagIds, minPriceCents, maxPriceCents, status };
}
// Returns WHERE fragments plus their parameters, with placeholders numbered
@@ -141,5 +158,11 @@ export function buildItemFilterSql(filters: ItemFilters, startIndex: number): Bu
next++;
}
if (filters.status !== null) {
params.push(filters.status);
clauses.push(`i.status = $${next}`);
next++;
}
return { clauses, params };
}
+20 -3
View File
@@ -5,6 +5,8 @@ import { randomUUID } from 'crypto';
import { PoolClient } from 'pg';
import { pool } from '../db';
import { ADMIN_ITEM_SELECT } from '../itemSelect';
import { asyncRoute } from '../asyncRoute';
import { parseItemFilters, buildItemFilterSql, FilterError } from '../itemFilters';
import { tagColorFor } from '../utils';
const router = Router();
@@ -111,10 +113,25 @@ async function setItemTags(client: PoolClient, itemId: number, tagIds: number[])
}
}
router.get('/items', async (_req: Request, res: Response) => {
const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ORDER BY i.created_at DESC`);
router.get('/items', asyncRoute(async (req: Request, res: Response) => {
// Same parser and query builder as the storefront, so admin filtering cannot
// drift from what customers see. The one addition is `status`, which is how
// the Inventory tab surfaces Reserved.
let filters;
try {
filters = parseItemFilters(req.query as Record<string, unknown>);
} catch (err) {
if (err instanceof FilterError) {
return res.status(400).json({ error: err.message });
}
throw err;
}
const { clauses, params } = buildItemFilterSql(filters, 1);
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const { rows } = await pool.query(`${ADMIN_ITEM_SELECT} ${where} ORDER BY i.created_at DESC`, params);
res.json(rows);
});
}));
router.post('/items', uploadImages, async (req: Request, res: Response) => {
const { name, description, price } = req.body;
+61 -5
View File
@@ -1,24 +1,80 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
const router = Router();
router.get('/', async (_req: Request, res: Response) => {
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query(`
SELECT
c.id, c.email, c.name, c.email_verified, c.marketing_consent, c.created_at,
COUNT(o.id) FILTER (WHERE o.status = 'completed') AS order_count,
COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents,
MAX(o.created_at) AS last_order_at
MAX(o.created_at) AS last_order_at,
-- Counted with a subquery rather than another LEFT JOIN: joining a second
-- one-to-many relation alongside orders would multiply the rows and
-- inflate order_count and total_spent_cents.
(SELECT COUNT(*)::int
FROM cart_items ci
JOIN carts ca ON ca.id = ci.cart_id
JOIN items i ON i.id = ci.item_id
WHERE ca.customer_id = c.id AND i.status = 'reserved') AS reserved_count
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id
ORDER BY c.created_at DESC
`);
res.json(rows);
});
}));
router.get('/:id', async (req: Request, res: Response) => {
router.get('/:id/reserved', asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(
`SELECT i.id AS item_id, i.name, i.price_cents, ci.added_at, ci.expires_at
FROM cart_items ci
JOIN carts ca ON ca.id = ci.cart_id
JOIN items i ON i.id = ci.item_id
WHERE ca.customer_id = $1 AND i.status = 'reserved'
ORDER BY ci.added_at`,
[req.params.id]
);
res.json(rows);
}));
// Mirrors the customer's own cart removal: drop the cart row and return the
// item to available. Deliberately no email — this is an action the customer
// did not take, and an unprompted "we removed your item" invites confusion.
router.post('/:id/reserved/:itemId/release', asyncRoute(async (req: Request, res: Response) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`DELETE FROM cart_items ci
USING carts ca
WHERE ci.cart_id = ca.id AND ca.customer_id = $1 AND ci.item_id = $2
RETURNING ci.item_id`,
[req.params.id, req.params.itemId]
);
if (!rows.length) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'that customer is not holding this item' });
}
// Guarded on 'reserved' so releasing never resurrects a sold item.
await client.query(
`UPDATE items SET status = 'available', reserved_until = NULL
WHERE id = $1 AND status = 'reserved'`,
[req.params.itemId]
);
await client.query('COMMIT');
res.status(204).end();
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}));
router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
const { rows: customerRows } = await pool.query(
`SELECT id, email, name, email_verified, marketing_consent, marketing_consent_at, created_at
FROM customers WHERE id = $1`,
@@ -35,6 +91,6 @@ router.get('/:id', async (req: Request, res: Response) => {
);
res.json({ customer: customerRows[0], orders: orderRows });
});
}));
export default router;
@@ -0,0 +1,230 @@
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,
options: { categoryId?: number | null; tagIds?: number[]; status?: string } = {}
): Promise<number> {
const { rows } = await pool.query(
`INSERT INTO items (name, price_cents, category_id, status) VALUES ($1, $2, $3, $4) RETURNING id`,
[name, priceCents, options.categoryId ?? null, options.status ?? 'available']
);
const itemId = rows[0].id;
for (const tagId of options.tagIds ?? []) {
await pool.query(`INSERT INTO item_tags (item_id, tag_id) VALUES ($1, $2)`, [itemId, tagId]);
}
return itemId;
}
async function registerCustomer(email: string) {
const agent = request.agent(app);
await agent.post('/api/customers/register').send({ email, password: 'supersecret123' });
const { rows } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [email]);
return { agent, id: rows[0].id as number };
}
const names = (body: { name: string }[]) => body.map(item => item.name).sort();
describe('GET /api/admin/items filtering', () => {
it('returns every item when nothing is filtered', async () => {
await createItem('A', 1000);
await createItem('B', 2000, { status: 'sold' });
const res = await request(app).get('/api/admin/items');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
});
it('filters by status, which is how Reserved is surfaced', async () => {
await createItem('Free', 1000, { status: 'available' });
await createItem('Held', 1000, { status: 'reserved' });
await createItem('Gone', 1000, { status: 'sold' });
const res = await request(app).get('/api/admin/items?status=reserved');
expect(names(res.body)).toEqual(['Held']);
});
it('matches a category and all of its descendants', async () => {
const furniture = await createCategory('Furniture');
const tables = await createCategory('Tables', furniture);
const decor = await createCategory('Decor');
await createItem('Nested', 1000, { categoryId: tables });
await createItem('Top', 1000, { categoryId: furniture });
await createItem('Elsewhere', 1000, { categoryId: decor });
const res = await request(app).get(`/api/admin/items?category=${furniture}`);
expect(names(res.body)).toEqual(['Nested', 'Top']);
});
it('requires every listed tag rather than any of them', async () => {
const vintage = await createTag('vintage');
const oak = await createTag('oak');
await createItem('Both', 1000, { tagIds: [vintage, oak] });
await createItem('One', 1000, { tagIds: [vintage] });
const res = await request(app).get(`/api/admin/items?tags=${vintage},${oak}`);
expect(names(res.body)).toEqual(['Both']);
});
it('bounds the price range inclusively', async () => {
await createItem('Under', 900);
await createItem('Edge', 1000);
await createItem('Over', 5100);
const res = await request(app).get('/api/admin/items?min_price=1000&max_price=5000');
expect(names(res.body)).toEqual(['Edge']);
});
it('combines every filter with AND', async () => {
const furniture = await createCategory('Furniture');
const tables = await createCategory('Tables', furniture);
const vintage = await createTag('vintage');
await createItem('Match', 3000, { categoryId: tables, tagIds: [vintage], status: 'reserved' });
await createItem('Wrong status', 3000, { categoryId: tables, tagIds: [vintage], status: 'available' });
await createItem('Wrong category', 3000, { tagIds: [vintage], status: 'reserved' });
await createItem('Wrong price', 9000, { categoryId: tables, tagIds: [vintage], status: 'reserved' });
const res = await request(app).get(
`/api/admin/items?category=${furniture}&tags=${vintage}&min_price=1000&max_price=5000&status=reserved`
);
expect(names(res.body)).toEqual(['Match']);
});
it('rejects an unknown status rather than returning everything', async () => {
await createItem('A', 1000);
const res = await request(app).get('/api/admin/items?status=pending');
expect(res.status).toBe(400);
});
it('still returns admin-only columns alongside the filters', async () => {
await createItem('A', 1000, { status: 'reserved' });
const res = await request(app).get('/api/admin/items?status=reserved');
expect(res.body[0]).toHaveProperty('reserved_until');
expect(res.body[0]).toHaveProperty('tags');
});
});
describe('admin customer reservations', () => {
it('reports how many items each customer is holding', async () => {
const itemId = await createItem('Held', 1000);
const { agent, id } = await registerCustomer('holder@example.com');
await agent.post(`/api/cart/items/${itemId}`);
const res = await request(app).get('/api/admin/customers');
const customer = res.body.find((c: { id: number }) => c.id === id);
expect(Number(customer.reserved_count)).toBe(1);
});
it('reports zero for a customer holding nothing', async () => {
const { id } = await registerCustomer('empty@example.com');
const res = await request(app).get('/api/admin/customers');
const customer = res.body.find((c: { id: number }) => c.id === id);
expect(Number(customer.reserved_count)).toBe(0);
});
it('lists the items a customer is holding', async () => {
const itemId = await createItem('Oak table', 34000);
const { agent, id } = await registerCustomer('lister@example.com');
await agent.post(`/api/cart/items/${itemId}`);
const res = await request(app).get(`/api/admin/customers/${id}/reserved`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].name).toBe('Oak table');
expect(res.body[0].item_id).toBe(itemId);
expect(res.body[0].price_cents).toBe(34000);
expect(res.body[0].expires_at).toBeTruthy();
});
it('returns an empty list for a customer holding nothing', async () => {
const { id } = await registerCustomer('nothing@example.com');
const res = await request(app).get(`/api/admin/customers/${id}/reserved`);
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('releasing an item returns it to available and empties the cart row', async () => {
const itemId = await createItem('Oak table', 34000);
const { agent, id } = await registerCustomer('release@example.com');
await agent.post(`/api/cart/items/${itemId}`);
const res = await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
expect(res.status).toBe(204);
const item = await request(app).get(`/api/items/${itemId}`);
expect(item.body.status).toBe('available');
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM cart_items WHERE item_id = $1`, [itemId]);
expect(rows[0].n).toBe(0);
});
it('a released item stops counting against the customer', async () => {
const itemId = await createItem('Oak table', 34000);
const { agent, id } = await registerCustomer('recount@example.com');
await agent.post(`/api/cart/items/${itemId}`);
await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
const res = await request(app).get('/api/admin/customers');
const customer = res.body.find((c: { id: number }) => c.id === id);
expect(Number(customer.reserved_count)).toBe(0);
});
it('a released item can be reserved again by someone else', async () => {
const itemId = await createItem('Oak table', 34000);
const { agent: first, id } = await registerCustomer('first@example.com');
await first.post(`/api/cart/items/${itemId}`);
await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
const { agent: second } = await registerCustomer('second@example.com');
const res = await second.post(`/api/cart/items/${itemId}`);
expect(res.status).toBe(201);
});
it('refuses to release an item the customer is not holding', async () => {
const itemId = await createItem('Not theirs', 1000);
const { id } = await registerCustomer('other@example.com');
const res = await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
expect(res.status).toBe(404);
});
it('does not touch a sold item when releasing', async () => {
const itemId = await createItem('Sold out', 1000);
const { agent, id } = await registerCustomer('sold@example.com');
await agent.post(`/api/cart/items/${itemId}`);
await pool.query(`UPDATE items SET status = 'sold' WHERE id = $1`, [itemId]);
await request(app).post(`/api/admin/customers/${id}/reserved/${itemId}/release`);
const { rows } = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]);
expect(rows[0].status).toBe('sold');
});
});
+27 -1
View File
@@ -6,7 +6,8 @@ describe('parseItemFilters', () => {
categoryId: null,
tagIds: [],
minPriceCents: null,
maxPriceCents: null
maxPriceCents: null,
status: null
});
});
@@ -72,6 +73,25 @@ describe('parseItemFilters', () => {
it('rejects a repeated query param rather than guessing which one to use', () => {
expect(() => parseItemFilters({ category: ['1', '2'] })).toThrow(FilterError);
});
it('parses each of the item statuses', () => {
expect(parseItemFilters({ status: 'available' }).status).toBe('available');
expect(parseItemFilters({ status: 'reserved' }).status).toBe('reserved');
expect(parseItemFilters({ status: 'sold' }).status).toBe('sold');
});
it('treats an absent or empty status as no status filter', () => {
expect(parseItemFilters({}).status).toBeNull();
expect(parseItemFilters({ status: '' }).status).toBeNull();
});
it('rejects a status outside the known set', () => {
expect(() => parseItemFilters({ status: 'pending' })).toThrow(FilterError);
});
it('rejects a status differing only by case, rather than silently coercing it', () => {
expect(() => parseItemFilters({ status: 'Reserved' })).toThrow(FilterError);
});
});
describe('buildItemFilterSql', () => {
@@ -100,6 +120,12 @@ describe('buildItemFilterSql', () => {
expect(built.clauses.join(' ')).toContain('$3');
});
it('filters on status', () => {
const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1);
expect(built.clauses.join(' ')).toContain('i.status');
expect(built.params).toEqual(['reserved']);
});
it('continues numbering across multiple filters', () => {
const built = buildItemFilterSql(
parseItemFilters({ category: '4', min_price: '100', max_price: '900' }),