[object Object]

← 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

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 →