[object Object]

← back to AbramsOS

tick 13: /recalls dashboard UI (read-only viewer for recall_match)

3877b565350522a9b64aba32b1349e4e2e1e4739 · 2026-05-10 09:44:20 -0700 · Steve

- routes/recalls.js: GET /recalls (HTML), GET /api/recalls (JSON)
  · joins recall_match × recall_event × purchase
  · status filter (pending_review/confirmed/dismissed) with chip-counts
- views/recalls.ejs: confidence-coded cards (red ≥85%, amber otherwise)
  with CPSC source link
- nav: /recalls added between /claims and /audit
- 2 new tests (auth-gate + API-gate)

Files touched

Diff

commit 3877b565350522a9b64aba32b1349e4e2e1e4739
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun May 10 09:44:20 2026 -0700

    tick 13: /recalls dashboard UI (read-only viewer for recall_match)
    
    - routes/recalls.js: GET /recalls (HTML), GET /api/recalls (JSON)
      · joins recall_match × recall_event × purchase
      · status filter (pending_review/confirmed/dismissed) with chip-counts
    - views/recalls.ejs: confidence-coded cards (red ≥85%, amber otherwise)
      with CPSC source link
    - nav: /recalls added between /claims and /audit
    - 2 new tests (auth-gate + API-gate)
---
 routes/recalls.js         | 52 ++++++++++++++++++++++++++++++++++++++++++
 server.js                 |  2 ++
 tests/recalls.test.js     | 35 +++++++++++++++++++++++++++++
 views/partials/header.ejs |  1 +
 views/recalls.ejs         | 57 +++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 147 insertions(+)

diff --git a/routes/recalls.js b/routes/recalls.js
new file mode 100644
index 0000000..35b4d45
--- /dev/null
+++ b/routes/recalls.js
@@ -0,0 +1,52 @@
+// /recalls — read-only viewer for recall_match × recall_event × purchase.
+
+const express = require('express');
+const db = require('../lib/db');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+router.get('/recalls', async (req, res) => {
+  const status = req.query.status || null;
+  const params = [DEV_USER_ID];
+  let where = `rm.user_id = $1`;
+  if (status) {
+    params.push(status);
+    where += ` AND rm.status = $${params.length}`;
+  }
+  const r = await db.query(
+    `SELECT rm.id AS match_id, rm.confidence, rm.status, rm.matched_at,
+            re.id AS recall_id, re.authority, re.external_id, re.title, re.hazard, re.remedy, re.url, re.published_at,
+            p.id AS purchase_id, p.merchant_name, p.purchase_date, p.total_amount, p.currency
+       FROM recall_match rm
+       JOIN recall_event re ON re.id = rm.recall_id
+  LEFT JOIN purchase p ON p.id = rm.asset_id AND rm.asset_table = 'purchase'
+      WHERE ${where}
+      ORDER BY rm.matched_at DESC
+      LIMIT 200`,
+    params
+  );
+
+  const counts = await db.query(
+    `SELECT status, count(*)::int AS n FROM recall_match WHERE user_id = $1 GROUP BY status ORDER BY status`,
+    [DEV_USER_ID]
+  );
+
+  res.render('recalls', { matches: r.rows, counts: counts.rows, activeStatus: status });
+});
+
+router.get('/api/recalls', async (_req, res) => {
+  const r = await db.query(
+    `SELECT rm.id AS match_id, rm.confidence, rm.status, rm.matched_at,
+            re.id AS recall_id, re.title, re.url, p.merchant_name
+       FROM recall_match rm
+       JOIN recall_event re ON re.id = rm.recall_id
+  LEFT JOIN purchase p ON p.id = rm.asset_id AND rm.asset_table = 'purchase'
+      WHERE rm.user_id = $1
+      ORDER BY rm.matched_at DESC LIMIT 200`,
+    [DEV_USER_ID]
+  );
+  res.json(r.rows);
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 59a2455..95fc750 100644
--- a/server.js
+++ b/server.js
@@ -20,6 +20,7 @@ const documentsRouter = require('./routes/documents');
 const remindersRouter = require('./routes/reminders');
 const claimsRouter = require('./routes/claims');
 const auditRouter = require('./routes/audit');
+const recallsRouter = require('./routes/recalls');
 
 const app = express();
 const PORT = parseInt(process.env.PORT || '9931', 10);
@@ -66,6 +67,7 @@ app.use(documentsRouter);           // /api/documents, /api/documents/:id/parse
 app.use(remindersRouter);           // /api/reminders/upcoming, regenerate, dismiss
 app.use(claimsRouter);              // /api/claims, /api/claims/from-reminder/:id
 app.use(auditRouter);               // /audit, /api/audit/events
+app.use(recallsRouter);             // /recalls, /api/recalls
 
 // Step-up-required routes (must re-verify TOTP within 60s)
 app.use('/import', requireStepUp, importRouter);
diff --git a/tests/recalls.test.js b/tests/recalls.test.js
new file mode 100644
index 0000000..ed0ca39
--- /dev/null
+++ b/tests/recalls.test.js
@@ -0,0 +1,35 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+
+require('dotenv').config();
+const app = require('../server');
+const { pool } = require('../lib/db');
+
+let server;
+test.before(() => new Promise((r) => { server = app.listen(0, r); }));
+test.after(async () => {
+  await new Promise((r) => server.close(r));
+  await pool.end();
+});
+
+function get(p) {
+  return new Promise((resolve, reject) => {
+    const port = server.address().port;
+    http.get(`http://127.0.0.1:${port}${p}`, (res) => {
+      let body = '';
+      res.on('data', (c) => (body += c));
+      res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers }));
+    }).on('error', reject);
+  });
+}
+
+test('/recalls is auth-gated', async () => {
+  const r = await get('/recalls');
+  assert.strictEqual(r.status, 302);
+});
+
+test('/api/recalls is auth-gated', async () => {
+  const r = await get('/api/recalls');
+  assert.ok([302, 401].includes(r.status));
+});
diff --git a/views/partials/header.ejs b/views/partials/header.ejs
index f1d381c..16e7658 100644
--- a/views/partials/header.ejs
+++ b/views/partials/header.ejs
@@ -26,6 +26,7 @@
       <a href="/connectors">Connectors</a>
       <a href="/purchases">Purchases</a>
       <a href="/claims">Claims</a>
