feat: filter the storefront by favorited items (#35)
Adds favorites as another dimension of the existing storefront filter rather than a separate view, so it lives in the URL, shows up as a removable chip, and combines with category, tags, and price by AND like everything else. A customer can ask for "my favorites under $500 in Furniture" instead of only "my favorites". Sold favorites are included. The storefront shows sold items everywhere else, and a favorite that has just sold is often exactly what the customer came back to look at after being emailed about it in #34. Hiding them would make items disappear from a list the customer curated themselves. Anyone wanting only what they can still buy can combine the toggle with the status filter. Which customer "my favorites" means comes from the session, never from the query string, so a hand-edited URL cannot name someone else's favorites. A signed-out visitor sees the toggle and gets the same inline register/login prompt the heart button and Add to Cart already use; signing in resolves the gate and the filter applies on its own. A bookmarked favorites link whose session has expired says so rather than rendering an empty grid, which would tell the visitor they have no favorites instead of that we do not know who they are. The API answers 401 for the same reason, and the admin inventory refuses the filter outright rather than ignoring it. The shared SQL builder now requires callers to say whose favorites they mean, even when that is nobody, and throws instead of dropping the clause — a future caller that forgets the guard fails loudly rather than quietly returning the whole catalogue. Verified with 59 unit tests, 134 backend integration tests, and 70 end-to-end tests, all passing, with type checking clean on both sides.
This commit is contained in:
@@ -10,6 +10,10 @@ export interface ItemFilters {
|
||||
minPriceCents: number | null;
|
||||
maxPriceCents: number | null;
|
||||
status: ItemStatus | null;
|
||||
// Storefront only: "just the items I have favorited". Which customer that
|
||||
// means is not part of the parsed filter — it comes from the session at build
|
||||
// time, so a query string can never name someone else's favorites.
|
||||
favoritesOnly: boolean;
|
||||
}
|
||||
|
||||
export type ItemStatus = 'available' | 'reserved' | 'sold';
|
||||
@@ -29,6 +33,12 @@ export interface BuiltFilter {
|
||||
// '10.5' are caller mistakes worth surfacing rather than silently coercing.
|
||||
const NON_NEGATIVE_INTEGER = /^\d+$/;
|
||||
|
||||
// Accepts both spellings because these URLs get hand-edited and shared, but
|
||||
// nothing else: '?favorites=yes' is a mistake worth reporting rather than
|
||||
// treating as either on or off.
|
||||
const TRUE_VALUES: readonly string[] = ['1', 'true'];
|
||||
const FALSE_VALUES: readonly string[] = ['0', 'false'];
|
||||
|
||||
function singleValue(value: unknown, name: string): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
@@ -107,12 +117,33 @@ export function parseItemFilters(query: Record<string, unknown>): ItemFilters {
|
||||
status = statusRaw as ItemStatus;
|
||||
}
|
||||
|
||||
return { categoryId, tagIds, minPriceCents, maxPriceCents, status };
|
||||
const favoritesRaw = singleValue(query.favorites, 'favorites');
|
||||
let favoritesOnly = false;
|
||||
if (favoritesRaw !== null && favoritesRaw !== '') {
|
||||
if (TRUE_VALUES.includes(favoritesRaw)) {
|
||||
favoritesOnly = true;
|
||||
} else if (!FALSE_VALUES.includes(favoritesRaw)) {
|
||||
throw new FilterError('invalid favorites');
|
||||
}
|
||||
}
|
||||
|
||||
return { categoryId, tagIds, minPriceCents, maxPriceCents, status, favoritesOnly };
|
||||
}
|
||||
|
||||
// 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 {
|
||||
//
|
||||
// `favoritesCustomerId` is required rather than optional so a caller has to say
|
||||
// whose favorites it means, even when it means nobody's. Both routes already
|
||||
// reject a favorites filter they cannot satisfy, so reaching the throw below is
|
||||
// a programming error — but it is here so that a future caller which forgets
|
||||
// the guard fails loudly instead of quietly ignoring the filter and listing the
|
||||
// whole catalogue.
|
||||
export function buildItemFilterSql(
|
||||
filters: ItemFilters,
|
||||
startIndex: number,
|
||||
favoritesCustomerId: number | null
|
||||
): BuiltFilter {
|
||||
const clauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let next = startIndex;
|
||||
@@ -164,5 +195,17 @@ export function buildItemFilterSql(filters: ItemFilters, startIndex: number): Bu
|
||||
next++;
|
||||
}
|
||||
|
||||
if (filters.favoritesOnly) {
|
||||
if (favoritesCustomerId === null) {
|
||||
throw new Error('favorites filter requires a customer id');
|
||||
}
|
||||
params.push(favoritesCustomerId);
|
||||
// EXISTS rather than a join: an item is favorited by a customer at most
|
||||
// once, but joining would still risk multiplying rows if that ever changed,
|
||||
// and this reads as the membership test it is.
|
||||
clauses.push(`EXISTS (SELECT 1 FROM favorites f WHERE f.item_id = i.id AND f.customer_id = $${next})`);
|
||||
next++;
|
||||
}
|
||||
|
||||
return { clauses, params };
|
||||
}
|
||||
|
||||
@@ -128,7 +128,13 @@ router.get('/items', asyncRoute(async (req: Request, res: Response) => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { clauses, params } = buildItemFilterSql(filters, 1);
|
||||
// Favorites belong to a customer, and the admin inventory view is not
|
||||
// browsing as one. Refused rather than ignored so the mistake is visible.
|
||||
if (filters.favoritesOnly) {
|
||||
return res.status(400).json({ error: 'favorites is not a valid inventory filter' });
|
||||
}
|
||||
|
||||
const { clauses, params } = buildItemFilterSql(filters, 1, null);
|
||||
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);
|
||||
|
||||
@@ -19,7 +19,16 @@ router.get('/', asyncRoute(async (req: Request, res: Response) => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { clauses, params } = buildItemFilterSql(filters, 1);
|
||||
// 401 rather than an empty list: a signed-out visitor asking for "my
|
||||
// favorites" has no favorites to be empty of, and answering with [] would
|
||||
// render as "no items match these filters" — a plausible-looking lie. The
|
||||
// storefront prompts for sign-in instead of sending this, so reaching here
|
||||
// means a bookmarked link outlived its session.
|
||||
if (filters.favoritesOnly && !req.customerId) {
|
||||
return res.status(401).json({ error: 'sign in to filter by favorites' });
|
||||
}
|
||||
|
||||
const { clauses, params } = buildItemFilterSql(filters, 1, req.customerId ?? null);
|
||||
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);
|
||||
|
||||
@@ -304,3 +304,89 @@ describe('notifying when a favorited item is deleted', () => {
|
||||
expect(rows[0].n).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering the storefront by favorites', () => {
|
||||
it('returns only the favorited items', async () => {
|
||||
const favorited = await createItem('Oak table');
|
||||
await createItem('Elm bench');
|
||||
const { agent } = await register('filter@example.com');
|
||||
await agent.post(`/api/customers/me/favorites/${favorited}`);
|
||||
|
||||
const res = await agent.get('/api/items?favorites=1');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
|
||||
});
|
||||
|
||||
it('keeps one customer out of another customer\'s favorites', async () => {
|
||||
const mine = await createItem('Oak table');
|
||||
const theirs = await createItem('Elm bench');
|
||||
const { agent: me } = await register('mine@example.com');
|
||||
const { agent: them } = await register('theirs@example.com');
|
||||
await me.post(`/api/customers/me/favorites/${mine}`);
|
||||
await them.post(`/api/customers/me/favorites/${theirs}`);
|
||||
|
||||
const res = await me.get('/api/items?favorites=1');
|
||||
|
||||
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
|
||||
});
|
||||
|
||||
it('still shows a favorite that has sold', async () => {
|
||||
const itemId = await createItem('Oak table');
|
||||
const { agent } = await register('sold@example.com');
|
||||
await agent.post(`/api/customers/me/favorites/${itemId}`);
|
||||
await pool.query(`UPDATE items SET status = 'sold' WHERE id = $1`, [itemId]);
|
||||
|
||||
const res = await agent.get('/api/items?favorites=1');
|
||||
|
||||
// The storefront shows sold items everywhere else, and a favorite that has
|
||||
// just sold is often exactly what the customer came back to look at.
|
||||
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
|
||||
});
|
||||
|
||||
it('combines with the other filters rather than replacing them', async () => {
|
||||
const cheap = await createItem('Oak table');
|
||||
const dear = await createItem('Elm bench');
|
||||
await pool.query(`UPDATE items SET price_cents = 90000 WHERE id = $1`, [dear]);
|
||||
const { agent } = await register('combined@example.com');
|
||||
await agent.post(`/api/customers/me/favorites/${cheap}`);
|
||||
await agent.post(`/api/customers/me/favorites/${dear}`);
|
||||
|
||||
const res = await agent.get('/api/items?favorites=1&max_price=50000');
|
||||
|
||||
expect(res.body.map((item: { name: string }) => item.name)).toEqual(['Oak table']);
|
||||
});
|
||||
|
||||
it('answers 401 rather than an empty list when nobody is signed in', async () => {
|
||||
await createItem('Oak table');
|
||||
|
||||
const res = await request(app).get('/api/items?favorites=1');
|
||||
|
||||
// An empty array would render as "no items match these filters", telling a
|
||||
// signed-out visitor they have no favorites instead of that we do not know
|
||||
// who they are.
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a favorites value that is neither on nor off', async () => {
|
||||
const { agent } = await register('bogus@example.com');
|
||||
|
||||
expect((await agent.get('/api/items?favorites=yes')).status).toBe(400);
|
||||
});
|
||||
|
||||
it('leaves the catalogue alone when the flag is off', async () => {
|
||||
await createItem('Oak table');
|
||||
await createItem('Elm bench');
|
||||
const { agent } = await register('off@example.com');
|
||||
|
||||
const res = await agent.get('/api/items?favorites=0');
|
||||
|
||||
expect(res.body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('refuses the favorites filter on the admin inventory', async () => {
|
||||
const res = await request(app).get('/api/admin/items?favorites=1');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,8 @@ describe('parseItemFilters', () => {
|
||||
tagIds: [],
|
||||
minPriceCents: null,
|
||||
maxPriceCents: null,
|
||||
status: null
|
||||
status: null,
|
||||
favoritesOnly: false
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +86,26 @@ describe('parseItemFilters', () => {
|
||||
expect(parseItemFilters({ status: '' }).status).toBeNull();
|
||||
});
|
||||
|
||||
it('parses the favorites flag in both spellings', () => {
|
||||
expect(parseItemFilters({ favorites: '1' }).favoritesOnly).toBe(true);
|
||||
expect(parseItemFilters({ favorites: 'true' }).favoritesOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an explicit off value as off', () => {
|
||||
expect(parseItemFilters({ favorites: '0' }).favoritesOnly).toBe(false);
|
||||
expect(parseItemFilters({ favorites: 'false' }).favoritesOnly).toBe(false);
|
||||
expect(parseItemFilters({ favorites: '' }).favoritesOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults favorites to off', () => {
|
||||
expect(parseItemFilters({}).favoritesOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a favorites value that is neither on nor off', () => {
|
||||
expect(() => parseItemFilters({ favorites: 'yes' })).toThrow(FilterError);
|
||||
expect(() => parseItemFilters({ favorites: 'mine' })).toThrow(FilterError);
|
||||
});
|
||||
|
||||
it('rejects a status outside the known set', () => {
|
||||
expect(() => parseItemFilters({ status: 'pending' })).toThrow(FilterError);
|
||||
});
|
||||
@@ -96,19 +117,19 @@ describe('parseItemFilters', () => {
|
||||
|
||||
describe('buildItemFilterSql', () => {
|
||||
it('produces no clauses and no params when nothing is filtered', () => {
|
||||
const built = buildItemFilterSql(parseItemFilters({}), 1);
|
||||
const built = buildItemFilterSql(parseItemFilters({}), 1, null);
|
||||
expect(built.clauses).toEqual([]);
|
||||
expect(built.params).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches a category and all of its descendants', () => {
|
||||
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 1);
|
||||
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 1, null);
|
||||
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);
|
||||
const built = buildItemFilterSql(parseItemFilters({ tags: '1,2' }), 1, null);
|
||||
// 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(*)');
|
||||
@@ -116,20 +137,41 @@ describe('buildItemFilterSql', () => {
|
||||
});
|
||||
|
||||
it('numbers placeholders from the given starting index', () => {
|
||||
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 3);
|
||||
const built = buildItemFilterSql(parseItemFilters({ category: '4' }), 3, null);
|
||||
expect(built.clauses.join(' ')).toContain('$3');
|
||||
});
|
||||
|
||||
it('filters on status', () => {
|
||||
const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1);
|
||||
const built = buildItemFilterSql(parseItemFilters({ status: 'reserved' }), 1, null);
|
||||
expect(built.clauses.join(' ')).toContain('i.status');
|
||||
expect(built.params).toEqual(['reserved']);
|
||||
});
|
||||
|
||||
it('restricts to the favorites of the given customer', () => {
|
||||
const built = buildItemFilterSql(parseItemFilters({ favorites: '1' }), 1, 42);
|
||||
expect(built.clauses.join(' ')).toContain('EXISTS');
|
||||
expect(built.clauses.join(' ')).toContain('favorites f');
|
||||
expect(built.params).toEqual([42]);
|
||||
});
|
||||
|
||||
it('does not restrict to favorites when the flag is off, even given a customer', () => {
|
||||
const built = buildItemFilterSql(parseItemFilters({}), 1, 42);
|
||||
expect(built.clauses).toEqual([]);
|
||||
expect(built.params).toEqual([]);
|
||||
});
|
||||
|
||||
// Both routes reject this before reaching the builder, so it can only happen
|
||||
// through a new caller that forgot to. Failing loudly beats dropping the
|
||||
// clause and returning the whole catalogue as if it were someone's favorites.
|
||||
it('throws rather than ignore a favorites filter with no customer', () => {
|
||||
expect(() => buildItemFilterSql(parseItemFilters({ favorites: '1' }), 1, null)).toThrow();
|
||||
});
|
||||
|
||||
it('continues numbering across multiple filters', () => {
|
||||
const built = buildItemFilterSql(
|
||||
parseItemFilters({ category: '4', min_price: '100', max_price: '900' }),
|
||||
1
|
||||
1,
|
||||
null
|
||||
);
|
||||
expect(built.params).toEqual([4, 100, 900]);
|
||||
const sql = built.clauses.join(' ');
|
||||
|
||||
Reference in New Issue
Block a user