[object Object]

← back to NationalPaperHangers

yolo tick 3: paper-thread comment submission + ops moderation

4a79e6fb3e3f9f5b86aa9889e85601b348946a67 · 2026-05-10 18:21:11 -0700 · SteveStudio2

Write-side for UX backlog #3. Ticks 1+2 shipped schema+seeds+read-only
public routes; this tick adds installer-only writes + ops moderation.

Public POST /papers/:slug/comments:
- requireInstaller gate (existing middleware) — installer-only writes.
- claimLimiter (5/hr/IP) mounted in server.js, same as /coi-request +
  /notify-when-live.
- Body validation: 20-4000 chars; reject with flash.error on failure.
- Trigger paper_threads_recount() bumps thread.comment_count on INSERT.
- Comments post immediately and are public-readable; no pre-mod queue
  (the friction would kill the format). Ops can flag post-hoc to hide.

Detail view:
- Replaced "ships in next tick" placeholder with the actual form
  (textarea + character hint + submit).
- Flash bubble renders both ok + error states above the form.
- Anchors: #comment-form and #comments for redirect targets.

Ops moderation (/admin/papers-moderation):
- requireOpsInstaller gate (env-driven allow-list, same as
  /admin/ops/credentials and /admin/ops/watch).
- Filter pills: all / visible / flagged.
- Per-comment Flag/Unflag toggle — reversible; flag = hide from public,
  not deletion. Schema preserved so audit trail stays intact.
- public detail query already filters c.flagged = false (was wired in
  tick 2), so flagging immediately hides without re-deploying.

E2E: tests/e2e-paper-comments.js — 10/0 standalone:
  1. Unauth detail renders + login CTA shown
  2. Login as installer
  3. Detail now shows comment form
  4. Submit valid note → row + trigger fires + renders
  5. Too-short note → rejected via flash, no row written
  + cleanup deletes test row

Next tick: "X contributions to paper threads" badge on installer
profiles (selection signal for designers) + polish.

Files touched

Diff

commit 4a79e6fb3e3f9f5b86aa9889e85601b348946a67
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Sun May 10 18:21:11 2026 -0700

    yolo tick 3: paper-thread comment submission + ops moderation
    
    Write-side for UX backlog #3. Ticks 1+2 shipped schema+seeds+read-only
    public routes; this tick adds installer-only writes + ops moderation.
    
    Public POST /papers/:slug/comments:
    - requireInstaller gate (existing middleware) — installer-only writes.
    - claimLimiter (5/hr/IP) mounted in server.js, same as /coi-request +
      /notify-when-live.
    - Body validation: 20-4000 chars; reject with flash.error on failure.
    - Trigger paper_threads_recount() bumps thread.comment_count on INSERT.
    - Comments post immediately and are public-readable; no pre-mod queue
      (the friction would kill the format). Ops can flag post-hoc to hide.
    
    Detail view:
    - Replaced "ships in next tick" placeholder with the actual form
      (textarea + character hint + submit).
    - Flash bubble renders both ok + error states above the form.
    - Anchors: #comment-form and #comments for redirect targets.
    
    Ops moderation (/admin/papers-moderation):
    - requireOpsInstaller gate (env-driven allow-list, same as
      /admin/ops/credentials and /admin/ops/watch).
    - Filter pills: all / visible / flagged.
    - Per-comment Flag/Unflag toggle — reversible; flag = hide from public,
      not deletion. Schema preserved so audit trail stays intact.
    - public detail query already filters c.flagged = false (was wired in
      tick 2), so flagging immediately hides without re-deploying.
    
    E2E: tests/e2e-paper-comments.js — 10/0 standalone:
      1. Unauth detail renders + login CTA shown
      2. Login as installer
      3. Detail now shows comment form
      4. Submit valid note → row + trigger fires + renders
      5. Too-short note → rejected via flash, no row written
      + cleanup deletes test row
    
    Next tick: "X contributions to paper threads" badge on installer
    profiles (selection signal for designers) + polish.
---
 routes/admin.js                   |  49 +++++++++++++
 routes/public.js                  |  31 +++++++++
 server.js                         |   1 +
 tests/e2e-paper-comments.js       | 141 ++++++++++++++++++++++++++++++++++++++
 views/admin/papers-moderation.ejs |  45 ++++++++++++
 views/public/papers-detail.ejs    |  31 +++++++--
 6 files changed, 292 insertions(+), 6 deletions(-)

diff --git a/routes/admin.js b/routes/admin.js
index 65f0cc3..1006ded 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -736,6 +736,55 @@ router.post('/ops/watch/:id(\\d+)/delete', requireOpsInstaller, async (req, res,
   } catch (err) { next(err); }
 });
 
