[object Object]

← back to AsSeenInMovies

asseeninmovies: /spotted/queue admin moderation — token-gated approve/reject pending submissions; reject sets verified_by='rejected' (reversible) and filters out of /spotted, /api/spotted, /movie/:id, /person/:id, home rail, and stats; ASIM_ADMIN_TOKEN persisted to .env

0206a11293de604d4690d5dce0878775c14a1431 · 2026-05-12 16:31:36 -0700 · SteveStudio2

Files touched

Diff

commit 0206a11293de604d4690d5dce0878775c14a1431
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 16:31:36 2026 -0700

    asseeninmovies: /spotted/queue admin moderation — token-gated approve/reject pending submissions; reject sets verified_by='rejected' (reversible) and filters out of /spotted, /api/spotted, /movie/:id, /person/:id, home rail, and stats; ASIM_ADMIN_TOKEN persisted to .env
---
 server.js | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 146 insertions(+), 7 deletions(-)

diff --git a/server.js b/server.js
index 9ea2f47..132e040 100644
--- a/server.js
+++ b/server.js
@@ -22,6 +22,20 @@ const { Pool } = require('pg');
 const PORT = parseInt(process.env.PORT || '9742', 10);
 const DB_URL = process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2';
 
+// Admin gate for /spotted/queue moderation routes. If ASIM_ADMIN_TOKEN is not
+// set in .env, generate an ephemeral one at boot and log it to stdout — that
+// way moderation is never silently unauth'd, but Steve can grab the token
+// from `pm2 logs asim` without provisioning anything.
+const ADMIN_TOKEN = process.env.ASIM_ADMIN_TOKEN
+  || require('crypto').randomBytes(16).toString('hex');
+if (!process.env.ASIM_ADMIN_TOKEN) {
+  console.log(`[asim] ephemeral admin token: ${ADMIN_TOKEN} — set ASIM_ADMIN_TOKEN in .env to persist`);
+}
+function isAdmin(req) {
+  const tok = req.query.token || req.headers['x-admin-token'] || (req.body && req.body.token);
+  return typeof tok === 'string' && tok === ADMIN_TOKEN;
+}
+
 const app = express();
 app.use(express.json({ limit: '2mb' }));
 app.disable('x-powered-by');
