← back to Lifestyle Asset Intel
yolo tick #28: /api/family ?q= filter (slug/name/brand, case-insensitive, composes with sort)
5b70e7f888fb367510822297442416a5c9948071 · 2026-05-14 00:45:46 -0700 · Steve Abrams
Files touched
M lib/openapi.jsM lib/valuation.jsM routes/api.jsM tests/smoke.test.js
Diff
commit 5b70e7f888fb367510822297442416a5c9948071
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 14 00:45:46 2026 -0700
yolo tick #28: /api/family ?q= filter (slug/name/brand, case-insensitive, composes with sort)
---
lib/openapi.js | 14 +++++++++++++-
lib/valuation.js | 19 ++++++++++++++++++-
routes/api.js | 10 ++++++++--
tests/smoke.test.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 85 insertions(+), 4 deletions(-)
diff --git a/lib/openapi.js b/lib/openapi.js
index d7234d0..fe44e28 100644
--- a/lib/openapi.js
+++ b/lib/openapi.js
@@ -367,13 +367,25 @@ function buildSpec({ version, gitSha }) {
'Returns one row per model family: brand identity, asset_count, ' +
'total_comps_24m (transactions in trailing 24 months), and avg_q50 ' +
'(unweighted mean of latest snapshot Q50 across the cohort). ' +
- 'Ordered by brand name, then family name.',
+ 'Default order is brand name then family name. Optional ?q= filter ' +
+ 'narrows by case-insensitive substring against family slug/name or ' +
+ 'brand slug/name. Optional ?sort= switches order — supported modes: ' +
+ 'name, slug, assets-desc, comps-desc, value-desc, value-asc. ' +
+ 'Unknown sort values fall back to default ordering.',
tags: ['catalog'],
+ parameters: [
+ { name: 'q', in: 'query', required: false,
+ schema: { type: 'string' }, example: 'birkin' },
+ { name: 'sort', in: 'query', required: false,
+ schema: { type: 'string' }, example: 'assets-desc' }
+ ],
responses: {
200: { description: 'OK', content: { 'application/json': { schema: {
type: 'object',
properties: {
count: { type: 'integer' },
+ q: { type: 'string', nullable: true },
+ sort: { type: 'string', nullable: true },
families: { type: 'array', items: {
type: 'object',
properties: {
diff --git a/lib/valuation.js b/lib/valuation.js
index c584ce0..4bc06c8 100644
--- a/lib/valuation.js
+++ b/lib/valuation.js
@@ -507,9 +507,24 @@ function computeExpectedDts(liquidity) {
// Single SQL pass — does NOT call listAssets() per family, so this
// stays cheap as the catalog grows.
//
+// opts.q (string) — case-insensitive filter token. Matches against
+// any of: family slug, family name, brand slug,
+// brand name. Mirrors the /api/assets q semantics.
// opts.sort (string) — see FAMILY_SORTS. Default ordering is brand+name.
async function listFamilies(opts) {
+ const q = opts && typeof opts.q === 'string' ? opts.q.trim() : '';
const sort = opts && typeof opts.sort === 'string' ? opts.sort.trim() : '';
+ const params = [];
+ let whereSql = '';
+ if (q) {
+ params.push(`%${q.toLowerCase()}%`);
+ const i = '$' + params.length;
+ whereSql = `
+ WHERE LOWER(mf.slug) LIKE ${i}
+ OR LOWER(mf.name) LIKE ${i}
+ OR LOWER(b.slug) LIKE ${i}
+ OR LOWER(b.name) LIKE ${i}`;
+ }
const rows = await many(
`SELECT mf.slug, mf.name,
b.slug AS brand_slug, b.name AS brand_name,
@@ -530,8 +545,10 @@ async function listFamilies(opts) {
WHERE t.canonical_asset_id = ca.id
AND t.transacted_at > now() - interval '24 months'
) stats ON true
+ ${whereSql}
GROUP BY mf.slug, mf.name, b.slug, b.name
- ORDER BY b.name, mf.name`
+ ORDER BY b.name, mf.name`,
+ params
);
const enriched = rows.map((r) => ({
slug: r.slug,
diff --git a/routes/api.js b/routes/api.js
index 5f42c0b..e7dc5e9 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -170,9 +170,15 @@ router.get('/valuation/:slug', setCache(NO_STORE), async (req, res, next) => {
router.get('/family', setCache(PUB_300), async (req, res, next) => {
try {
+ const q = typeof req.query.q === 'string' ? req.query.q : '';
const sort = typeof req.query.sort === 'string' ? req.query.sort : '';
- const families = await listFamilies({ sort });
- res.json({ count: families.length, sort: sort || null, families });
+ const families = await listFamilies({ q, sort });
+ res.json({
+ count: families.length,
+ q: q || null,
+ sort: sort || null,
+ families
+ });
} catch (e) { next(e); }
});
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index 774b219..3f499cc 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -314,6 +314,52 @@ test('GET /api/family?sort=bogus falls back to default ordering (no error)', asy
assert.ok(r.json.families.length >= 2);
});
+test('GET /api/family?q=birkin narrows result to matching family', async () => {
+ const r = await get('/api/family?q=birkin');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.q, 'birkin');
+ assert.equal(r.json.sort, null);
+ assert.ok(r.json.families.length >= 1);
+ for (const f of r.json.families) {
+ const hay = `${f.slug} ${f.name} ${f.brand && f.brand.slug} ${f.brand && f.brand.name}`.toLowerCase();
+ assert.ok(hay.includes('birkin'), `${f.slug} should match birkin in slug/name/brand`);
+ }
+});
+
+test('GET /api/family?q=hermes matches via brand and surfaces ≥2 families', async () => {
+ const r = await get('/api/family?q=hermes');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.q, 'hermes');
+ assert.ok(r.json.families.length >= 2, 'birkin + kelly both Hermès');
+ for (const f of r.json.families) {
+ assert.equal(f.brand.slug, 'hermes');
+ }
+});
+
+test('GET /api/family?q=birkin&sort=assets-desc composes filter + sort', async () => {
+ const r = await get('/api/family?q=birkin&sort=assets-desc');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.q, 'birkin');
+ assert.equal(r.json.sort, 'assets-desc');
+ const counts = r.json.families.map((f) => f.asset_count || 0);
+ for (let i = 1; i < counts.length; i++) {
+ assert.ok(counts[i] <= counts[i - 1],
+ `assets-desc broken at idx ${i}: ${counts[i - 1]} < ${counts[i]}`);
+ }
+ for (const f of r.json.families) {
+ const hay = `${f.slug} ${f.name} ${f.brand.slug} ${f.brand.name}`.toLowerCase();
+ assert.ok(hay.includes('birkin'));
+ }
+});
+
+test('GET /api/family?q=xyzzy-no-match returns empty families list', async () => {
+ const r = await get('/api/family?q=xyzzy-no-match');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.q, 'xyzzy-no-match');
+ assert.equal(r.json.count, 0);
+ assert.deepEqual(r.json.families, []);
+});
+
test('GET /family renders the families index page', async () => {
const r = await get('/family');
assert.equal(r.status, 200);
← c3d5177 yolo tick #27: /api/sources ?sort= (weight/tier/name/slug/ki
·
back to Lifestyle Asset Intel
·
yolo tick #29: /api/index ?q= filter (slug/name, case-insens 4206774 →