Creative ideas + design notes
Commits with substantial prose (≥120 chars) — the rationale behind each move.
8c10bac · 2026-06-02 · Add P3 order-handoff to /measure: PDP deep-link prefill + host-allowlisted shop CTA
- /measure reads sanitized ?sku/name/from/repeat/format/panelw/shop from the
launch URL, shows a 'Sizing for <product>' banner, prefills the calculator,
and points 'Shop the look' back at the exact PDP.
- shop return URL host-allowlisted (DW/wallco/philipperomano/novasuede +subs)
to prevent open redirect; product strings EJS-escaped, prefill JSON escapes <.
- 7 route tests (tests/measure-route.test.js), wired into npm test. 50/50 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
313ab55 · 2026-06-01 · Add standalone AR Wall Measurement Kit at /measure (P0+P1)
New designer/DIY tool that reuses the /book RoomMeasure engine (WebXR AR +
gyro clinometer) on a bare page and adds a rolls/panels calculator.
- public/js/measure-kit.js — pure UMD calc brain (US/Euro rolls + mural panels,
per-wall door/window deductions, repeat-aware waste). 14 unit tests, all pass.
- views/public/measure.ejs — device-aware hero CTA, multi-wall measure + manual
entry, live result card with plain-English buy line + dual handoff (shop DW /
find an installer). Everything client-side; no server storage.
- routes/public.js — GET /measure + sitemap entry. header.ejs — nav link.
- DATA_POLICY.md §11 — on-device-only measurement, no AR frames leave phone.
- package.json — measure-kit tests wired into npm test (now 43/43).
/book installer flow untouched. Local only — deploy is Steve-gated.
ec8d505 · 2026-05-19 · Hold back ai_agent call mode at launch (DTD verdict 2026-05-19)
The DTD panel (2/1) ruled ai_agent should not ship in the initial roster:
an AI autonomously representing a customer to a business is a different
risk class than hold/bridge and needs a dedicated compliance + UX review
before it is customer-visible. The ai_agent radio is gated behind an
if(false) block; the route and Butlr API still accept the mode, so
re-enabling is a one-token change once the review is done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9f104a2 · 2026-05-19 · Add "Call this installer" Butlr integration to installer pages
A 3-mode calling concierge on every installer profile (all 6 template
variants + legacy layout). The customer taps "Call this installer",
picks a mode in a modal, and NPH hands the call to Butlr's external API.
Modes:
hold — Butlr waits through IVR/hold, calls the customer back
bridge — click-to-call, joins customer + installer on one live call
ai_agent — AI agent delivers the customer's project brief, reports back
lib/butlr.js — thin client for Butlr POST /api/external/place-call
routes/call-installer.js — POST /installer/:slug/call-installer, validates,
normalizes phones, maps Butlr's 429 cap to a clear msg
partials/call-installer.ejs — button + modal, brief field shown only for
ai_agent, inline AJAX result
server.js — mounts route, 6 calls/hr/IP rate limit
public.js — passes butlrEnabled (butlr.isConfigured()) to render
Button hidden site-wide when BUTLR_EXTERNAL_SECRET is unset or the studio
has no phone on file. CSRF-protected via the global middleware.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
47d0f2e · 2026-05-18 · Guard booking lifecycle transitions against reviving terminal bookings
POST /admin/bookings/:id/{confirm,decline,complete} each ran an
unconditional UPDATE that overwrote status regardless of the booking's
current state. A stale admin tab, a back-button re-POST, or a
double-submit could therefore flip a completed, canceled, or declined
booking back to confirmed — silently, with no feedback either way since
these routes (unlike every other admin POST) skipped the session flash.
Each UPDATE now guards on the valid prior states in its WHERE clause:
confirm only from 'pending'; decline and complete only from
'pending'/'confirmed'. A 0-row result means the transition no longer
applies and is flashed back to the installer instead of redirecting as
a silent success. Test inserts bookings in terminal states and asserts
confirm/decline cannot touch them while the pending->confirmed->completed
path still works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
362b0cc · 2026-05-18 · Carry the IDOR-gate token on the customer's booking-confirmation link
/bookings/:uuid is access-gated three ways; for a customer (who is not
the owning installer) the only way in is the HMAC ?t= token — without it
the page 404s so booking UUIDs can't be enumerated. But the customer
confirmation email built the link as a bare plain-text URL,
`${publicUrl}/bookings/${booking.uuid}`, with no ?t= param. Every
customer who clicked through from their own confirmation email hit a
404 and could not view, reschedule, or cancel their booking.
The booking object passed to bookingConfirmationCustomer already carries
view_token (set in routes/api.js after signing). The link now appends
?t=<view_token> and is a real <a> anchor, matching the installer
notification email's idiom. Unit test asserts the emitted HTML links to
/bookings/:uuid?t=<token> and that the embedded token verifies against
the uuid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
da5acf0 · 2026-05-18 · Validate availability time windows instead of silently breaking the calendar
POST /admin/availability checked only that day_of_week was 0-6 and that
the time fields were non-empty — unlike POST /admin/time-off right below
it, which validates dates and ordering. So an installer could save an
availability window with a malformed time (e.g. "99:99"), which 500s on
the ::time-cast INSERT, or an inverted one (start_time 17:00 >
end_time 09:00), which saves cleanly with a 201 and shows in the
calendar UI but produces zero bookable slots: slots.js builds an invalid
luxon Interval and silently drops the whole window, so the installer
gets no bookings and no error explaining why.
A clockMinutes() helper in lib/utils.js parses "H:MM" / "HH:MM" /
"HH:MM:SS" to minute-of-day (or null when malformed); the route now
rejects malformed input with 400 invalid_time and inverted windows with
400 invalid_time_range. Unit-tested across valid forms, every malformed
shape, and ordering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3fa1e62 · 2026-05-18 · Coerce garbage numeric booking fields to null instead of 500ing
A booking POSTed directly with a non-numeric square_feet,
ceiling_height_ft, or roll_count_estimate ran parseFloat/parseInt → NaN
straight into an INTEGER/NUMERIC column, so the INSERT failed with
"invalid input syntax for type integer: NaN" and the customer got an
opaque 500. A numOrNull() helper now parses each field and yields null
when the value isn't a finite number; square_feet additionally falls
back to the measured-walls total. Verified: a booking with all three
fields set to junk now returns 201 with the columns stored as null.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
515caa4 · 2026-05-18 · Stop clobbering the customer's hand-entered square footage
room-capture.js promised "customer can override" the square_feet field
but serialize() overwrote it with the measured-walls total on every box
drag, keystroke, or wallpaper change — so the override only held when
zero rooms were measured. A userEditedSqft flag now latches on the
field's first real 'input' event (a programmatic .value set never fires
one), and the auto-fill is skipped once it trips. e2e proves a typed
999 survives a later room re-measure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3e66967 · 2026-05-18 · Validate booking-media uploads by magic bytes, not the spoofable mimetype
The unauthenticated /api/booking-media endpoint trusted the browser's
Content-Type to pick the stored extension, so a 40 MB blob mislabelled
image/png cleared multer's fileFilter and landed in public/uploads/.
New lib/media-sniff.js derives the extension from the file's leading
bytes (JPEG/PNG/WebP/WebM + ISO-BMFF brands for HEIC/AVIF/MP4/MOV);
content that isn't a real image/video is now rejected. e2e adds an
assertion proving a text blob with an image/png Content-Type fails.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
d69c431 · 2026-05-18 · Render true half-drop in the on-wall wallpaper preview
When the customer picks half-drop match, the preview now composites a
canvas 'super-tile' — two panels wide with the right column dropped by
half the vertical repeat (plus a wrap copy so it tiles seamlessly) — and
tiles that straight at double width. Falls back to straight tiling while
the image is still loading. Output is a data: URL (allowed by the page
CSP). e2e gains a half-drop assertion; suite 184 pass · 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
51b33df · 2026-05-18 · Harden the room-capture flow for mobile + touch
Mobile is the capture flow's primary surface (phone camera), so:
- @media (pointer:coarse) enlarges the box drag handles to 34px finger
targets; @media (max-width:560px) stacks the spec inputs, full-widths
the file buttons, and caps the photo at 58vh.
- wireBox now attaches pointermove/up to window instead of relying on
setPointerCapture — the drag survives the pointer leaving the small
handle, on mouse and touch alike.
e2e-room-capture gains a corner-drag assertion and a Phase 4 that loads
/book at 390px with touch — no horizontal overflow, finger-sized handles.
Suite 183 pass · 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5c5c9df · 2026-05-18 · e2e-room-capture: 14-assertion test for the camera quote flow
Covers the capture UI (photo upload → measure stage → box-aspect width
derivation → ceiling-height recompute → wallpaper preview + serialization),
the upload endpoint type-guard, and a room_captures DB round-trip proving
the sanitiser drops an off-site photo URL. Auto-discovered by run-e2e.js;
suite now 180 pass · 0 fail. Skips cleanly (78) without Chromium/fixture.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a1dfc05 · 2026-05-18 · Surface room captures in the installer brief — admin page + email
Admin booking-detail gets a 'Room captures' section: per room the wall
photo, walkthrough-video link, confirmed dimensions, and — when the
customer supplied their own wallpaper — its specs plus a true-scale tiled
preview on a wall of the measured proportions. The installer notification
email gets a matching 'Room captures' summary with absolute photo/video
links. Installer quotes from real footage instead of a typed guess.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6557a78 · 2026-05-18 · Persist room_captures on booking POST + derive sq ft from measured walls
New lib/room-captures.js sanitizes the customer-supplied capture JSON:
every photo/video/wallpaper URL must match the exact content-addressed
shape /api/booking-media produces (off-site URLs and path traversal are
dropped), numbers are clamped, the array is capped at 20 rooms, and a
wallpaper missing its width or repeat is dropped. The booking POST stores
the clean array and, when square_feet was left blank, backfills it from
the captured wall total. Verified: 7 sanitizer cases incl. off-site URL +
traversal + overflow; full e2e suite still 166/166.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
d2f12ee · 2026-05-18 · Add camera capture & measure step to the /book wizard
Per room the customer photographs a wall, drags a box to frame it, and
confirms the ceiling height — wall width is derived from the box aspect
(estimate-then-confirm, never a silent measurement). Optional room
walkthrough video. Optional 'preview your own wallpaper': upload the
wallpaper image + its exact panel width and vertical repeat, and it tiles
onto the wall photo at true real-world scale. Everything serializes into
the hidden room_captures field that posts with the booking. Measured walls
auto-total into the square-feet field. Verified via a 9-assertion
Playwright run (upload, box-drag aspect math, wallpaper preview, multi-room).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9df4b87 · 2026-05-18 · Add POST /api/booking-media — public upload for the room-capture flow
Unauthenticated (the customer has no account) so it's IP-rate-limited at
40/hr and uses sha256 content-addressed storage — the client cannot
influence the path. Accepts JPG/PNG/WebP/HEIC images + MP4/MOV/WebM video
up to 40 MB. CSRF is satisfied via the X-CSRF-Token header (multipart
bodies aren't parsed when the CSRF middleware runs). robots.txt now
disallows /uploads/bookings/ so customer media isn't indexed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
81398eb · 2026-05-18 · Migration 022: room_captures JSONB on bookings
Stores camera-captured rooms for the quote brief — per-wall photo/video,
customer-confirmed dimensions, and optional customer-supplied wallpaper
(exact panel width + pattern repeat) for an on-wall scaled preview.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
841f9be · 2026-05-18 · Surface the structured booking brief in the installer notification email
UX backlog #6's promise is 'the installer arrives prepped' — but the new
booking email showed only customer/when/type/address/notes. All the brief
data (ceiling height, surface state, access, brand/SKU, rolls, sq ft,
material, who's ordering, wallpaper-selected) was captured and stored but
never delivered where the installer first sees it. Added a 'Project brief'
section via prettyEnum + infoRow helpers (empty fields omitted; handles
both string req.body and boolean DB-row shapes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2e14b8b · 2026-05-18 · Fix /api/near-me: accept county/town/zip filters without coordinates
The handler computed countyHit/townHit/zipHit matches but the guard
rejected every request lacking lat/lng with 400 — so those three match
paths were unreachable dead logic. Now accepts a request with coordinates
OR any county/town/zip filter; distance_miles is null for filter-only
matches and they sort last. (Endpoint had zero callers since the /map
rewrite — keeping it as a working JSON API rather than shipping it broken.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
283685a · 2026-05-18 · Clear all 6 npm audit vulns — audit fix + bcrypt 5.x→6.0.0
npm audit fix resolved 3 moderate (brace-expansion, express-rate-limit,
ip-address). bcrypt bumped to 6.0.0, which drops the vulnerable
@mapbox/node-pre-gyp + tar chain (3 high) — 46 transitive packages removed.
API-compatible: only hash()/compare() are used, and bcrypt 6 still
validates the existing $2b$10$ hashes in the installers table. Verified
via login + claim-flow e2e. npm audit now reports 0 vulnerabilities.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
71aa66d · 2026-05-18 · Split geoLimiter — map JSON and helpful-votes get independent buckets
Same shared-bucket fix as claimLimiter: /api/installers.geo and the
paper-comment helpful-vote route shared one 60/min bucket, so heavy map
browsing could exhaust a reader's vote budget. Added a minuteLimiter(max)
factory; geoLimiter and voteLimiter are now separate instances.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
868356a · 2026-05-18 · Split shared claimLimiter into per-route buckets (claim/notify/COI/comment)
A single claimLimiter instance was applied to four unrelated routes, so they
shared one 5/hr/IP bucket — a designer who requested 5 COIs could no longer
claim a profile, and burst commenters were blocked by unrelated traffic. Each
route now gets its own independent store via an hourLimiter(prodMax) factory:
claim 5/hr (unchanged, sensitive), notify 10, COI 20, comments 20. Non-prod
stays widened to 100.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
f4d5c8f · 2026-05-18 · smoke.test.js: update /map assertion for address-driven near-me rewrite
/map (commit b3b4e14) no longer client-fetches /api/installers.geo —
markers are server-rendered from an address form. Assert leaflet.css +
the near-me form instead. The geo endpoint stays covered by its own test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4d68e2a · 2026-05-18 · tests/app.js: mount csrfMiddleware to mirror server.js
The COI-request partial references csrfToken; the test app factory never
got the CSRF middleware server.js added, so installer pages 500'd under
supertest. GET smoke tests pass through unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0cf8dae · 2026-05-18 · claimLimiter: widen to 100/hr in non-prod so back-to-back e2e suites don't 429
Shared by claim/notify/COI-request/paper-comments routes. The 5/hr cap
exhausted across suites, 429ing every POST after the first 5 — same class
of bug the loginLimiter commit fixed. Prod stays at 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
b3b4e14 · 2026-05-10 · /map → address-driven near-me view + service_counties/towns/zips
Steve's brief: 'make sure the map feature showing current location and
installers servicing which area … allow user to select their address …
no map of entire country needed.'
Schema (migration 021):
- installers.service_counties TEXT[]
- installers.service_towns TEXT[]
- installers.service_zips TEXT[]
- GIN indexes on each.
Default ARRAY[]::TEXT[] so the 524 seeded studios keep radius-only
behavior until they fill in counties/towns.
Route /map (rewritten):
- No-address visit: just the address-input form + 'Use my location' button.
No country pin map.
- Address visit: server-side geocode via OSM Nominatim (lib/geocode.js,
no API key, 10-min in-memory cache). Returns lat/lng + county + town +
zip + state.
- Filter: installer matches if (within service_radius_miles) OR
(county in service_counties) OR (town in service_towns) OR
(zip in service_zips). Match reasons surface per result.
- Sort: tier-paid first within 5-mi bucket, then distance ascending.
- Renders zoomed Leaflet map (auto-fits to results, max-zoom 12) +
result cards with distance, response time, brand-trained count, tier.
Route /api/near-me — JSON API for downstream widgets. Takes lat/lng +
optional county/town/zip, returns filtered installers + distance.
60s edge cache.
Admin /admin/profile:
- New form fields for service_counties / service_towns / service_zips
(comma-separated, capped per-entry + total count).
- POST handler arrays them safely, caps total list size.
CSP:
- connectSrc adds nominatim.openstreetmap.org for the client-side
'Use my location' reverse-geocode.
Cleanup:
- map.ejs no longer pulls markercluster (unused — single-city result
sets don't need clustering).
Smoke-tested:
- /map → 200, renders form + Use-my-location button.
- /map?address=90210 → 200, geocodes to 34.09 / -118.41 (Beverly Hills),
16 studios service the area, map markers + cards render.
58309cd · 2026-05-10 · loginLimiter: widen to 100/15min in non-prod (CI flakiness fix)
Production stays at 10/15min/IP. NODE_ENV !== 'production' gets 100,
because the chained npm run test:e2e runs several login-requiring
suites back-to-back and the 10-cap was tripping the limiter
mid-suite, masking real-vs-flaky test results.
5918434 · 2026-05-10 · yolo tick 5: expand /papers seed library 5→17 + helpful-vote e2e
Post-shipping polish for backlog #3. The 5 seed threads from migration
018 covered the obvious benchmarks; this adds 12 more from the
working-installer canon — patterns that get DM'd about weekly:
de Gournay St Laurent, Fromental Non Such, Phillip Jeffries Shoji,
Elitis Perles, Schumacher Iconic Leopard, Thibaut Tulia, Cole & Son
Cow Parsley, Zoffany Elswick, Zuber Eldorado, Arte Katagami, Hermès
Faubourg, Flavor Paper Pop Art
Distribution across categories now: specialty 5, chinoiserie 4, mural 2,
floral 2, grasscloth 2, damask 1, metallic 1. Filter pills on /papers
will all populate.
All on conflict (slug) do nothing — re-runnable. Reversible by slug.
E2E: tests/e2e-paper-helpful-vote.js — 7/0 standalone validates the
vote endpoint: button renders, click bumps helpful_count 0→1 +
inserts paper_comment_votes row, repeat vote from same IP-hash is
idempotent (count stays 1, vote table still 1 row), button label
reflects new count, mismatched slug rejected with 403/404.
Suite environment note: chained npm run test:e2e shows cascade
failures (paper-comments 5/5, coi-request 4/4) due to login rate-
limit exhaustion across the suite — login limiter is 10/15min/IP and
each test that logs in chews 1-2. Solo runs of each test are clean:
- e2e-coi-request: 14/0
- e2e-paper-comments: 10/0
- e2e-paper-helpful-vote: 7/0
For CI we should either widen the login limiter for localhost or
stagger the test runs. Out of scope for this tick.
aa09d6e · 2026-05-10 · yolo tick 4: paper-thread contribution count + helpful votes — backlog #3 SHIPPED
Final polish for UX backlog #3. Two pieces:
1. Helpful votes on individual comments (migration 019):
- New table paper_comment_votes (comment_id, ip_hash, created_at)
with PK (comment_id, ip_hash) — one vote per IP-hash per comment.
- POST /papers/:slug/comments/:id/helpful — no auth (designer
participation is the value). Rate-limited via geoLimiter
(60/min/IP). IP hashed sha256-prefix-16 same as installer_interest.
- ON CONFLICT DO NOTHING + RETURNING — only first vote bumps the
denormalized helpful_count. Repeat votes silently no-op.
- Comments sorted by helpful_count DESC then created_at ASC, so
useful notes rise.
- Smoke tested: 1st vote 302 + count 3→4, 2nd vote same IP-hash
302 + count still 4, paper_comment_votes has exactly 1 row.
2. Installer-profile selection signal:
- routes/public.js /installer/:slug now computes paperContributions
{count, helpful, recent[3]} via two cheap queries on the indexed
paper_comments table.
- views/partials/paper-contributions.ejs renders nothing when
count=0, otherwise: "N contributions · X found helpful" + list
of 3 recent threads + link to /papers.
- Included by all 7 templates: installer.ejs (in sidebar) + the 6
installer-tpl-*.ejs variants (before coi-request include).
- Designers see at a glance which studios contribute craft
knowledge — selection signal no generic directory has.
UX_CREATIVE_BACKLOG.md: item #3 marked SHIPPED.
Entire 7-item UX backlog now ships:
#1 In-the-seams portfolio ✓
#2 Brand-trained badges ✓
#3 Paper threads ✓ (this tick)
#4 Equipment fleet card ✓
#5 Live COI request ✓
#6 Structured booking ✓
#7 Acceptance-rate badge ✓
Loop stops here per the tick-4 prompt condition.
4a79e6f · 2026-05-10 · 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.
4492052 · 2026-05-10 · yolo tick 2: public /papers list + /papers/:slug detail (read-only)
Continues UX backlog #3. Tick 1 landed the schema; this tick exposes
the 5 seed threads via public read-only routes.
- GET /papers — list page sorted by comment_count DESC, brand/paper_name
fallback. Category filter pills (chinoiserie/grasscloth/specialty/
metallic). Renders 2-line description preview + paste/category badges
+ note count per row.
- GET /papers/:slug — thread detail. Header with brand + paper_name +
category/paste/SKU/comment-count meta row, blockquote-style description,
comments section ordered by helpful_count DESC then created_at ASC.
Each comment attributes to its installer with link to profile + city
+ verified badge — gives designers a selection signal (which studios
contribute thoughtful craft knowledge).
- 404 on missing slug.
- /papers added to primary nav (between Map and Watch).
- /papers + /papers/:slug added to sitemap.xml with weekly/0.6.
- Read-only: no comment submission yet. Detail page CTA tells logged-in
installers "comment submission ships next tick"; non-logged-in users
get an /login CTA.
Next tick: POST /papers/:slug/comments + admin moderation queue.
4dca72e · 2026-05-10 · yolo tick 1: paper_threads + paper_comments schema + 5 seed threads
Migration 018 lands the schema for UX backlog #3 ("This Paper" peer-
installer commentary). Schema only — no public routes yet, so the
tables are inert and the feature isn't visible to users.
paper_threads (id, slug, brand, paper_name, sku, paste_type, category,
description, seeded_by, comment_count, timestamps). 5 seed rows for
the most-commented papers in luxury wallcovering: de Gournay Earlham,
Fromental Bois, Phillip Jeffries Manila Hemp, Maya Romanoff Ajiro,
Schumacher Vassily.
paper_comments (thread_id, installer_id, body, helpful_count, flagged,
timestamps) — public-readable, installer-only writable via existing
requireInstaller middleware (wired in next tick).
Trigger paper_threads_recount() keeps comment_count denormalized so
sort-by-activity is cheap.
Next tick: public /papers list + /papers/:slug detail (read-only).
66c6c0d · 2026-05-10 · Live COI request flow (UX backlog #5)
Designer-driven Certificate of Insurance request, end-to-end. Replaces
the 3-day phone-tag in luxury commercial/hospitality jobs where the
designer's primary insurance requires the installer to be named as
additional-insured on a fresh COI per project.
Schema (migration 017):
- installers.insurance JSONB — carrier, policy_number, limits.{general_aggregate_usd,per_occurrence_usd}, expiry, broker_{name,email,phone}, auto_attach_to_email
- coi_requests table — installer_id, designer_{name,company,email,phone}, project_{name,address,start_date,value_usd}, additional_insured_{name,address}, notes, status (pending/acknowledged/fulfilled/declined/expired), installer_notified_at, broker_notified_at, fulfilled_{at,pdf_url}, decline_reason, source_{ip,user_agent}
Public flow:
- /installer/:slug — shared partial views/partials/coi-request.ejs renders
the "Request COI →" CTA + reveal-on-click structured form. Gated on
insurance_on_file && claimed. Included by all 6 template variants
(editorial/trade-pro/concierge/studio/heritage/bilingue) and the
legacy installer.ejs.
- POST /installer/:slug/coi-request — validates designer/insured fields,
writes coi_requests row, fans out 3 emails (installer + broker if set
+ designer confirmation) via existing lib/email Purelymail transport.
Rate-limited via claimLimiter (5/hr/IP).
- views/public/coi-request-result.ejs — confirmation page.
Admin flow:
- /admin/insurance (GET/POST) — installer enters structured insurance
metadata + broker contact. Insurance link added to admin nav.
- /admin/coi-requests (GET) — request inbox with status badges, project
details, additional-insured name, broker-notified state.
- /admin/coi-requests/:id/status (POST) — installer can mark requests
acknowledged/fulfilled/declined. fulfilled_pdf_url validated as http(s).
E2E: tests/e2e-coi-request.js — 14/0/0 standalone, full flow:
1. Public profile renders COI section
2. Reveal form + submit
3. DB row created with correct fields
4. Login as installer, request appears in admin inbox
5. Mark fulfilled, DB status flips + fulfilled_at set
6. Cleanup deletes test row
c8e0e1a · 2026-05-10 · e2e-stripe-flow: real installer-session fixture + Connect URL hardening
Phases 1-3 now drive the actual Stripe Connect + checkout flow with a
playwright-logged-in installer (rivera-installs-miami / demo+rivera).
Form submits go through the page (csrf + cookies ride along) but don't
follow Stripe's cross-origin redirects — instead they assert on the DB
side-effect (stripe_account_id populated, tier flipped, etc.) so the
test never has to load connect.stripe.com or checkout.stripe.com.
Safety gates:
- STRIPE_KEY_LIVE detection: when the test env has sk_live_ keys, phases
2 + 3 SKIP cleanly with a clear message — they would otherwise create
REAL Connect accounts + Checkout sessions in Steve's live Stripe. Opt
in with STRIPE_E2E_FULL=1 only against a sk_test_ server.
- Webhook probe: phase 5 sends a no-op probe event first; if the server
rejects with 400 (sig enforced, no STRIPE_DEV_ACCEPT_UNSIGNED on the
server side), phases 5+6 SKIP cleanly instead of failing.
- Output format aligned with run-e2e.js parser ([result] N pass · N fail).
Real bug fixed in lib/stripe.js — Connect account creation passed
installer.website straight through as business_profile.url, which
Stripe rejects with url_invalid for placeholder URLs (example.com,
localhost, .test). New validBusinessUrl() filter parses + whitelists
real public hosts only. Caught by trying to onboard rivera (seeded
with https://example.com/rivera) → 500 from Stripe.
Local suite now: 109 pass · 0 fail · 3 skipped (claim/template/stripe
skip when login limiter is exhausted from chained runs — pre-existing
soft-skip behavior).
6f348e2 · 2026-05-10 · nph-stripe-plumbing: scaffold e2e-stripe-flow.js + synthesis doc
Plannator Run A audit revealed the Stripe Connect + booking-deposit
plumbing is already shipped end-to-end on main (Connect onboarding,
checkout, 8-event webhook with idempotency, /admin/billing payouts
dashboard with platform rollup). Real gap was a single missing e2e
test, now scaffolded with 6 phases gated on STRIPE_SECRET_KEY or
STRIPE_DEV_ACCEPT_UNSIGNED=1.
PLANNATOR-SYNTHESIS.md documents the assumed-vs-actual delta and the
remaining human-only steps (Google + LinkedIn console clicks, PG-pw
rotation when Steve is at the keyboard).
51036bf · 2026-05-07 · rel=alternate JSON + HTTP Link header on /find — applies VCL pattern
New /api/find route mirrors /find filter logic (q, segment, material, zip,
state, show) and returns JSON wrapped in {query, pagination, results}.
HTML head adds <link rel="alternate" type="application/json"> when
alternateJsonUrl is in the render context. /find route now sets HTTP
Link header (RFC 8288) with rel=canonical + rel=alternate.
Smoke-tested locally on :9765 — 522 installers no-filter, 20 NY,
0 CA+residential (correctly applied; market_segments uses
'luxury_residential' not 'residential' — feature, not bug).
Same pattern shipped on Ventura Claw Leads earlier today (commit fbd4be4).
Now applied to NationalPaperHangers. Future targets: lawyer-directory,
lacountyeats, professional-directory, animals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
e6efc97 · 2026-05-07 · Fix legal-page ZIP in CAN-SPAM footer: 91335→91403
Steve confirmed 2026-05-07. Address now correct in both occurrences
on /legal — privacy and terms blocks both reference the same line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
c75cd32 · 2026-05-06 · /find shows ALL installers by default (was 5 claimed-only) · LIMIT 600
Steve override 2026-05-07: '/find' was hiding 519 of 524 installers behind
?show=all because the 2026-05-06 UX critic flipped default to 'claimed'.
Steve wants the directory's full breadth visible by default — flipping
back: default is now 'all', ?show=claimed opts down to 5 verified.
Toggle href reverses direction accordingly. LIMIT bumped 200 → 600 so
the full 524-installer list returns in one page.
Live verified: https://nationalpaperhangers.com/find renders 524 cards.
d15181a · 2026-05-06 · Remove 'Schedule install' booking option — initial visit only
NPH books the initial consultation; install scheduling, pricing, and contract
happen directly between consumer and installer afterward. Keeps NPH on the
lead-coordination side of CSLB $7026 — booking install work through NPH
would expose us to general-contractor classification.
- Drop visit-picker fieldset (consultation+install radio cards)
- Replace with brass-bordered note: 'You're scheduling an initial visit
with {studio}. Install scheduling, contracts, and pricing for the work
itself happen directly between you and the studio.'
- Drop visit-picker JS (no more project_type-swap on click)
- Drop dead .visit-picker / .vp-* CSS
The hidden project_type input still ships 'consultation' to /api/installers/:slug/book.
Existing API still validates the install enum value for legacy bookings already
in the DB; nothing new will hit that path.
Prod-verified: 0 picker strings, replacement note present.
31cdca8 · 2026-05-06 · Reposition NPH as referral/lead-coordination service (CSLB §7026 hygiene)
Steve's call after the 25% fee bump — cleaning up CA contractor-licensing
exposure surface area. Five micro-edits across 8 files:
1. Strip 'Concierge matching' and 'project oversight' everywhere they
appeared (home.ejs hero+JSONLD, head.ejs default meta, footer.ejs tag,
routes/public.js home metaDescription). Replaced with 'directory',
'lead routing', and 'search and schedule consultations directly with
installation studios' phrasing.
2. footer-fineprint now carries the substantive disclaimer:
'NPH is a referral and lead-coordination service. The contract for any
wallcovering installation is between the consumer and the installer
directly. NPH does not warrant, supervise, or guarantee installation
work.' Plus CSLB verification link.
3. /terms (legal.ejs terms branch): replaced placeholder with 9-section
substantive ToS positioning NPH as referral service. Sections explicit
about what NPH does NOT do (perform install work, set price, supervise,
warrant, employ). CSLB verification call-out for CA $500+ contracts.
Liability cap at 12mo of fees paid.
4. /privacy (legal.ejs privacy branch): replaced placeholder with full
policy. CCPA/GDPR rights, retention windows (active+90d, bookings 7yr,
suppression indefinite, logs 30d), Stripe/Purelymail/GA processor list.
5. installer.ejs reserve-banner + book.ejs deposit fieldset: added
'installation contract is between you and {studio} directly' + CSLB
link near every Book CTA.
Audit on prod (2026-05-06): 0 bad strings (Concierge matching / project
oversight / 10% marketplace fee) across 9 key surfaces; 1+ defensive
strings (referral disclaimer / CSLB link) on every public page.
This is hygiene, not legal advice — Steve still needs a CA construction-law
attorney consult before the marketplace scales materially.
bec9ac1 · 2026-05-06 · Bump platform fee 10%→25% + reframe as 'lead-coordination fee' in copy
Steve's call 2026-05-06. NPH_PLATFORM_FEE_BPS default 1000→2500; enterprise
discount tier 700→1500. The label change from 'platform fee' / 'marketplace
fee' to 'lead-coordination fee' is intentional positioning — a 25% take rate
described as a marketplace cut invites contractor-licensing scrutiny in CA
($7026), whereas a referral/lead-coordination fee is the standard home-services
lead-gen model (HomeAdvisor, Angi, Yelp).
Touched: lib/stripe.js DEFAULT_PLATFORM_FEE_BPS, views/admin/dashboard.ejs
(2 callouts), views/admin/billing.ejs, views/public/installer.ejs reserve banner.
NPH_PLATFORM_FEE_BPS=2500 set on Kamatera prod .env. 26/26 tests pass.
f21468c · 2026-05-06 · v0.7 #31: auto-email worker for installer_interest queue
scripts/notify-interest-queue.js — scans installer_interest WHERE notified_at
IS NULL and the installer is now in 'ready' state (claim_status in self/claimed
AND tier in pro/signature/enterprise AND ≥1 active installer_availability),
sends a one-shot 'they're live' email via Purelymail SMTP, marks notified_at
+ notify_msg_id on the queue row, audits to comms_send_audit.
Compliance:
- Pre-flight assertSendCompliance() — refuses to enter the loop if MAILING_ADDRESS
/ SESSION_SECRET / PUBLIC_URL / suppression tables are misconfigured.
- Per-recipient isSuppressed() scrub. Suppressed rows still get notified_at
marked (so they don't sit in the queue forever) plus a 'skipped' audit row.
- 1-click unsubscribe token + List-Unsubscribe headers (RFC 2369/8058).
- complianceFooter with MAILING_ADDRESS in every email.
Cron: */15 * * * * on Kamatera root crontab → /var/log/nph-notify-interest.log.
Live test on prod 2026-05-06:
- Seed row → run → sent (msgId @nationalpaperhangers.com) → audit row 'sent' → queue.notified_at populated.
- Second run → 0 candidates (idempotent).
Flags: --dry-run (no DB write, no SMTP, just log), --limit N (cap iteration).
f339c34 · 2026-05-06 · 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.
196af63 · 2026-05-06 · v0.5: NPH email through Purelymail SMTP + tighter /book calendar gate
lib/email.js — direct nodemailer→smtp.purelymail.com:587 with
INFO_NATIONALPAPERHANGERS_COM_PASSWORD. Was sending through George account=info
which actually maps to a Gmail label on Steve's DW Workspace, so From was
steve@designerwallcoverings.com — wrong domain for SPF/DKIM/DMARC alignment.
Verified live on Kamatera 2026-05-06: messageId
<...@nationalpaperhangers.com> (Purelymail SMTP, not Gmail).
routes/public.js + book.ejs — /book calendar now requires THREE conditions,
not just paid tier:
1. claim_status in (self, claimed) — real installer-user behind the listing
2. tier in (pro, signature, enterprise) — paid plan
3. ≥1 active installer_availability window
Any missing → fall through to contact-direct callout with reason-specific
copy (calendarBlockedReason: unclaimed | free_tier | no_schedule). The
unclaimed branch points the visitor to /installer/:slug#claim with prompt
to claim their listing.
8dd327f · 2026-05-06 · v0.5: bootstrap Stripe products+prices+webhook for NPH
- scripts/stripe-bootstrap-v05.js: idempotent script to provision 3 NPH products + 5 prices (Pro $39mo/$399yr, Signature $149mo/$1500yr, Enterprise $399mo) and a webhook endpoint at https://nationalpaperhangers.com/webhooks/stripe with 10 events. Reuses by metadata.nph_tier on subsequent runs. NPH_PUBLIC_URL env override prevents PUBLIC_URL=localhost (dev value) from being sent to Stripe.
- routes/webhooks.js: HEAD/GET handlers return 200 (Stripe URL probe needs reachable, not just POST). Unconfigured POST returns 400 (was 503 — Stripe rejected URLs returning 5xx as 'not publicly accessible'). New STRIPE_BOOTSTRAP_ACCEPT_PROBES=1 toggle for dev-only fail-open during initial endpoint creation.
- lib/stripe.js: constructWebhookEvent now reads STRIPE_NPH_WEBHOOK_SECRET first, falls back to STRIPE_WEBHOOK_SECRET. Each project has its own webhook secret; cross-validation across the lawyer/site-factory/NPH endpoints would be unsafe.
Saved via /secrets: STRIPE_NPH_WEBHOOK_SECRET + STRIPE_PRICE_PRO_MONTH/PRO_YEAR/SIGNATURE_MONTH/SIGNATURE_YEAR/ENTERPRISE_MONTH. Routed to NPH only (Mac2 + Kamatera). Webhook endpoint we_1TUDHV63uNmiRsMbRJea7EqU live with 10 events. isLive() + priceIdFor() + webhook secret loaded all confirmed on prod.
de5a034 · 2026-05-06 · v0.4 #5 follow-up: hide brand-name + tighten header actions at <769px
QA pass on https://nationalpaperhangers.com showed 2px overflow at 380 viewport
(.header-actions = 382px) and 12px overflow on /watch (auto-fill 360px minmax
forced single columns wider than viewport). Fixes:
- header-inner: padding 14/16 (was 32 desktop), wrap actions
- header-actions .btn: 6/10 padding, 12px font (down from 14)
- brand-name: display none — keep N·P·H mark only on phone
- watch-grid: force 1-col w/ !important to override the 360px auto-fill minmax
- watch-page padding 24/16 (was 48/32)
Playwright headless QA at 380×720 against prod: zero overflow on /find /watch
/installer/:slug /map (leaflet tile-pane is overflow:hidden so its tiles
aren't real horizontal overflow).
9bcbde8 · 2026-05-06 · v0.4 #5: load theme-toggle.js + /find↔/map filter parity + tablet/phone breakpoints
- public/css/public.css: 1024/769 tablet (3-col filter, 2-col how-grid) + 768 phone breakpoints (full single-column collapse, smaller padding, .display-sm shrink, map-controls grid)
- views/partials/head.ejs: <script src=/js/theme-toggle.js defer> — the file existed but was never loaded (regression)
- views/public/find.ejs: 'View on map' button in result-bar
- views/public/map.ejs: 'View as list' button + read q/segment/material from URL on load + sync list-link href on filter change
- routes/public.js: build mapHref preserving q/segment/material and pass to find.ejs
26/26 tests pass.