@@ -239,7 +253,7 @@ app.get('/spotted', async (req, res) => {
     const offset = (page - 1) * limit;
     const cat = typeof req.query.category === 'string' && req.query.category.length < 60 ? req.query.category : null;
     const params = [];
-    let where = `s.verified_at IS NOT NULL`;
+    let where = `s.verified_at IS NOT NULL AND (verified_by IS NULL OR verified_by NOT LIKE 'rejected%')`;
     if (cat) { params.push(cat); where += ` AND s.product_category = $${params.length}`; }
     params.push(limit, offset);
     const r = await pool.query(`
@@ -253,7 +267,7 @@ app.get('/spotted', async (req, res) => {
     const totalR = await pool.query(`SELECT count(*) AS n FROM asim_spotted s WHERE ${where}`, params.slice(0, params.length - 2));
     const total = parseInt(totalR.rows[0].n, 10);
     const totalPages = Math.max(1, Math.ceil(total / limit));
-    const catsR = await pool.query(`SELECT product_category, count(*) AS n FROM asim_spotted WHERE verified_at IS NOT NULL AND product_category IS NOT NULL GROUP BY 1 ORDER BY 2 DESC`);
+    const catsR = await pool.query(`SELECT product_category, count(*) AS n FROM asim_spotted WHERE verified_at IS NOT NULL AND (verified_by IS NULL OR verified_by NOT LIKE 'rejected%') AND product_category IS NOT NULL GROUP BY 1 ORDER BY 2 DESC`);
     const qs = (overrides = {}) => {
       const u = new URLSearchParams();
       if (cat) u.set('category', cat);
@@ -552,7 +566,7 @@ app.get('/api/spotted', async (req, res) => {
   const cat = req.query.category;
   try {
     const params = [];
-    let where = 'WHERE verified_at IS NOT NULL';
+    let where = `WHERE verified_at IS NOT NULL AND (verified_by IS NULL OR verified_by NOT LIKE 'rejected%')`;
     if (cat) { params.push(cat); where += ` AND product_category = $${params.length}`; }
     params.push(limit);
     const r = await pool.query(`
@@ -566,6 +580,131 @@ app.get('/api/spotted', async (req, res) => {
   }
 });
 
+// ────────────────────────────────────────────────────────────────────
+// /spotted/queue — admin moderation for pending user submissions.
+// Token-gated (ASIM_ADMIN_TOKEN env or ephemeral boot-token printed once).
+// Reversible: approve sets verified_at=NOW, reject sets verified_at and
+// stamps verified_by='rejected:<reason>' so the row is filtered out of
+// the public gallery WITHOUT being deleted — undoable from psql.
+// ────────────────────────────────────────────────────────────────────
+app.get('/spotted/queue', async (req, res) => {
+  if (!isAdmin(req)) return res.status(401).type('html').send(htmlShell('Admin', `<div class="empty"><p>Admin token required. Append <code>?token=&lt;ASIM_ADMIN_TOKEN&gt;</code> to the URL.</p></div>`));
+  try {
+    const r = await pool.query(`
+      SELECT s.*, m.title AS movie_title, m.release_year
+        FROM asim_spotted s JOIN asim_movies m ON m.id = s.movie_id
+       WHERE s.verified_at IS NULL
+       ORDER BY s.created_at DESC LIMIT 200`);
+    const t = req.query.token;
+    const rows = r.rows.map(s => `
+      <article class="queue-row" id="row-${s.id}">
+        <header>
+          <span class="qid">#${s.id}</span>
+          <a class="movie-link" href="/movie/${s.movie_id}" target="_blank">${esc(s.movie_title || '')} (${esc(String(s.release_year || '?'))})</a>
+          <time>${esc(new Date(s.created_at).toISOString().slice(0,16).replace('T',' '))} UTC</time>
+        </header>
+        <div class="qbody">
+          <div class="qfields">
+            <div><strong>Product:</strong> ${esc(s.product_name || '')}</div>
+            ${s.brand ? `<div><strong>Brand:</strong> ${esc(s.brand)}</div>` : ''}
+            ${s.product_category ? `<div><strong>Category:</strong> ${esc(s.product_category)}</div>` : ''}
+            ${s.scene_timecode ? `<div><strong>Timecode:</strong> ${esc(s.scene_timecode)}</div>` : ''}
+            ${s.scene_description ? `<div><strong>Scene:</strong> ${esc(s.scene_description)}</div>` : ''}
+            ${s.shop_url ? `<div><strong>Shop:</strong> <a href="${esc(s.shop_url)}" rel="nofollow noopener" target="_blank">${esc(s.shop_url.slice(0,80))}</a></div>` : ''}
+            <div class="submitter">
+              <strong>By:</strong> ${esc(s.submitted_by_email || '(anon)')} · ${esc(s.submitted_by_role || 'fan')}
+            </div>
+          </div>
+          <div class="qactions">
+            <button class="btn-approve" data-id="${s.id}">Approve</button>
+            <button class="btn-reject"  data-id="${s.id}">Reject</button>
+          </div>
+        </div>
+      </article>`).join('');
+    const body = `
+      <main class="container queue-page">
+        <h1>Spotted · Moderation Queue</h1>
+        <p class="hint">${r.rows.length} pending submission${r.rows.length === 1 ? '' : 's'}. Approve to publish to /spotted; reject to hide.</p>
+        <section class="queue-list">${rows || '<div class="empty">Queue is empty — no pending submissions.</div>'}</section>
+      </main>
+      <style>
+        .queue-page { max-width: 980px; padding-top: 28px; }
+        .queue-page h1 { font-family: 'Cormorant Garamond', serif; font-size: 38px; margin: 0 0 6px; }
+        .queue-page .hint { color: var(--ink-mute, #777); margin: 0 0 24px; font-size: 14px; }
+        .queue-row { border: 1px solid rgba(0,0,0,0.12); border-radius: 6px; padding: 16px 18px; margin: 0 0 14px; background: rgba(255,255,255,0.6); }
+        .queue-row header { display: flex; gap: 14px; align-items: baseline; margin-bottom: 10px; font-size: 13px; }
+        .queue-row .qid { font-weight: 600; color: #B58A50; }
+        .queue-row .movie-link { color: inherit; text-decoration: none; border-bottom: 1px solid rgba(0,0,0,0.2); }
+        .queue-row time { margin-left: auto; color: var(--ink-mute, #777); }
+        .queue-row .qbody { display: grid; grid-template-columns: 1fr 160px; gap: 18px; }
+        .queue-row .qfields div { margin: 3px 0; font-size: 14px; line-height: 1.45; }
+        .queue-row .submitter { margin-top: 8px; padding-top: 8px; border-top: 1px dashed rgba(0,0,0,0.08); color: var(--ink-mute, #777); font-size: 12px; }
+        .queue-row .qactions { display: flex; flex-direction: column; gap: 8px; }
+        .queue-row button { padding: 9px 14px; border: 0; border-radius: 4px; font-weight: 600; font-size: 13px; cursor: pointer; font-family: inherit; }
+        .queue-row .btn-approve { background: #2D7A3F; color: #fff; }
+        .queue-row .btn-reject  { background: #B23A3A; color: #fff; }
+        .queue-row.gone { opacity: 0.35; pointer-events: none; }
+      </style>
+      <script>
+        (function(){
+          const TOKEN = ${JSON.stringify(typeof t === 'string' ? t : '')};
+          async function act(action, id){
+            const row = document.getElementById('row-'+id);
+            const r = await fetch('/spotted/queue/'+id+'/'+action, {
+              method: 'POST',
+              headers: { 'Content-Type': 'application/json', 'x-admin-token': TOKEN },
+              body: JSON.stringify({})
+            });
+            if (r.ok) { row.classList.add('gone'); row.querySelector('.qactions').innerHTML = '<span style="font-size:13px;color:#666">'+action+'d</span>'; }
+            else { const j = await r.json().catch(()=>({error:'err'})); alert('Failed: '+(j.error||r.status)); }
+          }
+          document.querySelectorAll('.btn-approve').forEach(b => b.onclick = () => act('approve', b.dataset.id));
+          document.querySelectorAll('.btn-reject').forEach(b => b.onclick = () => act('reject', b.dataset.id));
+        })();
+      </script>`;
+    res.type('html').send(htmlShell('Moderation Queue', body));
+  } catch (e) {
+    res.status(500).type('html').send(htmlShell('Error', `<div class="empty">${esc(e.message)}</div>`));
+  }
+});
+
+app.post('/spotted/queue/:id/approve', async (req, res) => {
+  if (!isAdmin(req)) return res.status(401).json({ error: 'admin token required' });
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
+  try {
+    const r = await pool.query(`
+      UPDATE asim_spotted
+         SET verified_at = NOW(), verified_by = COALESCE(verified_by, 'admin-queue'), updated_at = NOW()
+       WHERE id = $1 AND verified_at IS NULL
+       RETURNING id`, [id]);
+    if (!r.rows[0]) return res.status(404).json({ error: 'not found or already verified' });
+    res.json({ ok: true, id });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+app.post('/spotted/queue/:id/reject', async (req, res) => {
+  if (!isAdmin(req)) return res.status(401).json({ error: 'admin token required' });
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
+  try {
+    // Reject = mark verified_at + verified_by='rejected'. Row stays in DB
+    // (undoable by clearing verified_by). Public gallery filters out
+    // verified_by LIKE 'rejected%' so it never shows.
+    const r = await pool.query(`
+      UPDATE asim_spotted
+         SET verified_at = NOW(), verified_by = 'rejected', updated_at = NOW()
+       WHERE id = $1 AND verified_at IS NULL
+       RETURNING id`, [id]);
+    if (!r.rows[0]) return res.status(404).json({ error: 'not found or already processed' });
+    res.json({ ok: true, id });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // ────────────────────────────────────────────────────────────────────
 // HTML PAGES — /movie/:id + /person/:id + helpers. Built so Steve can
 // actually browse the data, not just hit /api/* JSON. Dark luxe-magazine
@@ -696,7 +835,7 @@ app.get('/movie/:id', async (req, res) => {
     // Public /movie/:id should only surface verified placements; pending
     // submissions (verified_at IS NULL) wait for admin review.
     const spotted = await pool.query(`
-      SELECT * FROM asim_spotted WHERE movie_id=$1 AND verified_at IS NOT NULL
+      SELECT * FROM asim_spotted WHERE movie_id=$1 AND verified_at IS NOT NULL AND (verified_by IS NULL OR verified_by NOT LIKE 'rejected%')
        ORDER BY verified_at DESC, created_at DESC`, [id]);
 
     // Group credits by department
@@ -786,7 +925,7 @@ app.get('/person/:id', async (req, res) => {
         FROM asim_spotted s
         JOIN asim_movies m ON m.id = s.movie_id
         JOIN asim_credits c ON c.movie_id = m.id
-       WHERE c.person_id = $1 AND s.verified_at IS NOT NULL
+       WHERE c.person_id = $1 AND s.verified_at IS NOT NULL AND (verified_by IS NULL OR verified_by NOT LIKE 'rejected%')
        ORDER BY m.release_year DESC NULLS LAST, s.product_name
        LIMIT 24`, [id]);
 
@@ -1056,7 +1195,7 @@ app.get('/', async (_req, res) => {
         (SELECT count(*) FROM asim_movies WHERE poster_url IS NOT NULL) AS movies_poster,
         (SELECT count(*) FROM asim_people) AS people_total,
         (SELECT count(*) FROM asim_people WHERE headshot_url IS NOT NULL) AS people_headshot,
-        (SELECT count(*) FROM asim_spotted WHERE verified_at IS NOT NULL) AS spotted_total`);
+        (SELECT count(*) FROM asim_spotted WHERE verified_at IS NOT NULL AND (verified_by IS NULL OR verified_by NOT LIKE 'rejected%')) AS spotted_total`);
     stats = s.rows[0];
     const fm = await pool.query(`SELECT id, title, release_year, poster_url FROM asim_movies WHERE poster_url IS NOT NULL ORDER BY random() LIMIT 6`);
     featuredMovies = fm.rows;
@@ -1066,7 +1205,7 @@ app.get('/', async (_req, res) => {
       SELECT s.id AS spot_id, s.product_name, s.product_category, s.brand, s.scene_timecode,
              m.id AS movie_id, m.title, m.release_year, m.poster_url, m.poster_source
         FROM asim_spotted s JOIN asim_movies m ON m.id = s.movie_id
-       WHERE s.verified_at IS NOT NULL
+       WHERE s.verified_at IS NOT NULL AND (verified_by IS NULL OR verified_by NOT LIKE 'rejected%')
        ORDER BY s.verified_at DESC LIMIT 6`);
     featuredSpotted = fs.rows;
   } catch (e) {

← 4f8629c asseeninmovies: /person/:id — surface 'SPOTTED in their film  ·  back to AsSeenInMovies  ·  asseeninmovies: /spotted/queue stats header (4-tile strip — 3d60e9b →