← 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
A routes/recalls.jsM server.jsA tests/recalls.test.jsM views/partials/header.ejsA views/recalls.ejs
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 →