+      <a href="/recalls">Recalls</a>
       <a href="/audit">Audit</a>
     </nav>
     <% if (typeof userId !== 'undefined' && userId) { %>
diff --git a/views/recalls.ejs b/views/recalls.ejs
new file mode 100644
index 0000000..9329e0c
--- /dev/null
+++ b/views/recalls.ejs
@@ -0,0 +1,57 @@
+<%- include('partials/header', { title: 'Recalls' }) %>
+
+<section class="page-head">
+  <h1>Recall matches</h1>
+  <p class="subtle" style="margin:0;color:var(--text-dim);font-size:13px">CPSC recalls auto-matched to your purchases. Confidence ≥ 0.60 lands here for review.</p>
+</section>
+
+<section class="glass" style="padding:14px 18px">
+  <div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;font-size:12px">
+    <span style="color:var(--text-dim);margin-right:6px">Filter:</span>
+    <a href="/recalls" style="padding:3px 10px;border-radius:999px;background:<%= !activeStatus ? 'rgba(125,211,252,0.15)' : 'var(--surface-strong)' %>;color:<%= !activeStatus ? 'var(--accent)' : 'var(--text-dim)' %>;text-decoration:none">all</a>
+    <% counts.forEach(c => { %>
+      <a href="/recalls?status=<%= encodeURIComponent(c.status) %>" style="padding:3px 10px;border-radius:999px;background:<%= activeStatus === c.status ? 'rgba(125,211,252,0.15)' : 'var(--surface-strong)' %>;color:<%= activeStatus === c.status ? 'var(--accent)' : 'var(--text-dim)' %>;text-decoration:none">
+        <%= c.status %> · <%= c.n %>
+      </a>
+    <% }) %>
+  </div>
+</section>
+
+<% if (!matches.length) { %>
+  <section class="empty glass">
+    <p>No recall matches in this view. The CPSC sync runs daily at 03:17; matches will appear here as soon as one of your purchases overlaps.</p>
+  </section>
+<% } else { %>
+  <section class="connector-grid">
+    <% matches.forEach(m => { %>
+      <article class="connector-card glass" style="display:flex;flex-direction:column;gap:10px">
+        <header>
+          <span class="name">
+            <span class="icon" style="background:<%= m.confidence >= 0.85 ? 'rgba(248,113,113,0.20)' : 'rgba(251,191,36,0.20)' %>;color:<%= m.confidence >= 0.85 ? 'var(--danger)' : 'var(--warn)' %>">!</span>
+            <%= m.title || (m.authority + ' ' + m.external_id) %>
+          </span>
+          <span class="status <%= m.status === 'confirmed' ? 'connected' : m.status === 'dismissed' ? 'not_connected' : 'syncing' %>"><%= m.status %></span>
+        </header>
+        <% if (m.merchant_name) { %>
+          <p class="desc">
+            Matched purchase: <strong><%= m.merchant_name %></strong>
+            <% if (m.purchase_date) { %> · <%= new Date(m.purchase_date).toLocaleDateString() %><% } %>
+            <% if (m.total_amount) { %> · <%= m.currency || 'USD' %> <%= Number(m.total_amount).toFixed(2) %><% } %>
+          </p>
+        <% } %>
+        <% if (m.hazard) { %>
+          <p class="desc"><strong>Hazard:</strong> <%= m.hazard %></p>
+        <% } %>
+        <% if (m.remedy) { %>
+          <p class="desc"><strong>Remedy:</strong> <%= m.remedy %></p>
+        <% } %>
+        <p class="subtle" style="font-size:11px">
+          Confidence: <%= Math.round(m.confidence * 100) %>% · matched <%= new Date(m.matched_at).toLocaleString() %>
+          <% if (m.url) { %> · <a href="<%= m.url %>" target="_blank" rel="noopener" style="margin-left:6px">CPSC notice ↗</a><% } %>
+        </p>
+      </article>
+    <% }) %>
+  </section>
+<% } %>
+
+<%- include('partials/footer') %>

← 00b6568 tick 12: service_commitment + merchant policy extractor  ·  back to AbramsOS  ·  tick 14: unit tests for lib/crypto.js + lib/ids.js (backlog 8adb083 →