[object Object]

← 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 => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[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 => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[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 →