← 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=<ASIM_ADMIN_TOKEN></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 →