← 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
M routes/admin.jsM routes/public.jsM server.jsA tests/e2e-paper-comments.jsA views/admin/papers-moderation.ejsM views/public/papers-detail.ejs
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 > 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 →