[object Object]

← back to Lifestyle Asset Intel

yolo tick #31: /api/audit/recent ?route= substring filter (case-insensitive, composes with asset_slug)

01bd2e3179b515b008ac3a2fdaf827c4cf4c1ea6 · 2026-05-14 08:16:22 -0700 · Steve Abrams

Files touched

Diff

commit 01bd2e3179b515b008ac3a2fdaf827c4cf4c1ea6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 14 08:16:22 2026 -0700

    yolo tick #31: /api/audit/recent ?route= substring filter (case-insensitive, composes with asset_slug)
---
 lib/audit.js        | 28 ++++++++++++++--------------
 routes/api.js       | 10 ++++++++--
 tests/audit.test.js | 31 +++++++++++++++++++++++++++++++
 3 files changed, 53 insertions(+), 16 deletions(-)

diff --git a/lib/audit.js b/lib/audit.js
index b4d28f9..bf4a09f 100644
--- a/lib/audit.js
+++ b/lib/audit.js
@@ -89,29 +89,29 @@ function auditMiddleware(req, res, next) {
   next();
 }
 
-async function recentCalls({ limit = 100, asset_slug = null } = {}) {
+async function recentCalls({ limit = 100, asset_slug = null, route = null } = {}) {
   const lim = Math.max(1, Math.min(1000, parseInt(limit, 10) || 100));
+  const where = [];
+  const params = [];
   if (asset_slug) {
-    const r = await pool.query(
-      `SELECT id, called_at, caller_ip::text AS caller_ip,
-              route, method, asset_slug, response_status,
-              methodology_version, latency_ms, user_agent, request_id
-         FROM valuation_calls
-        WHERE asset_slug = $1
-        ORDER BY called_at DESC
-        LIMIT $2`,
-      [asset_slug, lim]
-    );
-    return r.rows;
+    params.push(asset_slug);
+    where.push(`asset_slug = $${params.length}`);
   }
+  if (route && typeof route === 'string' && route.trim()) {
+    params.push(`%${route.trim().toLowerCase()}%`);
+    where.push(`LOWER(route) LIKE $${params.length}`);
+  }
+  params.push(lim);
+  const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
   const r = await pool.query(
     `SELECT id, called_at, caller_ip::text AS caller_ip,
             route, method, asset_slug, response_status,
             methodology_version, latency_ms, user_agent, request_id
        FROM valuation_calls
+       ${whereSql}
       ORDER BY called_at DESC
-      LIMIT $1`,
-    [lim]
+      LIMIT $${params.length}`,
+    params
   );
   return r.rows;
 }
diff --git a/routes/api.js b/routes/api.js
index 27721b9..6728b89 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -241,8 +241,14 @@ router.get('/audit/recent', setCache(NO_STORE), async (req, res, next) => {
   try {
     const limit = req.query.limit;
     const asset_slug = req.query.asset_slug || null;
-    const rows = await recentCalls({ limit, asset_slug });
-    res.json({ count: rows.length, calls: rows });
+    const route = typeof req.query.route === 'string' ? req.query.route.trim() : '';
+    const rows = await recentCalls({ limit, asset_slug, route: route || null });
+    res.json({
+      count: rows.length,
+      asset_slug: asset_slug || null,
+      route: route || null,
+      calls: rows
+    });
   } catch (e) { next(e); }
 });
 
diff --git a/tests/audit.test.js b/tests/audit.test.js
index 39e30b7..bc56b89 100644
--- a/tests/audit.test.js
+++ b/tests/audit.test.js
@@ -132,6 +132,37 @@ test('GET /api/audit/recent?asset_slug filters', async () => {
   }
 });
 
+test('GET /api/audit/recent?route filters by route substring (case-insensitive) and composes with asset_slug', async () => {
+  // Prime the table with at least one /api/valuation row + one non-matching row.
+  await getJson('/api/valuation/birkin-30-togo-gold-ghw-us');
+  await getJson('/api/assets');
+  await new Promise((res) => setTimeout(res, 250));
+
+  // Substring match — uppercase input proves case-insensitivity.
+  const r = await getJson('/api/audit/recent?route=VALUATION&limit=50');
+  assert.equal(r.status, 200);
+  assert.equal(r.json.route, 'VALUATION');
+  assert.ok(r.json.calls.length >= 1, 'at least one /api/valuation row');
+  for (const c of r.json.calls) {
+    assert.match(c.route.toLowerCase(), /valuation/);
+  }
+
+  // Composes cleanly with asset_slug.
+  const r2 = await getJson(
+    '/api/audit/recent?route=valuation&asset_slug=birkin-30-togo-gold-ghw-us'
+  );
+  assert.equal(r2.status, 200);
+  for (const c of r2.json.calls) {
+    assert.match(c.route, /\/api\/valuation/);
+    assert.equal(c.asset_slug, 'birkin-30-togo-gold-ghw-us');
+  }
+
+  // Empty string route should NOT filter (behaves like no param).
+  const r3 = await getJson('/api/audit/recent?route=&limit=5');
+  assert.equal(r3.status, 200);
+  assert.equal(r3.json.route, null);
+});
+
 test('valuation_calls has no UPDATE or DELETE called by app code', async () => {
   // Sanity: app should never mutate the table. A grep would verify
   // this at code-review time, but the fastest check at runtime is that

← 456f827 yolo tick #30: /api/sources ?q= filter (slug/name/kind/notes  ·  back to Lifestyle Asset Intel  ·  yolo tick #32: /api/audit/recent ?method= filter (HTTP verb, 369b470 →