[object Object]

← back to AsSeenInMovies

asseeninmovies: SPOTTED submission form — public /spotted/submit + POST /api/spotted/submit with rate-limit (1/30s), autocomplete movie picker, validation, queues unverified rows for admin review

1cdcaf53459a7606502dbddd4f67a36f8f9e62e6 · 2026-05-12 16:08:26 -0700 · SteveStudio2

Files touched

Diff

commit 1cdcaf53459a7606502dbddd4f67a36f8f9e62e6
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 16:08:26 2026 -0700

    asseeninmovies: SPOTTED submission form — public /spotted/submit + POST /api/spotted/submit with rate-limit (1/30s), autocomplete movie picker, validation, queues unverified rows for admin review
---
 server.js | 237 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 237 insertions(+)

diff --git a/server.js b/server.js
index 3742c10..2227cb8 100644
--- a/server.js
+++ b/server.js
@@ -211,6 +211,242 @@ app.get('/api/ingest/status', async (_req, res) => {
   }
 });
 
+// SPOTTED — public submission form. Users (fans / set decorators / brand
+// reps) can flag a product they saw on screen. Inserts with verified_at=NULL
+// so admin review is required before it surfaces in /api/spotted. Rate-limit:
+// 1 submission per IP per 30 seconds (anti-spam, generous for honest users).
+const spottedRateMap = new Map();   // ip → last-submit-timestamp
+function spottedRateLimit(ip) {
+  const last = spottedRateMap.get(ip) || 0;
+  const now = Date.now();
+  if (now - last < 30000) return false;
+  spottedRateMap.set(ip, now);
+  // Garbage-collect map every ~5min.
+  if (spottedRateMap.size > 2000) {
+    const cutoff = now - 60000;
+    for (const [k, v] of spottedRateMap) if (v < cutoff) spottedRateMap.delete(k);
+  }
+  return true;
+}
+
+app.get('/spotted/submit', async (req, res) => {
+  const movieId = parseInt(req.query.movie_id || '0', 10);
+  let movieTitle = '', movieYear = '';
+  if (Number.isFinite(movieId) && movieId > 0) {
+    try {
+      const r = await pool.query(`SELECT title, release_year FROM asim_movies WHERE id=$1 LIMIT 1`, [movieId]);
+      if (r.rows[0]) { movieTitle = r.rows[0].title; movieYear = r.rows[0].release_year || ''; }
+    } catch (_e) { /* fall through */ }
+  }
+  const body = `
+    <style>
+      .form-stack{max-width:640px;margin:0 auto;display:flex;flex-direction:column;gap:14px}
+      .form-stack label{display:flex;flex-direction:column;gap:5px;font-size:12px;letter-spacing:0.12em;text-transform:uppercase;color:var(--ink-faint)}
+      .form-stack input,.form-stack textarea,.form-stack select{background:transparent;border:1px solid var(--line);color:var(--ink);padding:10px 12px;border-radius:6px;font:inherit;font-size:14.5px;text-transform:none;letter-spacing:normal}
+      .form-stack textarea{min-height:84px;resize:vertical}
+      .form-stack .row{display:grid;grid-template-columns:2fr 1fr;gap:14px}
+      @media (max-width:560px){.form-stack .row{grid-template-columns:1fr}}
+      .submit-btn{background:var(--gold);color:#1a1410;border:0;padding:14px 28px;border-radius:6px;font:inherit;font-size:14px;letter-spacing:0.12em;text-transform:uppercase;cursor:pointer;font-weight:600;margin-top:8px}
+      .submit-btn:hover{filter:brightness(1.1)}
+      .submit-btn:disabled{opacity:0.4;cursor:wait}
+      .form-help{color:var(--ink-faint);font-size:12px;margin-top:-2px}
+      .form-result{margin-top:18px;padding:14px 18px;border-radius:6px;border:1px solid var(--gold);background:rgba(201,161,75,0.07)}
+      .form-result.bad{border-color:#a33;background:rgba(170,50,50,0.07);color:#fbb}
+      .ms-search{position:relative}
+      .ms-results{position:absolute;top:calc(100% + 4px);left:0;right:0;background:var(--card);border:1px solid var(--line);border-radius:6px;max-height:240px;overflow:auto;z-index:5;display:none}
+      .ms-results.open{display:block}
+      .ms-results a{display:block;padding:8px 12px;color:var(--ink);font-size:13px;border-bottom:1px solid var(--line);text-decoration:none}
+      .ms-results a:hover{background:rgba(201,161,75,0.08);color:var(--gold)}
+    </style>
+    <h1 class="title">Submit a SPOTTED placement</h1>
+    <p class="meta-line">Saw a product on screen you can identify? Submit it for the community.</p>
+
+    <form id="spotted-form" class="form-stack" autocomplete="off" novalidate>
+      <label>
+        Movie or TV episode
+        <div class="ms-search">
+          <input id="movie-search" name="movie_search" type="text" placeholder="Type to search…" value="${esc(movieTitle ? movieTitle + (movieYear ? ' (' + movieYear + ')' : '') : '')}" required>
+          <input id="movie-id" name="movie_id" type="hidden" value="${esc(movieId || '')}">
+          <div id="movie-results" class="ms-results"></div>
+        </div>
+        <span class="form-help">Required. Start typing and pick from the dropdown.</span>
+      </label>
+
+      <div class="row">
+        <label>
+          Product name
+          <input name="product_name" type="text" placeholder="e.g. Brass arc floor lamp" required maxlength="160">
+        </label>
+        <label>
+          Category
+          <select name="product_category">
+            <option value="">— pick one —</option>
+            <option>Wallcovering</option>
+            <option>Lamp / Lighting</option>
+            <option>Furniture</option>
+            <option>Rug / Floor</option>
+            <option>Window treatment</option>
+            <option>Art / Wall décor</option>
+            <option>Accessories</option>
+            <option>Wardrobe</option>
+            <option>Tabletop / Kitchen</option>
+            <option>Other</option>
+          </select>
+        </label>
+      </div>
+
+      <div class="row">
+        <label>
+          Brand
+          <input name="brand" type="text" placeholder="e.g. Schoolhouse" maxlength="120">
+        </label>
+        <label>
+          Scene timecode
+          <input name="scene_timecode" type="text" placeholder="e.g. 0:42:13" maxlength="14">
+        </label>
+      </div>
+
+      <label>
+        Scene description
+        <textarea name="scene_description" placeholder="Where in the film does this appear? e.g. \\"Living room scene when Eve confronts Marlene, ~42 min in.\\"" maxlength="500"></textarea>
+      </label>
+
+      <label>
+        Where to buy (URL)
+        <input name="shop_url" type="url" placeholder="https://…" maxlength="500">
+      </label>
+
+      <div class="row">
+        <label>
+          Your email (optional)
+          <input name="submitted_by_email" type="email" placeholder="you@example.com" maxlength="160">
+          <span class="form-help">Only used to credit you if verified. Never shown publicly.</span>
+        </label>
+        <label>
+          Your role
+          <select name="submitted_by_role">
+            <option value="fan">Fan</option>
+            <option value="set-decorator">Set decorator</option>
+            <option value="brand-rep">Brand representative</option>
+            <option value="other">Other</option>
+          </select>
+        </label>
+      </div>
+
+      <button type="submit" class="submit-btn">Submit for review →</button>
+      <div id="form-result" class="form-result" style="display:none"></div>
+    </form>
+
+    <script>
+      (function(){
+        var ms = document.getElementById('movie-search');
+        var mid = document.getElementById('movie-id');
+        var box = document.getElementById('movie-results');
+        var debounce = null;
+        function clearResults(){ box.innerHTML=''; box.classList.remove('open'); }
+        function pickMovie(id, title, year){ mid.value = id; ms.value = title + (year ? ' ('+year+')' : ''); clearResults(); }
+        function searchMovies(q){
+          fetch('/api/search?q=' + encodeURIComponent(q)).then(function(r){return r.json()}).then(function(j){
+            var movies = (j && j.movies) ? j.movies.slice(0,8) : [];
+            if (!movies.length){ clearResults(); return; }
+            box.innerHTML = movies.map(function(m){
+              var safeTitle = (m.title||'').replace(/[<>&"]/g, function(c){return {'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]});
+              return '<a href="#" data-id="'+m.id+'" data-title="'+safeTitle+'" data-year="'+(m.release_year||'')+'">' + safeTitle + ' <span style="color:var(--ink-faint);font-size:11px">'+(m.release_year||'')+'</span></a>';
+            }).join('');
+            box.classList.add('open');
+            box.querySelectorAll('a').forEach(function(a){
+              a.addEventListener('click', function(e){
+                e.preventDefault();
+                pickMovie(a.getAttribute('data-id'), a.getAttribute('data-title'), a.getAttribute('data-year'));
+              });
+            });
+          }).catch(clearResults);
+        }
+        ms.addEventListener('input', function(){
+          mid.value = '';  // clear bound id when user types again
+          clearTimeout(debounce);
+          var q = ms.value.trim();
+          if (q.length < 2) { clearResults(); return; }
+          debounce = setTimeout(function(){ searchMovies(q); }, 200);
+        });
+        document.addEventListener('click', function(e){
+          if (!box.contains(e.target) && e.target !== ms) clearResults();
+        });
+
+        var form = document.getElementById('spotted-form');
+        var result = document.getElementById('form-result');
+        form.addEventListener('submit', function(e){
+          e.preventDefault();
+          result.style.display='none';
+          var fd = new FormData(form);
+          var data = {};
+          fd.forEach(function(v,k){ data[k] = v; });
+          if (!data.movie_id) { result.className='form-result bad'; result.style.display='block'; result.textContent='Pick a movie from the dropdown.'; return; }
+          if (!data.product_name) { result.className='form-result bad'; result.style.display='block'; result.textContent='Product name required.'; return; }
+          var btn = form.querySelector('button[type=submit]');
+          btn.disabled = true; btn.textContent = 'Submitting…';
+          fetch('/api/spotted/submit', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data)})
+            .then(function(r){ return r.json().then(function(j){ return {ok:r.ok, body:j} }) })
+            .then(function(x){
+              btn.disabled=false; btn.textContent='Submit for review →';
+              if (x.ok) {
+                result.className='form-result'; result.style.display='block';
+                result.innerHTML='<strong>Thanks!</strong> Submission #'+x.body.id+' is queued for review. We\\'ll cross-check it against the set-decorator credits and verify within 48h.';
+                form.reset();
+              } else {
+                result.className='form-result bad'; result.style.display='block';
+                result.textContent = x.body.error || 'Submission failed. Please try again.';
+              }
+            })
+            .catch(function(err){
+              btn.disabled=false; btn.textContent='Submit for review →';
+              result.className='form-result bad'; result.style.display='block';
+              result.textContent='Network error. Please try again.';
+            });
+        });
+      })();
+    </script>
+  `;
+  res.type('html').send(htmlShell('Submit a placement', body));
+});
+
+app.post('/api/spotted/submit', async (req, res) => {
+  const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || 'unknown';
+  if (!spottedRateLimit(ip)) return res.status(429).json({ error: 'Too many submissions — wait 30 seconds before retrying.' });
+  const b = req.body || {};
+  const movieId = parseInt(b.movie_id, 10);
+  if (!Number.isFinite(movieId) || movieId < 1) return res.status(400).json({ error: 'movie_id required' });
+  const productName = String(b.product_name || '').trim();
+  if (!productName || productName.length < 2 || productName.length > 160) return res.status(400).json({ error: 'product_name must be 2–160 chars' });
+  // Bound all free-text fields.
+  const cat   = String(b.product_category   || '').slice(0, 60) || null;
+  const brand = String(b.brand              || '').slice(0, 120) || null;
+  const desc  = String(b.scene_description  || '').slice(0, 500) || null;
+  const tc    = String(b.scene_timecode     || '').slice(0, 14)  || null;
+  const url   = String(b.shop_url           || '').slice(0, 500) || null;
+  const em    = String(b.submitted_by_email || '').slice(0, 160) || null;
+  const role  = ['fan','set-decorator','brand-rep','other'].includes(b.submitted_by_role) ? b.submitted_by_role : 'fan';
+  if (url && !/^https?:\/\//i.test(url)) return res.status(400).json({ error: 'shop_url must start with http(s)://' });
+  if (em  && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(em)) return res.status(400).json({ error: 'submitted_by_email looks invalid' });
+  try {
+    // Confirm the movie_id exists (else FK would error anyway, but a friendlier
+    // 404 helps).
+    const m = await pool.query(`SELECT id FROM asim_movies WHERE id=$1 LIMIT 1`, [movieId]);
+    if (!m.rows[0]) return res.status(404).json({ error: 'movie not found' });
+    const r = await pool.query(`
+      INSERT INTO asim_spotted
+        (movie_id, product_name, product_category, brand, scene_description,
+         scene_timecode, shop_url, submitted_by_email, submitted_by_role,
+         image_source)
+      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'submitted')
+      RETURNING id`,
+      [movieId, productName, cat, brand, desc, tc, url, em, role]);
+    res.json({ ok: true, id: r.rows[0].id });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // SPOTTED feed — most recent verified placements across all films
 app.get('/api/spotted', async (req, res) => {
   const limit = Math.min(parseInt(req.query.limit, 10) || 60, 200);
@@ -321,6 +557,7 @@ function htmlShell(title, body) {
     <a href="/movies">Movies</a>
     <a href="/people">People</a>
     <a href="/api/spotted">Spotted</a>
+    <a href="/spotted/submit">Submit</a>
     <a href="/search">Search</a>
   </nav>
 </header>

← 562f3b6 asseeninmovies: /search HTML page — combined movies+people g  ·  back to AsSeenInMovies  ·  asseeninmovies: /spotted HTML gallery — verified placements 6ed5341 →