← back to NationalPaperHangers
v0.6: notify-when-live capture form on unclaimed /book
f339c343c5e4bf701cf3d51310bfe5f410c5fce8 · 2026-05-06 15:03:17 -0700 · SteveStudio2
Turns the dead-end /book page on unclaimed installers into a capture funnel.
- db/migrations/016_installer_interest.sql: new table (installer_id, email,
source, zip, notes, ip_hash, created_at, notified_at, notify_msg_id) with
UNIQUE(installer_id, email) for per-studio dedup. Partial index on un-notified
rows for fast queue scans.
- routes/public.js: POST /installer/:slug/notify-when-live — basic email shape
validation, ip_hash via SESSION_SECRET-salted sha256, ON CONFLICT DO NOTHING
on the dedup constraint. Silent no-op when slug is already claimed (don't
leak that someone else's interest worked). Renders book-notify-result.
- views/public/book-notify-result.ejs: success/error landing page.
- views/public/book.ejs: capture form on the unclaimed branch only — email +
ZIP fields, CSRF token, micro-copy promising one email when they're live.
- server.js: claimLimiter applied to the new route (5 req/hour/IP).
Live test on prod (demo-unclaimed-ridgeway-paper, 2026-05-06): form renders,
POST returns 'Got it', row lands in installer_interest with the correct
email/zip/source. Auto-email-on-claim is v0.7.
Files touched
A db/migrations/016_installer_interest.sqlM routes/public.jsM server.jsA views/public/book-notify-result.ejsM views/public/book.ejs
Diff
commit f339c343c5e4bf701cf3d51310bfe5f410c5fce8
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 6 15:03:17 2026 -0700
v0.6: notify-when-live capture form on unclaimed /book
Turns the dead-end /book page on unclaimed installers into a capture funnel.
- db/migrations/016_installer_interest.sql: new table (installer_id, email,
source, zip, notes, ip_hash, created_at, notified_at, notify_msg_id) with
UNIQUE(installer_id, email) for per-studio dedup. Partial index on un-notified
rows for fast queue scans.
- routes/public.js: POST /installer/:slug/notify-when-live — basic email shape
validation, ip_hash via SESSION_SECRET-salted sha256, ON CONFLICT DO NOTHING
on the dedup constraint. Silent no-op when slug is already claimed (don't
leak that someone else's interest worked). Renders book-notify-result.
- views/public/book-notify-result.ejs: success/error landing page.
- views/public/book.ejs: capture form on the unclaimed branch only — email +
ZIP fields, CSRF token, micro-copy promising one email when they're live.
- server.js: claimLimiter applied to the new route (5 req/hour/IP).
Live test on prod (demo-unclaimed-ridgeway-paper, 2026-05-06): form renders,
POST returns 'Got it', row lands in installer_interest with the correct
email/zip/source. Auto-email-on-claim is v0.7.
---
db/migrations/016_installer_interest.sql | 29 ++++++++++++++++++
routes/public.js | 51 ++++++++++++++++++++++++++++++++
server.js | 1 +
views/public/book-notify-result.ejs | 21 +++++++++++++
views/public/book.ejs | 15 ++++++++++
5 files changed, 117 insertions(+)
diff --git a/db/migrations/016_installer_interest.sql b/db/migrations/016_installer_interest.sql
new file mode 100644
index 0000000..0694071
--- /dev/null
+++ b/db/migrations/016_installer_interest.sql
@@ -0,0 +1,29 @@
+-- Capture queue for "notify me when this studio is on NPH" submissions
+-- collected from the /book page when an installer is unclaimed. When the
+-- studio claims their listing + configures availability, every email in
+-- this table for that installer_id gets a one-shot notification email.
+--
+-- Why a real table: a queue beats firing emails inline because (a) the
+-- claimer's first action might be over hours/days/weeks after the request,
+-- and (b) compliance scrub (suppression list) needs to run at send-time,
+-- not capture-time, since users can opt out in between.
+
+CREATE TABLE IF NOT EXISTS installer_interest (
+ id BIGSERIAL PRIMARY KEY,
+ installer_id BIGINT NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
+ email TEXT NOT NULL, -- always stored lower-cased; route handler normalizes
+ source TEXT NOT NULL DEFAULT 'book_page',
+ zip TEXT,
+ notes TEXT,
+ ip_hash TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ notified_at TIMESTAMPTZ,
+ notify_msg_id TEXT,
+ UNIQUE (installer_id, email)
+);
+
+CREATE INDEX IF NOT EXISTS installer_interest_installer_id_idx
+ ON installer_interest (installer_id) WHERE notified_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS installer_interest_email_idx
+ ON installer_interest (email);
diff --git a/routes/public.js b/routes/public.js
index 5a553cb..092def2 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -270,6 +270,57 @@ router.get('/installer/:slug/book', async (req, res, next) => {
} catch (err) { next(err); }
});
+// POST /installer/:slug/notify-when-live — capture interest from visitors who
+// hit /book on an unclaimed studio. When the studio claims + configures
+// availability, every email here gets a one-shot notification (separate
+// scheduled job; not fired inline). Per-installer dedup via UNIQUE constraint.
+router.post('/installer/:slug/notify-when-live', express.urlencoded({ extended: false }), async (req, res, next) => {
+ try {
+ const installer = await db.one(
+ `SELECT id, slug, business_name, claim_status FROM installers WHERE slug = $1`,
+ [req.params.slug]
+ );
+ if (!installer) return res.status(404).render('public/404', { title: 'Not Found' });
+
+ // Only meaningful for unclaimed listings — silently treat already-claimed
+ // as a no-op (avoid leaking that a different visitor's "interest" worked).
+ const email = String(req.body.email || '').trim().toLowerCase();
+ const zip = String(req.body.zip || '').trim().slice(0, 10) || null;
+ const notes = String(req.body.notes || '').trim().slice(0, 500) || null;
+
+ // Basic email shape check; defer real validation to send-time.
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ return res.status(400).render('public/book-notify-result', {
+ title: 'Email needed',
+ installer,
+ ok: false,
+ error: 'Please enter a valid email address.'
+ });
+ }
+
+ if (installer.claim_status === 'unclaimed') {
+ const ipHash = require('crypto')
+ .createHash('sha256')
+ .update((req.headers['x-forwarded-for'] || req.ip || '') + (process.env.SESSION_SECRET || ''))
+ .digest('hex')
+ .slice(0, 16);
+ await db.query(
+ `INSERT INTO installer_interest (installer_id, email, source, zip, notes, ip_hash)
+ VALUES ($1, $2, 'book_page', $3, $4, $5)
+ ON CONFLICT (installer_id, email) DO NOTHING`,
+ [installer.id, email, zip, notes, ipHash]
+ );
+ }
+
+ res.render('public/book-notify-result', {
+ title: 'Got it',
+ installer,
+ ok: true,
+ email
+ });
+ } catch (err) { next(err); }
+});
+
// /bookings/:uuid — booking detail page with PII (customer email, phone,
// address). Access is gated three ways:
// (a) ?t=<HMAC sig> — the link emailed in the booking confirmation
diff --git a/server.js b/server.js
index dd21b81..26c625f 100644
--- a/server.js
+++ b/server.js
@@ -179,6 +179,7 @@ app.use('/signup', loginLimiter);
app.use(['/installer/:slug/claim', '/installer/:slug/claim/complete'], claimLimiter);
app.use('/api/installers/:slug/book', bookLimiter);
app.use('/api/installers.geo', geoLimiter);
+app.use('/installer/:slug/notify-when-live', claimLimiter);
app.use('/', publicRoutes);
app.use('/', authRoutes);
diff --git a/views/public/book-notify-result.ejs b/views/public/book-notify-result.ejs
new file mode 100644
index 0000000..e549ff5
--- /dev/null
+++ b/views/public/book-notify-result.ejs
@@ -0,0 +1,21 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="book-page" style="max-width:680px;margin:0 auto;padding:48px 24px">
+ <% if (ok) { %>
+ <div class="callout" style="text-align:center">
+ <h1 class="display-sm" style="margin:0 0 12px">You're on the list</h1>
+ <p style="font-size:16px;line-height:1.55">We'll email <strong><%= email %></strong> the moment <strong><%= installer.business_name %></strong> claims their listing and turns on bookings on National Paper Hangers.</p>
+ <p class="muted" style="margin-top:20px;font-size:13px">In the meantime, <a href="/find">browse studios with live calendars</a> if your project is on a deadline.</p>
+ <a href="/installer/<%= installer.slug %>" class="btn btn-ghost btn-sm" style="margin-top:24px">← Back to <%= installer.business_name %>'s profile</a>
+ </div>
+ <% } else { %>
+ <div class="callout" style="border-color:#dc2626">
+ <h1 class="display-sm" style="margin:0 0 12px">Something needs fixing</h1>
+ <p style="font-size:15px;line-height:1.55"><%= error %></p>
+ <a href="/installer/<%= installer.slug %>/book" class="btn btn-primary" style="margin-top:16px">← Try again</a>
+ </div>
+ <% } %>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/book.ejs b/views/public/book.ejs
index 8419d76..426b2b3 100644
--- a/views/public/book.ejs
+++ b/views/public/book.ejs
@@ -100,6 +100,21 @@
<% if (installer.phone) { %><a href="tel:<%= installer.phone %>" class="btn btn-ghost">Call <%= installer.phone %></a><% } %>
</div>
<% if (_reason === 'unclaimed') { %>
+ <form method="post" action="/installer/<%= installer.slug %>/notify-when-live" style="margin:24px 0;padding:20px;border-top:1px solid var(--border);background:var(--bg-alt,#f8f7f2);border-radius:10px">
+ <input type="hidden" name="_csrf" value="<%= typeof csrfToken !== 'undefined' ? csrfToken : '' %>">
+ <h3 style="margin:0 0 8px;font-size:18px">Want a heads-up when they're on?</h3>
+ <p class="muted" style="margin:0 0 12px;font-size:13px">We'll email you the moment <%= installer.business_name %> claims their listing and turns on bookings — usually within a week of outreach.</p>
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
+ <label style="flex:2;min-width:220px">Email
+ <input type="email" name="email" required placeholder="you@example.com" style="width:100%;box-sizing:border-box">
+ </label>
+ <label style="flex:1;min-width:80px">ZIP
+ <input type="text" name="zip" maxlength="10" placeholder="optional" style="width:100%;box-sizing:border-box">
+ </label>
+ <button type="submit" class="btn btn-primary">Notify me</button>
+ </div>
+ <p class="muted" style="margin:10px 0 0;font-size:11px">One email when they're live, then nothing. We don't share emails — you can unsubscribe from the notification with one click.</p>
+ </form>
<p class="muted" style="margin-top:16px;font-size:13px">Are you the owner of <%= installer.business_name %>? <a href="/installer/<%= installer.slug %>#claim">Claim this listing →</a> and configure your calendar to start receiving bookings directly through NPH.</p>
<% } else { %>
<p class="muted">Want to book directly into a calendar instead? <a href="/find">Browse studios with live calendars →</a></p>
← 196af63 v0.5: NPH email through Purelymail SMTP + tighter /book cale
·
back to NationalPaperHangers
·
v0.7 #31: auto-email worker for installer_interest queue f21468c →