← 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
M lib/openapi.jsM routes/api.jsM tests/smoke.test.js
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 →