[object Object]

← back to Lifestyle Asset Intel

yolo tick #32: /api/audit/recent ?method= filter (HTTP verb, case-insensitive, composes with route+asset_slug); OpenAPI documents route+method params

369b470cc2dcbb7f6d6d5b8fa0daf9ad9fc1e2e6 · 2026-05-14 08:50:46 -0700 · Steve Abrams

Files touched

Diff

commit 369b470cc2dcbb7f6d6d5b8fa0daf9ad9fc1e2e6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 14 08:50:46 2026 -0700

    yolo tick #32: /api/audit/recent ?method= filter (HTTP verb, case-insensitive, composes with route+asset_slug); OpenAPI documents route+method params
---
 lib/audit.js        |  6 +++++-
 lib/openapi.js      |  7 +++++--
 routes/api.js       |  9 ++++++++-
 tests/audit.test.js | 37 +++++++++++++++++++++++++++++++++++++
 4 files changed, 55 insertions(+), 4 deletions(-)

diff --git a/lib/audit.js b/lib/audit.js
index bf4a09f..058a858 100644
--- a/lib/audit.js
+++ b/lib/audit.js
@@ -89,7 +89,7 @@ function auditMiddleware(req, res, next) {
   next();
 }
 
-async function recentCalls({ limit = 100, asset_slug = null, route = null } = {}) {
+async function recentCalls({ limit = 100, asset_slug = null, route = null, method = null } = {}) {
   const lim = Math.max(1, Math.min(1000, parseInt(limit, 10) || 100));
   const where = [];
   const params = [];
@@ -101,6 +101,10 @@ async function recentCalls({ limit = 100, asset_slug = null, route = null } = {}
     params.push(`%${route.trim().toLowerCase()}%`);
     where.push(`LOWER(route) LIKE $${params.length}`);
   }
+  if (method && typeof method === 'string' && method.trim()) {
+    params.push(method.trim().toUpperCase());
+    where.push(`UPPER(method) = $${params.length}`);
+  }
   params.push(lim);
   const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
   const r = await pool.query(
diff --git a/lib/openapi.js b/lib/openapi.js
index 47756b3..3614fb2 100644
--- a/lib/openapi.js
+++ b/lib/openapi.js
@@ -556,11 +556,14 @@ function buildSpec({ version, gitSha }) {
         summary: 'Recent valuation_calls audit entries',
         description:
           'Immutable audit log per METHODOLOGY § 14. Returns most recent N (default 100) ' +
-          'calls in descending called_at order. Optional asset_slug filter.',
+          'calls in descending called_at order. Optional filters compose with AND: ' +
+          'asset_slug (exact match), route (case-insensitive substring), method (exact, case-insensitive).',
         tags: ['ops'],
         parameters: [
           { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 1000, default: 100 } },
-          { name: 'asset_slug', in: 'query', schema: { type: 'string' } }
+          { name: 'asset_slug', in: 'query', schema: { type: 'string' } },
+          { name: 'route', in: 'query', description: 'Case-insensitive substring match against route template', schema: { type: 'string' } },
+          { name: 'method', in: 'query', description: 'Exact HTTP method, case-insensitive', schema: { type: 'string', example: 'GET' } }
         ],
         responses: {
           200: { description: 'OK', content: { 'application/json': { schema: {
diff --git a/routes/api.js b/routes/api.js
index 6728b89..a3b0d1f 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -242,11 +242,18 @@ router.get('/audit/recent', setCache(NO_STORE), async (req, res, next) => {
     const limit = req.query.limit;
     const asset_slug = req.query.asset_slug || null;
     const route = typeof req.query.route === 'string' ? req.query.route.trim() : '';
-    const rows = await recentCalls({ limit, asset_slug, route: route || null });
+    const method = typeof req.query.method === 'string' ? req.query.method.trim() : '';
+    const rows = await recentCalls({
+      limit,
+      asset_slug,
+      route: route || null,
+      method: method || null
+    });
     res.json({
       count: rows.length,
       asset_slug: asset_slug || null,
       route: route || null,
+      method: method ? method.toUpperCase() : null,
       calls: rows
     });
   } catch (e) { next(e); }
diff --git a/tests/audit.test.js b/tests/audit.test.js
index bc56b89..db25a8a 100644
--- a/tests/audit.test.js
+++ b/tests/audit.test.js
@@ -163,6 +163,43 @@ test('GET /api/audit/recent?route filters by route substring (case-insensitive)
   assert.equal(r3.json.route, null);
 });
 
+test('GET /api/audit/recent?method filters by HTTP method (case-insensitive) and composes', async () => {
+  // Prime the audit table with a couple GET rows.
+  await getJson('/api/valuation/birkin-30-togo-gold-ghw-us');
+  await getJson('/api/assets');
+  await new Promise((res) => setTimeout(res, 250));
+
+  // Lowercase input proves case-insensitivity; response should normalize to upper.
+  const r = await getJson('/api/audit/recent?method=get&limit=20');
+  assert.equal(r.status, 200);
+  assert.equal(r.json.method, 'GET');
+  assert.ok(r.json.calls.length >= 1, 'at least one GET row');
+  for (const c of r.json.calls) {
+    assert.equal(c.method, 'GET');
+  }
+
+  // Composes cleanly with route filter.
+  const r2 = await getJson('/api/audit/recent?method=GET&route=valuation&limit=20');
+  assert.equal(r2.status, 200);
+  for (const c of r2.json.calls) {
+    assert.equal(c.method, 'GET');
+    assert.match(c.route, /\/api\/valuation/);
+  }
+
+  // No matches for a method we never call against the audit route.
+  const r3 = await getJson('/api/audit/recent?method=PUT&limit=20');
+  assert.equal(r3.status, 200);
+  assert.equal(r3.json.method, 'PUT');
+  for (const c of r3.json.calls) {
+    assert.equal(c.method, 'PUT');
+  }
+
+  // Empty string method should NOT filter (behaves like no param).
+  const r4 = await getJson('/api/audit/recent?method=&limit=5');
+  assert.equal(r4.status, 200);
+  assert.equal(r4.json.method, 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

← 01bd2e3 yolo tick #31: /api/audit/recent ?route= substring filter (c  ·  back to Lifestyle Asset Intel  ·  yolo tick #33: /api/audit/recent ?status= filter (exact 3-di 674dc23 →