← back to Wallco Ai
wallco.ai · audit log Phase 1 — capture all user + admin actions per Steve's spec (Sev all calls and recording per user and admin). New wallco_audit_log table in dw_unified (id BIGSERIAL, user_id FK trade_users, is_admin, actor_label, action TEXT, payload JSONB, ip INET, user_agent, created_at) + 3 indexes (user/admin/action). logEvent(req, action, payload) helper is fire-and-forget — never throws, never blocks request path. Wired to 5 event paths: trade_login_request, trade_login_success, trade_logout, design_saved/unsaved, sample_request_submitted, designs_search. /me/activity (auth-gated trade-user) shows own rows with pretty-printed action labels + payload summary + CCPA-aligned ?format=json export. /admin/activity (admin-gated, localhost bypass kicks in) shows all rows with user_id/action/from/to/limit filters + JSON export. Nav link 'My Activity' added next to 'My Saved'. Verified end-to-end: table created, anon /me/activity 302 → login, auth → 200 16.8KB rendered, 4 events captured (trade_login_request + trade_login_success + design_saved + designs_search), admin view returns rows joined to user emails.
48baf5a1c974664636db52341fb77c82cb33ce19 · 2026-05-12 14:48:34 -0700 · SteveStudio2
Files touched
Diff
commit 48baf5a1c974664636db52341fb77c82cb33ce19
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 14:48:34 2026 -0700
wallco.ai · audit log Phase 1 — capture all user + admin actions per Steve's spec (Sev all calls and recording per user and admin). New wallco_audit_log table in dw_unified (id BIGSERIAL, user_id FK trade_users, is_admin, actor_label, action TEXT, payload JSONB, ip INET, user_agent, created_at) + 3 indexes (user/admin/action). logEvent(req, action, payload) helper is fire-and-forget — never throws, never blocks request path. Wired to 5 event paths: trade_login_request, trade_login_success, trade_logout, design_saved/unsaved, sample_request_submitted, designs_search. /me/activity (auth-gated trade-user) shows own rows with pretty-printed action labels + payload summary + CCPA-aligned ?format=json export. /admin/activity (admin-gated, localhost bypass kicks in) shows all rows with user_id/action/from/to/limit filters + JSON export. Nav link 'My Activity' added next to 'My Saved'. Verified end-to-end: table created, anon /me/activity 302 → login, auth → 200 16.8KB rendered, 4 events captured (trade_login_request + trade_login_success + design_saved + designs_search), admin view returns rows joined to user emails.
---
server.js | 266 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 266 insertions(+)
diff --git a/server.js b/server.js
index 5a9c1fa..df5fc00 100644
--- a/server.js
+++ b/server.js
@@ -345,6 +345,9 @@ app.get('/api/designs/search', (req, res) => {
const topCategories = Object.entries(categoryTally)
.sort((a, b) => b[1] - a[1])
.map(([category, count]) => ({ category, count }));
+ // Log the search query (audit + future analytics). Only logs when q≥2 chars
+ // so the early-return path above doesn't double-log empty probes.
+ logEvent(req, 'designs_search', { q: qRaw, count: out.length, did_you_mean: didYouMean.map(d => d.word) });
res.json({
q: qRaw,
limit,
@@ -716,6 +719,64 @@ function ensureTradeUserTables() {
}
ensureTradeUserTables();
+// Audit-log — every user + admin action lands here. Append-only. Steve's call:
+// trade users see only their own at /me/activity; admin sees everything at /admin/activity.
+function ensureAuditLogTable() {
+ try {
+ psqlQuery(`CREATE TABLE IF NOT EXISTS wallco_audit_log (
+ id BIGSERIAL PRIMARY KEY,
+ user_id INTEGER REFERENCES wallco_trade_users(id) ON DELETE SET NULL,
+ is_admin BOOLEAN NOT NULL DEFAULT FALSE,
+ actor_label TEXT,
+ action TEXT NOT NULL,
+ payload JSONB,
+ ip INET,
+ user_agent TEXT,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+ );`);
+ psqlQuery(`CREATE INDEX IF NOT EXISTS wallco_audit_user_idx
+ ON wallco_audit_log(user_id, created_at DESC) WHERE user_id IS NOT NULL;`);
+ psqlQuery(`CREATE INDEX IF NOT EXISTS wallco_audit_admin_idx
+ ON wallco_audit_log(is_admin, created_at DESC) WHERE is_admin = TRUE;`);
+ psqlQuery(`CREATE INDEX IF NOT EXISTS wallco_audit_action_idx
+ ON wallco_audit_log(action, created_at DESC);`);
+ console.log(' Audit-log table ready (wallco_audit_log)');
+ } catch (e) {
+ console.error('[audit-log] table init failed:', e.message);
+ }
+}
+ensureAuditLogTable();
+
+// logEvent(req, action, payload) — fire-and-forget INSERT into wallco_audit_log.
+// Never throws (audit MUST NOT break the request path). Extracts actor from
+// cookies — admin > trade user > anonymous.
+function logEvent(req, action, payload) {
+ try {
+ const u = getTradeUser(req);
+ const admin = isAdmin(req);
+ const ip = (req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '').toString().split(',')[0].trim() || null;
+ const ua = (req.headers['user-agent'] || '').toString().slice(0, 500);
+ const actorLabel = admin ? 'admin' : (u && u.email) ? u.email : 'anon';
+ const payloadJson = payload ? JSON.stringify(payload).slice(0, 8000) : null;
+ psqlQuery(`INSERT INTO wallco_audit_log (user_id, is_admin, actor_label, action, payload, ip, user_agent)
+ VALUES (
+ ${u ? parseInt(u.id, 10) : 'NULL'},
+ ${admin ? 'TRUE' : 'FALSE'},
+ ${pgEsc(actorLabel)},
+ ${pgEsc(action)},
+ ${payloadJson ? pgEsc(payloadJson) + '::jsonb' : 'NULL'},
+ ${ip ? pgEsc(ip) + '::inet' : 'NULL'},
+ ${pgEsc(ua)}
+ );`);
+ } catch (e) {
+ // Audit failures are non-fatal; log once but never re-raise.
+ if (!global.__auditLogWarned) {
+ console.warn('[audit-log] insert failed:', e.message);
+ global.__auditLogWarned = true;
+ }
+ }
+}
+
// HTML-escape helper for safe interpolation into trade-login templates.
function _escTrade(s) {
return String(s == null ? '' : s)
@@ -839,6 +900,7 @@ app.post('/api/trade/login', (req, res) => {
const host = req.headers['x-forwarded-host'] || req.get('host');
const magicLink = `${proto}://${host}/trade/auth?token=${token}&return=${encodeURIComponent(returnTo)}`;
console.log(`[trade-login] magic link: ${magicLink} (for ${email})`);
+ logEvent(req, 'trade_login_request', { email });
res.json({
ok: true,
magic_link: magicLink,
@@ -894,11 +956,21 @@ app.get('/trade/auth', (req, res) => {
});
// Whitelist returnTo to relative paths (prevent open-redirect).
const safeReturn = (returnTo && returnTo.startsWith('/') && !returnTo.startsWith('//')) ? returnTo : '/';
+ // Log login_success — need to set cookie context so logEvent picks up the user.
+ // The cookie is already in res but not yet on the request; pass actor explicitly.
+ try {
+ psqlQuery(`INSERT INTO wallco_audit_log (user_id, is_admin, actor_label, action, payload, ip, user_agent)
+ VALUES (${parseInt(row.id, 10)}, FALSE, ${pgEsc(row.email)}, ${pgEsc('trade_login_success')},
+ ${pgEsc(JSON.stringify({ return_to: safeReturn }))}::jsonb,
+ ${pgEsc(((req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '').toString().split(',')[0].trim()) || '0.0.0.0')}::inet,
+ ${pgEsc((req.headers['user-agent'] || '').toString().slice(0, 500))});`);
+ } catch(_) {}
res.redirect(safeReturn);
});
// GET /trade/logout — clear cookie, send home.
app.get('/trade/logout', (req, res) => {
+ logEvent(req, 'trade_logout', null);
res.clearCookie(TRADE_COOKIE, { path: '/' });
res.redirect('/');
});
@@ -982,6 +1054,7 @@ app.post('/api/saved', (req, res) => {
nowSaved = true;
}
const cnt = parseInt(psqlQuery(`SELECT count(*) FROM wallco_saved_designs WHERE user_id=${parseInt(u.id, 10)};`), 10) || 0;
+ logEvent(req, nowSaved ? 'design_saved' : 'design_unsaved', { design_id: designId, saved_count: cnt });
res.json({ ok: true, saved: nowSaved, saved_count: cnt });
} catch (e) {
console.error('[saved] toggle failed:', e.message);
@@ -1142,6 +1215,197 @@ ${HAMBURGER_JS}
</html>`);
});
+// ── /me/activity — auth-gated. Per-trade-user activity log (own rows only).
+// Used by users to see their own footprint (saves, basket adds, sample requests, etc.).
+// JSON via ?format=json for client/export use.
+app.get('/me/activity', (req, res) => {
+ const u = getTradeUser(req);
+ if (!u) return res.redirect('/trade/login?return=' + encodeURIComponent('/me/activity'));
+ const limit = Math.max(1, Math.min(500, parseInt(req.query.limit || '100', 10)));
+ let rows = [];
+ try {
+ const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.created_at DESC), '[]'::json) FROM (
+ SELECT id, action, payload, ip::text AS ip, user_agent, created_at
+ FROM wallco_audit_log
+ WHERE user_id = ${parseInt(u.id, 10)}
+ ORDER BY created_at DESC
+ LIMIT ${limit}
+ ) t;`);
+ rows = JSON.parse(raw || '[]');
+ } catch (e) {
+ console.error('[me/activity] query failed:', e.message);
+ }
+ // JSON export path (CCPA/GDPR-aligned data-portability).
+ if (String(req.query.format || '').toLowerCase() === 'json') {
+ res.setHeader('Cache-Control', 'no-store');
+ return res.json({ user_id: u.id, email: u.email, count: rows.length, rows });
+ }
+ const esc = (s) => String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
+ const actionPretty = (a) => ({
+ trade_login_request: 'Requested sign-in link',
+ trade_login_success: 'Signed in',
+ trade_logout: 'Signed out',
+ design_saved: 'Saved a design',
+ design_unsaved: 'Unsaved a design',
+ sample_request_submitted: 'Requested samples',
+ designs_search: 'Searched the catalog',
+ })[a] || a;
+ const rowsHtml = rows.map(r => {
+ const t = new Date(r.created_at);
+ const when = t.toISOString().replace('T',' ').slice(0,16) + ' UTC';
+ let summary = '';
+ try {
+ const p = r.payload || {};
+ if (r.action === 'design_saved' || r.action === 'design_unsaved') summary = `design #${p.design_id}`;
+ else if (r.action === 'designs_search') summary = `“${esc(p.q || '')}” → ${p.count} hits`;
+ else if (r.action === 'sample_request_submitted') summary = `request_id=${esc(p.request_id || '')} · ${p.design_count} designs`;
+ else if (p && Object.keys(p).length) summary = esc(JSON.stringify(p).slice(0, 140));
+ } catch(_) {}
+ return `<tr>
+ <td style="padding:8px 12px;font:11px ui-monospace,Menlo,monospace;color:var(--ink-faint,#888);white-space:nowrap">${esc(when)}</td>
+ <td style="padding:8px 12px;font:13px var(--sans,system-ui);color:var(--ink,#111)">${esc(actionPretty(r.action))}</td>
+ <td style="padding:8px 12px;font:12px var(--sans,system-ui);color:var(--ink-soft,#555)">${summary}</td>
+ </tr>`;
+ }).join('');
+ res.setHeader('Cache-Control', 'no-store');
+ res.type('html').send(`${htmlHead({
+ title: 'My Activity — wallco.ai',
+ description: 'Your wallco.ai activity log.',
+ canonical: 'https://wallco.ai/me/activity'
+ })}
+<body>
+${htmlHeader('/me/activity')}
+<main>
+<section style="padding:32px 40px 16px;max-width:980px;margin:0 auto">
+ <div style="display:flex;flex-wrap:wrap;align-items:baseline;gap:16px;justify-content:space-between;margin-bottom:18px">
+ <div>
+ <h1 style="font:300 28px/1.2 'Cormorant Garamond',serif;margin:0 0 4px">My activity</h1>
+ <div style="font:13px var(--sans,system-ui);color:var(--ink-soft,#555)">
+ ${rows.length} recorded ${rows.length === 1 ? 'event' : 'events'} · <a href="/me/saved" style="color:var(--ink-soft,#555)">Saved →</a>
+ </div>
+ </div>
+ <div style="display:flex;gap:10px">
+ <a href="/me/activity?format=json" class="btn-outline" style="padding:8px 14px;font:12px var(--sans);text-decoration:none">Export JSON</a>
+ </div>
+ </div>
+ ${rows.length
+ ? `<table style="width:100%;border-collapse:collapse">
+ <thead><tr>
+ <th style="text-align:left;padding:6px 12px;font:10px var(--sans,system-ui);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint,#888);border-bottom:1px solid var(--line,#eee)">When (UTC)</th>
+ <th style="text-align:left;padding:6px 12px;font:10px var(--sans,system-ui);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint,#888);border-bottom:1px solid var(--line,#eee)">Action</th>
+ <th style="text-align:left;padding:6px 12px;font:10px var(--sans,system-ui);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint,#888);border-bottom:1px solid var(--line,#eee)">Detail</th>
+ </tr></thead>
+ <tbody>${rowsHtml}</tbody>
+ </table>`
+ : `<div style="padding:48px 28px;text-align:center;color:var(--ink-soft,#555);font:14px/1.6 var(--sans);background:var(--card-bg,#faf8f3);border-radius:8px">
+ <p style="margin:0 0 8px;font:300 22px/1.2 var(--serif,Georgia,serif);color:var(--ink,#111)">No activity yet.</p>
+ <p>Sign-ins, saves, sample requests, and catalog searches show up here.</p>
+ </div>`}
+</section>
+</main>
+${FOOTER}
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── /admin/activity — admin-gated. All events across all users.
+// Filters: ?user_id=, ?action=, ?from=YYYY-MM-DD, ?to=YYYY-MM-DD, ?limit=
+// JSON export at ?format=json.
+app.get('/admin/activity', (req, res) => {
+ if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+ const limit = Math.max(1, Math.min(1000, parseInt(req.query.limit || '200', 10)));
+ const userId = parseInt(req.query.user_id, 10);
+ const action = String(req.query.action || '').trim();
+ const from = String(req.query.from || '').trim();
+ const to = String(req.query.to || '').trim();
+ const wheres = [];
+ if (Number.isFinite(userId) && userId > 0) wheres.push(`l.user_id = ${userId}`);
+ if (action) wheres.push(`l.action = ${pgEsc(action)}`);
+ if (/^\d{4}-\d{2}-\d{2}$/.test(from)) wheres.push(`l.created_at >= ${pgEsc(from)}::timestamptz`);
+ if (/^\d{4}-\d{2}-\d{2}$/.test(to)) wheres.push(`l.created_at < (${pgEsc(to)}::date + 1)::timestamptz`);
+ const whereClause = wheres.length ? `WHERE ${wheres.join(' AND ')}` : '';
+ let rows = [];
+ try {
+ const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.created_at DESC), '[]'::json) FROM (
+ SELECT l.id, l.user_id, l.is_admin, l.actor_label, l.action, l.payload,
+ l.ip::text AS ip, l.user_agent, l.created_at,
+ u.email AS user_email
+ FROM wallco_audit_log l
+ LEFT JOIN wallco_trade_users u ON u.id = l.user_id
+ ${whereClause}
+ ORDER BY l.created_at DESC
+ LIMIT ${limit}
+ ) t;`);
+ rows = JSON.parse(raw || '[]');
+ } catch (e) {
+ console.error('[admin/activity] query failed:', e.message);
+ }
+ if (String(req.query.format || '').toLowerCase() === 'json') {
+ res.setHeader('Cache-Control', 'no-store');
+ return res.json({ count: rows.length, filter: { user_id: userId || null, action: action || null, from: from || null, to: to || null }, rows });
+ }
+ const esc = (s) => String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
+ const rowsHtml = rows.map(r => {
+ const t = new Date(r.created_at);
+ const when = t.toISOString().replace('T',' ').slice(0,19) + 'Z';
+ const actor = r.is_admin ? '<span style="color:var(--gold,#c9a14b);font-weight:600">admin</span>'
+ : (r.user_email ? esc(r.user_email) : esc(r.actor_label || 'anon'));
+ let summary = '';
+ try { summary = r.payload ? esc(JSON.stringify(r.payload).slice(0, 240)) : ''; } catch(_) {}
+ return `<tr>
+ <td style="padding:6px 10px;font:10px ui-monospace,Menlo,monospace;color:var(--ink-faint,#888);white-space:nowrap">${esc(when)}</td>
+ <td style="padding:6px 10px;font:12px var(--sans,system-ui)">${actor}</td>
+ <td style="padding:6px 10px;font:12px var(--sans,system-ui);color:var(--ink,#111)">${esc(r.action)}</td>
+ <td style="padding:6px 10px;font:11px ui-monospace,Menlo,monospace;color:var(--ink-soft,#555);max-width:480px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${summary}">${summary}</td>
+ <td style="padding:6px 10px;font:11px ui-monospace,Menlo,monospace;color:var(--ink-faint,#888);white-space:nowrap">${esc(r.ip || '')}</td>
+ </tr>`;
+ }).join('');
+ res.setHeader('Cache-Control', 'no-store');
+ res.type('html').send(`${htmlHead({
+ title: 'Activity Log — wallco.ai admin',
+ description: 'Admin view of all audit events.',
+ canonical: 'https://wallco.ai/admin/activity'
+ })}
+<body>
+${htmlHeader('/admin/activity')}
+<main>
+<section style="padding:24px 40px;max-width:1280px;margin:0 auto">
+ <div style="display:flex;flex-wrap:wrap;gap:14px;align-items:baseline;justify-content:space-between;margin-bottom:14px">
+ <div>
+ <h1 style="font:300 28px/1.2 'Cormorant Garamond',serif;margin:0">Activity log <span style="font:12px var(--sans);color:var(--ink-faint,#888);text-transform:uppercase;letter-spacing:.08em;margin-left:6px">admin · all events</span></h1>
+ <div style="font:12px var(--sans,system-ui);color:var(--ink-soft,#555);margin-top:4px">${rows.length} rows · limit ${limit}${userId ? ' · user '+userId : ''}${action ? ' · action '+esc(action) : ''}${from ? ' · from '+esc(from) : ''}${to ? ' · to '+esc(to) : ''}</div>
+ </div>
+ <form method="get" action="/admin/activity" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;font:12px var(--sans,system-ui)">
+ <input name="user_id" type="number" min="1" placeholder="user_id" value="${userId||''}" style="width:90px;padding:5px 8px;border:1px solid var(--line,#ddd);border-radius:4px">
+ <input name="action" type="text" placeholder="action" value="${esc(action)}" style="width:160px;padding:5px 8px;border:1px solid var(--line,#ddd);border-radius:4px">
+ <input name="from" type="date" value="${esc(from)}" style="padding:5px 8px;border:1px solid var(--line,#ddd);border-radius:4px">
+ <input name="to" type="date" value="${esc(to)}" style="padding:5px 8px;border:1px solid var(--line,#ddd);border-radius:4px">
+ <input name="limit" type="number" min="10" max="1000" value="${limit}" style="width:70px;padding:5px 8px;border:1px solid var(--line,#ddd);border-radius:4px">
+ <button type="submit" style="padding:5px 14px;background:var(--ink,#111);color:var(--bg,#fff);border:0;border-radius:4px;cursor:pointer">Filter</button>
+ <a href="?${new URLSearchParams(req.query).toString()}&format=json" style="padding:5px 12px;text-decoration:none;color:var(--ink-soft,#555);border:1px solid var(--line,#ddd);border-radius:4px">JSON</a>
+ </form>
+ </div>
+ ${rows.length
+ ? `<table style="width:100%;border-collapse:collapse;font-size:12px">
+ <thead><tr style="background:var(--card-bg,#faf8f3)">
+ <th style="text-align:left;padding:7px 10px;font:10px var(--sans,system-ui);text-transform:uppercase;letter-spacing:.06em;color:var(--ink-faint,#888);border-bottom:1px solid var(--line,#eee)">When (UTC)</th>
+ <th style="text-align:left;padding:7px 10px;font:10px var(--sans,system-ui);text-transform:uppercase;letter-spacing:.06em;color:var(--ink-faint,#888);border-bottom:1px solid var(--line,#eee)">Actor</th>
+ <th style="text-align:left;padding:7px 10px;font:10px var(--sans,system-ui);text-transform:uppercase;letter-spacing:.06em;color:var(--ink-faint,#888);border-bottom:1px solid var(--line,#eee)">Action</th>
+ <th style="text-align:left;padding:7px 10px;font:10px var(--sans,system-ui);text-transform:uppercase;letter-spacing:.06em;color:var(--ink-faint,#888);border-bottom:1px solid var(--line,#eee)">Payload</th>
+ <th style="text-align:left;padding:7px 10px;font:10px var(--sans,system-ui);text-transform:uppercase;letter-spacing:.06em;color:var(--ink-faint,#888);border-bottom:1px solid var(--line,#eee)">IP</th>
+ </tr></thead>
+ <tbody>${rowsHtml}</tbody>
+ </table>`
+ : '<p style="padding:32px;text-align:center;color:var(--ink-soft,#555)">No matching events.</p>'}
+</section>
+</main>
+${FOOTER}
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
// ── /admin/dashboard — key metrics for wallco.ai operations
app.get('/admin/dashboard', (req, res) => {
if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
@@ -1401,6 +1665,7 @@ function htmlHeader(active) {
`<a href="${href}" class="${active === href ? 'active' : ''}"${href === '/basket' ? ' id="nav-basket-link"' : ''}>${label}${href === '/basket' ? '<span id="nav-basket-count" style="display:none;margin-left:6px;font:11px var(--sans,system-ui);background:var(--gold,#c9a14b);color:var(--accent,#0d0d0d);padding:1px 7px;border-radius:999px;vertical-align:middle"></span>' : ''}</a>`
).join('\n ')}
<a href="/me/saved" class="${active === '/me/saved' ? 'active' : ''}">My Saved</a>
+ <a href="/me/activity" class="${active === '/me/activity' ? 'active' : ''}">My Activity</a>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark/light mode">
<svg class="sun-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="moon-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
@@ -5744,6 +6009,7 @@ app.post('/api/basket/submit', (req, res) => {
console.error('basket JSONL append error:', e.message);
return res.status(500).json({ ok: false, error: 'failed to record request' });
}
+ logEvent(req, 'sample_request_submitted', { request_id, design_count: (record.design_ids || []).length, design_ids: record.design_ids, email: record.email || null });
res.json({ ok: true, request_id });
});
← 38b72fe tests: mask catalog content in isolate-visual VR so tests st
·
back to Wallco Ai
·
fix(/api/room): retry upstream once on fetch failed / transi dbc500b →