NationalPaperHangers

repo: ~/Projects/NationalPaperHangers · 93 commits · 0 in last 24h, 0 in last 7d ·

Search the build

93 commits indexed

  1. 365532c 2026-07-03 chore: macstudio3 migration — reconcile from mac2 + repoint paths (stevestudio2→macstudio3, node/npm/npx→/opt/homebrew)
  2. 5ea9d11 2026-06-18 Add measure_jobs schema (migration 023) — Council Idea #5 lead-handoff capture table
  3. 8c10bac 2026-06-02 Add P3 order-handoff to /measure: PDP deep-link prefill + host-allowlisted shop CTA
  4. 313ab55 2026-06-01 Add standalone AR Wall Measurement Kit at /measure (P0+P1)
  5. b18f55a 2026-05-31 Add per-site favicon (kills /favicon.ico 404)
  6. 55b6c34 2026-05-31 fix: add rel=noopener noreferrer to target=_blank links in admin views
  7. ec8d505 2026-05-19 Hold back ai_agent call mode at launch (DTD verdict 2026-05-19)
  8. 9f104a2 2026-05-19 Add "Call this installer" Butlr integration to installer pages
  9. d08c846 2026-05-19 feat: add WebXR AR + gyro-clinometer wall measurement to /book room-capture tool
  10. 098e20c 2026-05-19 JSON-serialise booking-page analytics values so a stray quote or newline cannot break the script
  11. 258f66a 2026-05-19 Constrain profile hero_url to the installer's own uploads, not an arbitrary URL
  12. 50ae349 2026-05-19 Validate the plan tier on billing checkout so installers cannot self-upgrade for free
  13. 1e77ada 2026-05-19 Actually limit verified-brand chips to 3 — LIMIT next to array_agg was a no-op
  14. 124a38f 2026-05-19 Coerce a garbage COI project-value to null instead of 500ing the insert
  15. 50d107f 2026-05-19 Reject malformed emails at signup and claim instead of just checking for an @
  16. a3b0896 2026-05-19 Validate admin portfolio uploads by magic bytes, not the spoofable mimetype
  17. aaf1edb 2026-05-19 Reject bookings that fall outside the installer's published hours
  18. 47d0f2e 2026-05-18 Guard booking lifecycle transitions against reviving terminal bookings
  19. 362b0cc 2026-05-18 Carry the IDOR-gate token on the customer's booking-confirmation link
  20. da5acf0 2026-05-18 Validate availability time windows instead of silently breaking the calendar
  21. 3fa1e62 2026-05-18 Coerce garbage numeric booking fields to null instead of 500ing
  22. 515caa4 2026-05-18 Stop clobbering the customer's hand-entered square footage
  23. 3e66967 2026-05-18 Validate booking-media uploads by magic bytes, not the spoofable mimetype
  24. d69c431 2026-05-18 Render true half-drop in the on-wall wallpaper preview
  25. 51b33df 2026-05-18 Harden the room-capture flow for mobile + touch
  26. 5c5c9df 2026-05-18 e2e-room-capture: 14-assertion test for the camera quote flow
  27. a1dfc05 2026-05-18 Surface room captures in the installer brief — admin page + email
  28. 6557a78 2026-05-18 Persist room_captures on booking POST + derive sq ft from measured walls
  29. d2f12ee 2026-05-18 Add camera capture & measure step to the /book wizard
  30. 9df4b87 2026-05-18 Add POST /api/booking-media — public upload for the room-capture flow
  31. 81398eb 2026-05-18 Migration 022: room_captures JSONB on bookings
  32. 841f9be 2026-05-18 Surface the structured booking brief in the installer notification email
  33. 2e14b8b 2026-05-18 Fix /api/near-me: accept county/town/zip filters without coordinates
  34. 283685a 2026-05-18 Clear all 6 npm audit vulns — audit fix + bcrypt 5.x→6.0.0
  35. 71aa66d 2026-05-18 Split geoLimiter — map JSON and helpful-votes get independent buckets
  36. 868356a 2026-05-18 Split shared claimLimiter into per-route buckets (claim/notify/COI/comment)
  37. f4d5c8f 2026-05-18 smoke.test.js: update /map assertion for address-driven near-me rewrite
  38. 4d68e2a 2026-05-18 tests/app.js: mount csrfMiddleware to mirror server.js
  39. 0cf8dae 2026-05-18 claimLimiter: widen to 100/hr in non-prod so back-to-back e2e suites don't 429
  40. d8b8e58 2026-05-13 snapshot: 2 file(s) changed, +1 new, ~1 modified
  41. 6346a38 2026-05-12 wia-bb: log session-minutes to cost-tracker via shared helper
  42. b3b4e14 2026-05-10 /map → address-driven near-me view + service_counties/towns/zips
  43. 58309cd 2026-05-10 loginLimiter: widen to 100/15min in non-prod (CI flakiness fix)
  44. 5918434 2026-05-10 yolo tick 5: expand /papers seed library 5→17 + helpful-vote e2e
  45. aa09d6e 2026-05-10 yolo tick 4: paper-thread contribution count + helpful votes — backlog #3 SHIPPED
  46. 4a79e6f 2026-05-10 yolo tick 3: paper-thread comment submission + ops moderation
  47. 4492052 2026-05-10 yolo tick 2: public /papers list + /papers/:slug detail (read-only)
  48. 4dca72e 2026-05-10 yolo tick 1: paper_threads + paper_comments schema + 5 seed threads
  49. b681b43 2026-05-10 UX_CREATIVE_BACKLOG: mark #5 (Live COI) shipped v0
  50. 66c6c0d 2026-05-10 Live COI request flow (UX backlog #5)
  51. c8e0e1a 2026-05-10 e2e-stripe-flow: real installer-session fixture + Connect URL hardening
  52. 6f348e2 2026-05-10 nph-stripe-plumbing: scaffold e2e-stripe-flow.js + synthesis doc
  53. 51036bf 2026-05-07 rel=alternate JSON + HTTP Link header on /find — applies VCL pattern
  54. e6efc97 2026-05-07 Fix legal-page ZIP in CAN-SPAM footer: 91335→91403
  55. ab11e6e 2026-05-07 tighten .gitignore: add missing standing-rule patterns (build/ .next/)
  56. 8efbbdb 2026-05-07 tests: e2e-business-invariants — sitemap.xml + robots.txt regression checks (528 URLs, ref to sitemap, /admin Disallow). 41 prod assertions
  57. 442cc6c 2026-05-07 og: add og:image + twitter:image to every page (default brand jpg, per-installer override = hero/portfolio/segment image). Adds 8 e2e assertions to business-invariants. Live preview cards no longer render text-only
  58. d145b79 2026-05-06 tests: e2e-business-invariants — 27 prod-runnable assertions for content rules that have regressed today (no /book CTAs on unclaimed × 6 templates, /find sort+density, /watch renders, referral disclaimer on /privacy /terms). Auto-detects local vs prod slug
  59. 2ff46f6 2026-05-06 ops: launchd-driven e2e prod-monitor (every 6h @ :17), local-only tests skip cleanly when BASE is remote, sort test uses localeCompare to match page impl. Emails Steve via George on failure
  60. a0527b7 2026-05-06 tests: graceful rate-limit handling — public-pages soft-passes 429 on /login + /signup, template-chooser exits 78 (skip) when login limiter is exhausted from prior suite runs
  61. cead641 2026-05-06 tests: e2e-public-pages — 12 routes × 4 assertions = 48; status 200 + content-marker + no-error-template + no-console-errors. Runs clean against prod (no auth/DB writes)
  62. c75cd32 2026-05-06 /find shows ALL installers by default (was 5 claimed-only) · LIMIT 600
  63. 698afa0 2026-05-06 tests: e2e suite runner — npm run test:e2e runs all e2e-*.js sequentially, exit-78 = skip; claim-flow detects 429 from prior runs and skips cleanly. 36/0/1
  64. eb62adf 2026-05-06 tests: e2e claim flow — landing form → email-domain check → token mint → single-use verify → password set → /admin landing. 10/10 pass with auto-cleanup
  65. 0420926 2026-05-06 tests: e2e for /find sort + density controls — 7/7, locks in CLAUDE.md standing rule
  66. 72e3f64 2026-05-06 find: add sort + density controls per CLAUDE.md standing rule (newest claimed/A-Z/tier/verified/creds + 240-420px card-min slider, both localStorage-persisted)
  67. 7c77afb 2026-05-06 tests: e2e buyer wizard — 5 steps, conditional brand reveal, back-nav persistence, pill state. 16/16 pass
  68. efd7990 2026-05-06 fix(admin/template): drag-drop upload was 403'ing on CSRF — multer parses multipart AFTER csrf mw runs, so _csrf in FormData was invisible. Switch XHR to X-CSRF-Token header. e2e: 13/13 pass
  69. 6546f0f 2026-05-06 fix: /admin/template flash double-clear (route was reading null after global middleware drained it) — caught by new e2e click-test, all 7 assertions pass
  70. d15181a 2026-05-06 Remove 'Schedule install' booking option — initial visit only
  71. 31cdca8 2026-05-06 Reposition NPH as referral/lead-coordination service (CSLB §7026 hygiene)
  72. bec9ac1 2026-05-06 Bump platform fee 10%→25% + reframe as 'lead-coordination fee' in copy
  73. f21468c 2026-05-06 v0.7 #31: auto-email worker for installer_interest queue
  74. f339c34 2026-05-06 v0.6: notify-when-live capture form on unclaimed /book
  75. 196af63 2026-05-06 v0.5: NPH email through Purelymail SMTP + tighter /book calendar gate
  76. 8dd327f 2026-05-06 v0.5: bootstrap Stripe products+prices+webhook for NPH
  77. de5a034 2026-05-06 v0.4 #5 follow-up: hide brand-name + tighten header actions at <769px
  78. 9bcbde8 2026-05-06 v0.4 #5: load theme-toggle.js + /find↔/map filter parity + tablet/phone breakpoints
  79. 939d55b 2026-05-06 watch: linkedin embed support + industry company-page strip (verified manufacturer + WIA pages)
  80. fdd57f9 2026-05-06 templates: hide all /book schedule CTAs for unclaimed (directory-listing) studios — claimed-only after sign-up; show studio info only
  81. dd2fcde 2026-05-06 claude-codex r3 cleanup: iframe width/height (CLS fix, MEDIUM#9) + JSONB CHECK on featured_video_urls (MEDIUM#7) — both applied to prod
  82. 8c19628 2026-05-06 admin/uploads: per-installer rate limit (30/hr) keyed on installer.id — protects upload dir from runaway/hostile authenticated client
  83. a7e5ecf 2026-05-06 watch page: ops-curated YT videos about pro installations + social_verified gate on per-installer handles (no fake seeds)
  84. 350a7bb 2026-05-06 claude-codex r3 (cont): guard installer in social-videos partial, TikTok iframe v1 path, cap embedAll at 12 — adversarial probe shows evilyoutube.com now rejected
  85. 4ecde9c 2026-05-06 claude-codex r3: fix null-element crash in 4 templates, session-save race in OAuth callbacks, hostname-spoof in social-embed, missing Vimeo CSP
  86. a9f49d0 2026-05-06 watch the work: YT + IG reels + TikTok embeds via partial across all 7 templates
  87. f240b01 2026-05-06 auth: regenerate session on OAuth sign-in (fixation defense — match claim.js)
  88. f65e1d4 2026-05-06 fix(public/installer): coerce market_segments/materials elements to string before .replace — null array elements were crashing /installer/:slug rendering and triggering pm2 restart loop (41 restarts before fix)
  89. 0b791b8 2026-05-06 find: default to verified-only, add 'Show directory listings' toggle (UX critic P1)
  90. 324d2ba 2026-05-06 post-launch QA fixes — header Dashboard label bug, /book gate for unclaimed, deposit copy, GA4 CSP, oakwood active, kill broken portfolio placeholders
  91. 07b632e 2026-05-06 post-deploy: deploy scripts v2/v3, LinkedIn auth route, 6 templates live in prod
  92. 4606c77 2026-05-06 post-launch v0.2 design fixes — sticky reserve banner, drop response-sla badge clutter from /find cards, title-case segment dropdown options
  93. dfd87ae 2026-05-06 snapshot before claude-codex debate

Authors

Agents used

  • none detected

Skills used

  • /papers15
  • /book14
  • /find13
  • /installer10
  • /map10
  • /measure5
  • /bookings5
  • /stripe5
  • /video4
  • /zip4
  • /watch4
  • /call-installer3
  • /booking-media3
  • /uploads3
  • /town3
  • /lng3
  • /installers3
  • /comments3
  • /partials3
  • /coi-request3
  • /template3
  • /segment3
  • /nationalpaperhangers3
  • /png2
  • /near-me2
  • /min2
  • /notify2
  • /towns2
  • /notify-when-live2
  • /ops2

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.

File tree

284 files tracked. Click any to browse the source at HEAD.

Other build journals

← NEW SKU Viewer  ·  all 4 projects  ·  Norma →

Export

commits.csv · feed.atom · project.json · commits.json

rendered in 1ms