[object Object]

← back to Lifestyle Asset Intel

yolo tick #30: /api/sources ?q= filter (slug/name/kind/notes, case-insensitive, composes with sort)

456f8276e146c4b34119321026ced17c5e60ddd9 · 2026-05-14 01:49:12 -0700 · Steve Abrams

Author: Steve Abrams <steve@designerwallcoverings.com>

Files touched

Diff

commit 456f8276e146c4b34119321026ced17c5e60ddd9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 14 01:49:12 2026 -0700

    yolo tick #30: /api/sources ?q= filter (slug/name/kind/notes, case-insensitive, composes with sort)
    
    Author: Steve Abrams <steve@designerwallcoverings.com>
---
 lib/openapi.js      | 15 ++++++++++-----
 routes/api.js       | 26 +++++++++++++++++++++++---
 tests/smoke.test.js | 30 ++++++++++++++++++++++++++++++
 3 files changed, 63 insertions(+), 8 deletions(-)

diff --git a/lib/openapi.js b/lib/openapi.js
index e9e4170..47756b3 100644
--- a/lib/openapi.js
+++ b/lib/openapi.js
@@ -296,12 +296,16 @@ function buildSpec({ version, gitSha }) {
           'quote desks, retail) with its tier, kind, weight, and enabled flag. ' +
           'Default order is by tier then name. Pass ?sort= to switch — supported ' +
           'modes: name, slug, tier, tier-desc, weight-desc, weight-asc, kind, ' +
-          'enabled. Unknown sort values fall back to the default order.',
+          'enabled. Unknown sort values fall back to the default order. ' +
+          'Pass ?q= for a case-insensitive substring filter against slug, name, ' +
+          'kind, or notes; composes with ?sort=.',
         tags: ['catalog'],
-        parameters: [{
-          name: 'sort', in: 'query', required: false,
-          schema: { type: 'string' }, example: 'weight-desc'
-        }],
+        parameters: [
+          { name: 'q', in: 'query', required: false,
+            schema: { type: 'string' }, example: 'auction' },
+          { name: 'sort', in: 'query', required: false,
+            schema: { type: 'string' }, example: 'weight-desc' }
+        ],
         responses: {
           200: {
             description: 'OK',
@@ -309,6 +313,7 @@ function buildSpec({ version, gitSha }) {
               type: 'object',
               properties: {
                 count: { type: 'integer' },
+                q: { type: 'string', nullable: true },
                 sort: { type: 'string', nullable: true },
                 sources: { type: 'array', items: { $ref: '#/components/schemas/Source' } }
               }
diff --git a/routes/api.js b/routes/api.js
index e8e11d7..27721b9 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -140,12 +140,32 @@ router.get('/sources', setCache(PUB_300), async (req, res, next) => {
   try {
     const sort = typeof req.query.sort === 'string' ? req.query.sort.trim() : '';
     const orderBy = SOURCE_SORTS[sort] || SOURCE_SORTS.default;
+    const qRaw = typeof req.query.q === 'string' ? req.query.q.trim() : '';
+    // Case-insensitive substring filter across the human-meaningful
+    // fields. Notes is included because seeded source rows carry the
+    // "auction"/"marketplace"/"index" colour in plain English there.
+    const params = [];
+    let whereSql = '';
+    if (qRaw) {
+      params.push(`%${qRaw.toLowerCase()}%`);
+      whereSql =
+        ' WHERE LOWER(slug) LIKE $1 ' +
+        '    OR LOWER(name) LIKE $1 ' +
+        '    OR LOWER(kind) LIKE $1 ' +
+        '    OR LOWER(COALESCE(notes, \'\')) LIKE $1';
+    }
     const r = await pool.query(
       `SELECT slug, name, tier, kind, weight, enabled, url, notes
-         FROM sources
-        ORDER BY ${orderBy}`
+         FROM sources${whereSql}
+        ORDER BY ${orderBy}`,
+      params
     );
-    res.json({ count: r.rows.length, sort: sort || null, sources: r.rows });
+    res.json({
+      count: r.rows.length,
+      q: qRaw || null,
+      sort: sort || null,
+      sources: r.rows
+    });
   } catch (e) { next(e); }
 });
 
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index b5db9ad..75cbb8c 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -202,6 +202,36 @@ test('GET /api/sources?sort=name returns alphabetical by name', async () => {
   assert.deepEqual(names, sorted, 'names returned in alphabetical order');
 });
 
+test('GET /api/sources?q=auction filters by kind/notes (case-insensitive)', async () => {
+  const r = await get('/api/sources?q=AUCTION');
+  assert.equal(r.status, 200);
+  assert.equal(r.json.q, 'AUCTION');
+  assert.ok(r.json.sources.length >= 2, 'sothebys + christies seeded as kind=auction');
+  for (const s of r.json.sources) {
+    const hay = `${s.slug} ${s.name} ${s.kind} ${s.notes || ''}`.toLowerCase();
+    assert.ok(hay.includes('auction'), `${s.slug} should match auction in slug/name/kind/notes`);
+  }
+});
+
+test('GET /api/sources?q=sothebys&sort=weight-desc composes filter + sort', async () => {
+  const r = await get('/api/sources?q=sothebys&sort=weight-desc');
+  assert.equal(r.status, 200);
+  assert.equal(r.json.q, 'sothebys');
+  assert.equal(r.json.sort, 'weight-desc');
+  assert.ok(r.json.sources.length >= 1);
+  for (const s of r.json.sources) {
+    const hay = `${s.slug} ${s.name} ${s.kind} ${s.notes || ''}`.toLowerCase();
+    assert.ok(hay.includes('sothebys') || hay.includes("sotheby's"));
+  }
+});
+
+test('GET /api/sources?q=zzznomatch returns empty list (no error)', async () => {
+  const r = await get('/api/sources?q=zzznomatch');
+  assert.equal(r.status, 200);
+  assert.equal(r.json.count, 0);
+  assert.deepEqual(r.json.sources, []);
+});
+
 test('GET /api/sources?sort=garbage falls back to default ordering', async () => {
   const r = await get('/api/sources?sort=garbage');
   assert.equal(r.status, 200);

← 4206774 yolo tick #29: /api/index ?q= filter (slug/name, case-insens  ·  back to Lifestyle Asset Intel  ·  yolo tick #31: /api/audit/recent ?route= substring filter (c 01bd2e3 →