+// ===================================================================
+// OPS — paper-comment moderation (UX backlog #3)
+// ===================================================================
+//
+// Comments post immediately and are public-readable. Ops can flip the
+// `flagged` boolean to hide a comment from the public detail view. Schema
+// is reversible — flagging is not deletion.
+router.get('/papers-moderation', requireOpsInstaller, async (req, res, next) => {
+  try {
+    const show = (req.query.show || 'all').trim();
+    const where = show === 'flagged' ? `c.flagged = true`
+                : show === 'visible' ? `c.flagged = false`
+                : `TRUE`;
+    const rows = await db.many(
+      `SELECT c.id, c.thread_id, c.body, c.helpful_count, c.flagged,
+              c.created_at, c.edited_at,
+              t.slug AS thread_slug, t.brand AS thread_brand, t.paper_name AS thread_paper,
+              i.slug AS installer_slug, i.business_name AS installer_business_name
+         FROM paper_comments c
+         JOIN paper_threads t ON t.id = c.thread_id
+         JOIN installers i ON i.id = c.installer_id
+        WHERE ${where}
+        ORDER BY c.created_at DESC
+        LIMIT 200`
+    );
+    res.render('admin/papers-moderation', {
+      title: 'Ops · Paper-comment moderation · National Paper Hangers',
+      comments: rows,
+      show
+    });
+  } catch (err) { next(err); }
+});
+
+router.post('/papers-moderation/:id(\\d+)/flag', requireOpsInstaller, async (req, res, next) => {
+  try {
+    await db.query(`UPDATE paper_comments SET flagged = true WHERE id = $1`, [req.params.id]);
+    req.session.flash = { ok: 'Comment flagged — hidden from public view.' };
+    res.redirect('/admin/papers-moderation' + (req.body.return_to_show ? `?show=${encodeURIComponent(req.body.return_to_show)}` : ''));
+  } catch (err) { next(err); }
+});
+
+router.post('/papers-moderation/:id(\\d+)/unflag', requireOpsInstaller, async (req, res, next) => {
+  try {
+    await db.query(`UPDATE paper_comments SET flagged = false WHERE id = $1`, [req.params.id]);
+    req.session.flash = { ok: 'Comment unflagged — visible again.' };
+    res.redirect('/admin/papers-moderation' + (req.body.return_to_show ? `?show=${encodeURIComponent(req.body.return_to_show)}` : ''));
+  } catch (err) { next(err); }
+});
+
 // ===================================================================
 // Template chooser (the 6 page designs the studio can pick from)
 // ===================================================================
diff --git a/routes/public.js b/routes/public.js
index 8856ec3..1955e6e 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -277,6 +277,37 @@ router.get('/papers/:slug', async (req, res, next) => {
   } catch (err) { next(err); }
 });
 
