[object Object]

← back to Interiordesignershowroom

Add gated /admin curation dashboard + dotenv env loading

5637f88ce057e6f42ccb4cce2c6e7fdc213e81fe · 2026-08-01 11:25:16 -0700 · Steve Abrams

- routes/admin.js: basic-auth (timing-safe), featured-toggle, guide publish/unpublish,
  click analytics (top-clicked, 7-day counts), created date+time chips per admin-card rule
- server.js loads .env via dotenv (CJ_COMPANY_ID etc.); admin router mounted
- verified: /admin 401 unauth / 200 authed, timestamps render, existing routes intact

Files touched

Diff

commit 5637f88ce057e6f42ccb4cce2c6e7fdc213e81fe
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 11:25:16 2026 -0700

    Add gated /admin curation dashboard + dotenv env loading
    
    - routes/admin.js: basic-auth (timing-safe), featured-toggle, guide publish/unpublish,
      click analytics (top-clicked, 7-day counts), created date+time chips per admin-card rule
    - server.js loads .env via dotenv (CJ_COMPANY_ID etc.); admin router mounted
    - verified: /admin 401 unauth / 200 authed, timestamps render, existing routes intact
---
 routes/admin.js | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js       |   3 ++
 2 files changed, 135 insertions(+)

diff --git a/routes/admin.js b/routes/admin.js
new file mode 100644
index 0000000..dac9a1c
--- /dev/null
+++ b/routes/admin.js
@@ -0,0 +1,132 @@
+// Gated admin curation dashboard. Basic-auth, no npm deps beyond express.
+// Lets Steve hand-pick `featured` products, publish/unpublish guides, and see
+// which links are actually converting (click analytics).
+const express = require('express');
+const crypto = require('crypto');
+const db = require('../lib/db');
+const { esc, money } = require('../lib/render');
+
+const router = express.Router();
+
+// --- Basic auth (timing-safe) --------------------------------------------
+const USER = process.env.ADMIN_USER || 'admin';
+const PASS = process.env.ADMIN_PASS || 'DW2024!';
+function safeEq(a, b) {
+  const A = Buffer.from(a), B = Buffer.from(b);
+  if (A.length !== B.length) return false;
+  return crypto.timingSafeEqual(A, B);
+}
+function auth(req, res, next) {
+  const h = req.get('authorization') || '';
+  const m = h.match(/^Basic (.+)$/);
+  if (m) {
+    const [u, p] = Buffer.from(m[1], 'base64').toString().split(':');
+    if (u != null && p != null && safeEq(u, USER) && safeEq(p, PASS)) return next();
+  }
+  res.set('WWW-Authenticate', 'Basic realm="IDS Admin"').status(401).send('Auth required');
+}
+router.use('/admin', auth);
+router.use('/admin', express.urlencoded({ extended: false }));
+
+// Steve's rule: admin cards show created date AND time, visible, ISO in title=.
+function when(ts) {
+  if (!ts) return '';
+  const d = new Date(ts);
+  const label = d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+  return `<span class="when" title="${esc(d.toISOString())}">🕓 ${esc(label)}</span>`;
+}
+
+const shell = (body) => `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1"><title>IDS Admin</title>
+<style>
+:root{--line:#e2ddd4;--ink:#221e19;--accent:#8a7250}
+*{box-sizing:border-box}body{font:14px/1.5 -apple-system,Segoe UI,Helvetica,Arial,sans-serif;margin:0;background:#f6f3ee;color:var(--ink)}
+header{background:#221e19;color:#f3ede3;padding:14px 22px;display:flex;justify-content:space-between;align-items:center}
+header a{color:#e6c98a;text-decoration:none;font-size:.85rem}
+main{max-width:1200px;margin:0 auto;padding:22px}
+h1{font-size:1.3rem;margin:0}h2{font-size:1rem;border-bottom:1px solid var(--line);padding-bottom:6px;margin:26px 0 12px}
+.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
+.stat{background:#fff;border:1px solid var(--line);border-radius:6px;padding:14px}
+.stat b{font-size:1.6rem;display:block}.stat span{color:#8a8178;font-size:.78rem;text-transform:uppercase;letter-spacing:.05em}
+table{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--line);border-radius:6px;overflow:hidden}
+th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);font-size:.86rem;vertical-align:middle}
+th{background:#efe9df;font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;color:#6b6259}
+.when{color:#8a8178;font-size:.75rem;white-space:nowrap}
+.pill{display:inline-block;padding:1px 8px;border-radius:20px;font-size:.72rem;border:1px solid var(--line)}
+.on{background:#e7f3e7;border-color:#bcd9bc}.off{background:#f3e7e7;border-color:#d9bcbc}
+button{cursor:pointer;border:1px solid var(--line);background:#fff;border-radius:5px;padding:4px 10px;font-size:.8rem}
+button:hover{border-color:var(--accent);color:var(--accent)}
+img.thumb{width:40px;height:40px;object-fit:cover;border-radius:4px;vertical-align:middle}
+.net{font-size:.7rem;text-transform:uppercase;color:var(--accent);letter-spacing:.04em}
+</style></head><body>
+<header><h1>Interior Designer’s Showroom — Admin</h1><a href="/">← View site</a></header>
+<main>${body}</main></body></html>`;
+
+router.get('/admin', async (_req, res, next) => {
+  try {
+    const [counts, topClicks, products, guides] = await Promise.all([
+      db.query(`SELECT
+        (SELECT count(*) FROM products) AS products,
+        (SELECT count(*) FROM products WHERE featured) AS featured,
+        (SELECT count(*) FROM guides WHERE published) AS guides,
+        (SELECT count(*) FROM clicks) AS clicks,
+        (SELECT count(*) FROM clicks WHERE clicked_at > now() - interval '7 days') AS clicks7`),
+      db.query(`SELECT p.id,p.title,p.advertiser,count(c.*) AS n
+        FROM clicks c JOIN products p ON p.id=c.product_id
+        GROUP BY p.id,p.title,p.advertiser ORDER BY n DESC LIMIT 10`),
+      db.query(`SELECT id,title,advertiser,network,price,sale_price,image_url,featured,room,created_at
+        FROM products ORDER BY featured DESC, created_at DESC LIMIT 200`),
+      db.query(`SELECT id,slug,title,published,created_at FROM guides ORDER BY created_at DESC`),
+    ]);
+    const c = counts.rows[0];
+    const stats = `<div class="stats">
+      <div class="stat"><b>${c.products}</b><span>Products</span></div>
+      <div class="stat"><b>${c.featured}</b><span>Featured</span></div>
+      <div class="stat"><b>${c.guides}</b><span>Published guides</span></div>
+      <div class="stat"><b>${c.clicks}</b><span>Total clicks</span></div>
+      <div class="stat"><b>${c.clicks7}</b><span>Clicks · 7 days</span></div>
+    </div>`;
+
+    const topRows = topClicks.rows.length
+      ? topClicks.rows.map((r) => `<tr><td>${esc(r.title)}</td><td>${esc(r.advertiser || '')}</td><td><b>${r.n}</b></td></tr>`).join('')
+      : `<tr><td colspan="3" style="color:#8a8178">No clicks logged yet.</td></tr>`;
+
+    const prodRows = products.rows.map((p) => `<tr>
+      <td>${p.image_url ? `<img class="thumb" src="${esc(p.image_url)}" alt="">` : ''}</td>
+      <td>${esc(p.title)}<div class="net">${esc(p.network)} · ${esc(p.advertiser || '')}</div></td>
+      <td>${esc(p.room || '')}</td>
+      <td>${money(p.sale_price || p.price)}</td>
+      <td>${when(p.created_at)}</td>
+      <td><span class="pill ${p.featured ? 'on' : 'off'}">${p.featured ? 'Featured' : '—'}</span></td>
+      <td><form method="post" action="/admin/products/${p.id}/featured"><button>${p.featured ? 'Unfeature' : 'Feature'}</button></form></td>
+    </tr>`).join('');
+
+    const guideRows = guides.rows.map((g) => `<tr>
+      <td>${esc(g.title)}<div class="net">/guides/${esc(g.slug)}</div></td>
+      <td>${when(g.created_at)}</td>
+      <td><span class="pill ${g.published ? 'on' : 'off'}">${g.published ? 'Published' : 'Draft'}</span></td>
+      <td><form method="post" action="/admin/guides/${g.id}/published"><button>${g.published ? 'Unpublish' : 'Publish'}</button></form></td>
+    </tr>`).join('');
+
+    res.send(shell(`
+      ${stats}
+      <h2>Top-clicked products</h2>
+      <table><tr><th>Product</th><th>Advertiser</th><th>Clicks</th></tr>${topRows}</table>
+      <h2>Products — curate featured (${products.rows.length})</h2>
+      <table><tr><th></th><th>Product</th><th>Room</th><th>Price</th><th>Added</th><th>Status</th><th></th></tr>${prodRows}</table>
+      <h2>Guides</h2>
+      <table><tr><th>Guide</th><th>Created</th><th>Status</th><th></th></tr>${guideRows}</table>
+    `));
+  } catch (e) { next(e); }
+});
+
+router.post('/admin/products/:id/featured', async (req, res, next) => {
+  try { await db.query(`UPDATE products SET featured = NOT featured, updated_at=now() WHERE id=$1`, [req.params.id]); res.redirect('/admin'); }
+  catch (e) { next(e); }
+});
+router.post('/admin/guides/:id/published', async (req, res, next) => {
+  try { await db.query(`UPDATE guides SET published = NOT published, updated_at=now() WHERE id=$1`, [req.params.id]); res.redirect('/admin'); }
+  catch (e) { next(e); }
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index bbcc667..e1cc6b6 100644
--- a/server.js
+++ b/server.js
@@ -1,3 +1,4 @@
+try { require('dotenv').config(); } catch (_) { /* dotenv optional — env may come from pm2/shell */ }
 const express = require('express');
 const path = require('path');
 const db = require('./lib/db');
@@ -172,6 +173,8 @@ app.get('/sitemap.xml', async (_req, res, next) => {
   } catch (e) { next(e); }
 });
 
+app.use(require('./routes/admin'));
+
 app.use((_req, res) => res.status(404).send(layout({ title: 'Not found', body: '<section><h1>Not found</h1><p><a href="/">Back to the showroom →</a></p></section>' })));
 app.use((err, _req, res, _next) => { console.error(err); res.status(500).send(layout({ title: 'Error', body: '<section><h1>Something went wrong</h1></section>' })); });
 

← 9d15d2b Fix adapter bugs from adversarial review: Rakuten token endp  ·  back to Interiordesignershowroom  ·  Add robots.txt (block /admin + /go), go-live staging checkli fee7e91 →