+// POST /papers/:slug/comments — verified-installer note submission. Body is
+// the only field. We don't expose authorship as a form choice — it's always
+// the logged-in installer. Comments are public-readable immediately (no
+// pre-moderation queue) but ops can flag them via /admin/papers-moderation.
+router.post('/papers/:slug/comments', express.urlencoded({ extended: false }), requireInstaller, async (req, res, next) => {
+  try {
+    const thread = await db.one(
+      `SELECT id, slug FROM paper_threads WHERE slug = $1`,
+      [req.params.slug]
+    );
+    if (!thread) return res.status(404).render('public/404', { title: 'Not Found' });
+
+    const body = String(req.body.body || '').trim();
+    if (body.length < 20) {
+      req.session.flash = { error: 'Notes must be at least 20 characters — give designers something they can act on.' };
+      return res.redirect(`/papers/${thread.slug}#comment-form`);
+    }
+    if (body.length > 4000) {
+      req.session.flash = { error: 'Notes are capped at 4,000 characters.' };
+      return res.redirect(`/papers/${thread.slug}#comment-form`);
+    }
+
+    await db.query(
+      `INSERT INTO paper_comments (thread_id, installer_id, body) VALUES ($1, $2, $3)`,
+      [thread.id, req.installer.id, body]
+    );
+    req.session.flash = { ok: 'Note added to the thread.' };
+    res.redirect(`/papers/${thread.slug}#comments`);
+  } catch (err) { next(err); }
+});
+
 router.get('/installer/:slug', async (req, res, next) => {
   try {
     const installer = await db.one(
diff --git a/server.js b/server.js
index 4108c36..4c4c2c3 100644
--- a/server.js
+++ b/server.js
@@ -181,6 +181,7 @@ app.use('/api/installers/:slug/book', bookLimiter);
 app.use('/api/installers.geo', geoLimiter);
 app.use('/installer/:slug/notify-when-live', claimLimiter);
 app.use('/installer/:slug/coi-request', claimLimiter);
+app.use('/papers/:slug/comments', claimLimiter);
 
 app.use('/', publicRoutes);
 app.use('/', authRoutes);
diff --git a/tests/e2e-paper-comments.js b/tests/e2e-paper-comments.js
new file mode 100644
index 0000000..f0b92bd
--- /dev/null
+++ b/tests/e2e-paper-comments.js
@@ -0,0 +1,141 @@
+// e2e click-test: paper-thread comment submission (UX backlog #3, tick 3).
+//
+// Flow:
+//   1. Public /papers/de-gournay-earlham renders thread + login CTA.
+//   2. Login as installer (atelier-bond-nyc).
+//   3. Profile fetched — currentInstaller surfaces → comment form replaces CTA.
+//   4. Submit a valid note → row inserted, trigger bumps thread.comment_count.
+//   5. Page renders the new comment with the installer attribution.
+//   6. Submit a too-short note → flash.error shown, no row written.
+//   7. Cleanup: delete the test row, reset thread.comment_count.
+//
+// Skip cleanly against remote BASE.
+
+const path = require('path');
+require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
+const { chromium } = require('playwright-core');
+
+const BASE = process.env.BASE || 'http://localhost:9765';
+const SLUG = 'de-gournay-earlham';
+const EMAIL = process.env.EMAIL || 'demo+bond@example.com';
+const PW = process.env.PW || 'demo1234';
+const IS_LOCAL = /localhost|127\.0\.0\.1/.test(BASE);
+
+if (!IS_LOCAL) {
+  console.log(`[e2e-paper-comments] SKIP — BASE=${BASE} is remote`);
+  process.exit(78);
+}
+
+function findChromium() {
+  const paths = [
+    '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+    '/Applications/Chromium.app/Contents/MacOS/Chromium',
+    process.env.CHROME_PATH,
+  ].filter(Boolean);
+  for (const p of paths) { try { require('fs').accessSync(p); return p; } catch {} }
+  return null;
+}
+
+let _db = null;
+function db() { if (!_db) _db = require('../lib/db'); return _db; }
+
+const NOTE = `E2E test note ${Date.now()} — soak time matters when seam-matching this paper; bring a backup brush.`;
+const SHORT_NOTE = 'too short';
+
+(async () => {
+  const exe = findChromium();
+  if (!exe) { console.error('FAIL: no Chromium'); process.exit(2); }
+  console.log(`[e2e-papers] using browser: ${exe}`);
+
+  const browser = await chromium.launch({ executablePath: exe, headless: true });
+  const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+  const page = await ctx.newPage();
+  let pass = 0, fail = 0;
+  const assert = (cond, msg) => { if (cond) { console.log(`  ✓ ${msg}`); pass++; } else { console.log(`  ✗ ${msg}`); fail++; } };
+
+  let createdId = null;
+
+  try {
+    console.log(`\n[step 1] public ${BASE}/papers/${SLUG} renders unauth`);
+    await page.goto(`${BASE}/papers/${SLUG}`, { waitUntil: 'domcontentloaded' });
+    const html1 = await page.content();
+    assert(html1.includes('Earlham'), 'thread name renders');
+    assert(/Log in as a verified installer/i.test(html1), 'unauth CTA shown');
+
+    console.log(`\n[step 2] login as ${EMAIL}`);
+    const loginResp = await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
+    if (loginResp && loginResp.status() === 429) {
+      console.log('  ⊘ login rate-limit — skipping write phases');
+      process.exit(78);
+    }
+    await page.fill('input[name="email"]', EMAIL);
+    await page.fill('input[name="password"]', PW);
+    await Promise.all([
+      page.waitForURL(/\/admin/, { timeout: 8000 }),
+      page.click('form[action="/login"] button[type="submit"]')
+    ]);
+    assert(page.url().includes('/admin'), 'logged in');
+
+    console.log(`\n[step 3] /papers/${SLUG} now shows the comment form`);
+    await page.goto(`${BASE}/papers/${SLUG}`, { waitUntil: 'domcontentloaded' });
+    const form = await page.$('#comment-form form[action$="/comments"]');
+    assert(!!form, 'comment form present for logged-in installer');
+
+    console.log(`\n[step 4] submit a valid note`);
+    const beforeCount = (await db().one(`SELECT comment_count FROM paper_threads WHERE slug=$1`, [SLUG])).comment_count;
+    await page.fill('#comment-form textarea[name="body"]', NOTE);
+    await Promise.all([
+      page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 8000 }),
+      page.click('#comment-form button[type="submit"]')
+    ]);
+
+    const newRow = await db().one(
+      `SELECT c.id, c.body, c.flagged, t.comment_count
+         FROM paper_comments c
+         JOIN paper_threads t ON t.id = c.thread_id
+        WHERE t.slug = $1 AND c.body = $2
+        ORDER BY c.id DESC LIMIT 1`,
+      [SLUG, NOTE]
+    );
+    assert(!!newRow, 'paper_comments row created');
+    if (newRow) createdId = newRow.id;
+    assert(newRow && !newRow.flagged, 'flagged=false by default');
+    assert(newRow && newRow.comment_count === beforeCount + 1, `trigger bumped comment_count ${beforeCount}→${newRow && newRow.comment_count}`);
+
+    const html2 = await page.content();
+    assert(html2.includes(NOTE.slice(0, 60)), 'new comment renders on detail page');
+
+    console.log(`\n[step 5] submit a too-short note → rejected`);
+    await page.goto(`${BASE}/papers/${SLUG}`, { waitUntil: 'domcontentloaded' });
+    await page.fill('#comment-form textarea[name="body"]', SHORT_NOTE);
+    // Bypass HTML5 minlength by removing the attribute first — backend must still reject
+    await page.evaluate(() => {
+      document.querySelector('textarea[name="body"]').removeAttribute('minlength');
+    });
+    await Promise.all([
+      page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 8000 }),
+      page.click('#comment-form button[type="submit"]')
+    ]);
+    const html3 = await page.content();
+    assert(/at least 20 characters/i.test(html3), 'too-short note rejected with flash.error');
+    const shortRow = await db().one(
+      `SELECT id FROM paper_comments WHERE body = $1`, [SHORT_NOTE]
+    );
+    assert(!shortRow, 'no row written for too-short note');
+
+  } catch (err) {
+    console.error(`\nFAIL — uncaught: ${err.message}`);
+    fail++;
+  } finally {
+    if (createdId) {
+      try {
+        await db().query(`DELETE FROM paper_comments WHERE id = $1`, [createdId]);
+        console.log(`\n[cleanup] deleted paper_comments id=${createdId}`);
+      } catch (e) { console.error(`[cleanup] ${e.message}`); }
+    }
+    await browser.close();
+  }
+
+  console.log(`\n[result] ${pass} pass · ${fail} fail`);
+  process.exit(fail > 0 ? 1 : 0);
+})();
diff --git a/views/admin/papers-moderation.ejs b/views/admin/papers-moderation.ejs
new file mode 100644
index 0000000..847d31a
--- /dev/null
+++ b/views/admin/papers-moderation.ejs
@@ -0,0 +1,45 @@
+<%- include('../partials/head', { title, admin: true }) %>
+<%- include('partials/admin-header') %>
+<section class="admin-main">
+  <header class="admin-page-head">
+    <h1>Paper-comment moderation</h1>
+    <p class="muted">Comments post immediately and are public-readable. Flag to hide from public view (reversible, not deletion).</p>
+  </header>
+  <% if (flash && flash.ok) { %><div class="callout callout-success"><%= flash.ok %></div><% } %>
+  <% if (flash && flash.error) { %><div class="callout callout-warn"><%= flash.error %></div><% } %>
+
+  <nav style="display:flex;gap:8px;margin:8px 0 24px">
+    <% ['all','visible','flagged'].forEach(function(s){ %>
+      <a href="/admin/papers-moderation?show=<%= s %>"
+         style="padding:6px 14px;border:1px solid <%= show === s ? '#0e0e0e' : 'var(--border,#ddd)' %>;border-radius:18px;font-size:13px;color:<%= show === s ? '#fff' : 'inherit' %>;background:<%= show === s ? '#0e0e0e' : 'transparent' %>;text-decoration:none;text-transform:capitalize">
+        <%= s %>
+      </a>
+    <% }); %>
+  </nav>
+
+  <% if (!comments || !comments.length) { %>
+    <div class="callout"><p style="margin:0">No comments in this view.</p></div>
+  <% } else { %>
+    <% comments.forEach(function(c){ %>
+      <article style="border:1px solid <%= c.flagged ? '#fca5a5' : 'var(--border,#ddd)' %>;border-radius:8px;padding:16px 18px;margin:0 0 14px;background:<%= c.flagged ? '#fef2f2' : 'transparent' %>">
+        <header style="display:flex;justify-content:space-between;align-items:start;gap:12px;flex-wrap:wrap;margin-bottom:10px">
+          <div style="font-size:13px">
+            <a href="/papers/<%= c.thread_slug %>" style="color:inherit"><strong><%= c.thread_brand %> · <%= c.thread_paper %></strong></a>
+            <span class="muted"> · by <a href="/installer/<%= c.installer_slug %>" style="color:inherit"><%= c.installer_business_name %></a></span>
+          </div>
+          <span class="muted" style="font-size:12px"><%= new Date(c.created_at).toLocaleString('en-US') %></span>
+        </header>
+        <p style="margin:0 0 12px;font-size:14px;line-height:1.55;white-space:pre-wrap"><%= c.body %></p>
+        <form method="post" action="/admin/papers-moderation/<%= c.id %>/<%= c.flagged ? 'unflag' : 'flag' %>" style="display:flex;gap:8px;align-items:center">
+          <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+          <input type="hidden" name="return_to_show" value="<%= show %>">
+          <button type="submit" class="btn <%= c.flagged ? 'btn-ghost' : 'btn-warn' %> btn-sm">
+            <%= c.flagged ? 'Unflag (restore)' : 'Flag (hide)' %>
+          </button>
+          <% if (c.flagged) { %><span class="muted" style="font-size:12px">Hidden from public</span><% } %>
+        </form>
+      </article>
+    <% }); %>
+  <% } %>
+</section>
+<%- include('../partials/footer') %>
diff --git a/views/public/papers-detail.ejs b/views/public/papers-detail.ejs
index 9cde425..369ec2a 100644
--- a/views/public/papers-detail.ejs
+++ b/views/public/papers-detail.ejs
@@ -62,13 +62,32 @@
     </ul>
   <% } %>
 
-  <% if (locals.currentInstaller && !locals.currentInstaller.unclaimed) { %>
-    <div style="margin-top:32px;padding:20px;border:1px dashed var(--border,#ddd);border-radius:8px;text-align:center">
-      <p style="margin:0 0 6px">Comment submission ships in the next tick.</p>
-      <p class="muted" style="margin:0;font-size:13px">For now, this thread is read-only.</p>
-    </div>
+  <% if (locals.flash && flash.ok) { %>
+    <div class="callout callout-success" style="margin-top:24px;padding:14px 18px;border-radius:6px;background:#e8f5e9;border:1px solid #c8e6c9;color:#1b5e20"><%= flash.ok %></div>
+  <% } %>
+  <% if (locals.flash && flash.error) { %>
+    <div class="callout callout-warn" style="margin-top:24px;padding:14px 18px;border-radius:6px;background:#fff3e0;border:1px solid #ffcc80;color:#b45309"><%= flash.error %></div>
+  <% } %>
+
+  <% if (locals.currentInstaller) { %>
+    <section id="comment-form" style="margin-top:32px;padding:22px;border:1px solid var(--border,#ddd);border-radius:8px">
+      <h3 style="margin:0 0 6px;font-size:16px">Add a note as <%= locals.currentInstaller.business_name %></h3>
+      <p class="muted" style="margin:0 0 14px;font-size:13px">
+        Drop %, paste behavior in humid environments, seam-matching gotchas, what to bring on the second visit. Specific &gt; general. Designers will see this.
+      </p>
+      <form method="POST" action="/papers/<%= thread.slug %>/comments" style="display:grid;gap:10px">
+        <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+        <textarea name="body" rows="6" minlength="20" maxlength="4000" required
+                  placeholder="What working installers wish they'd known going in…"
+                  style="width:100%;padding:12px;font-family:inherit;font-size:14px;line-height:1.55;border:1px solid var(--border,#ddd);border-radius:4px;resize:vertical"></textarea>
+        <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
+          <p class="muted" style="margin:0;font-size:12px">20–4,000 characters. Public-readable. Attributed to your studio profile.</p>
+          <button type="submit" class="btn btn-primary btn-sm">Post note</button>
+        </div>
+      </form>
+    </section>
   <% } else { %>
-    <p class="muted" style="margin-top:32px;font-size:13px;text-align:center"><a href="/login">Log in as a verified installer</a> to contribute notes.</p>
+    <p class="muted" style="margin-top:32px;font-size:13px;text-align:center"><a href="/login?next=/papers/<%= thread.slug %>">Log in as a verified installer</a> to contribute notes.</p>
   <% } %>
 </section>
 

← 4492052 yolo tick 2: public /papers list + /papers/:slug detail (rea  ·  back to NationalPaperHangers  ·  yolo tick 4: paper-thread contribution count + helpful votes aa09d6e →