{"slug":"NationalPaperHangers","total":93,"limit":100,"offset":0,"since":null,"commits":[{"hash":"365532c","date":"2026-07-03 08:08:07 -0700","author":"Steve","subject":"chore: macstudio3 migration — reconcile from mac2 + repoint paths (stevestudio2→macstudio3, node/npm/npx→/opt/homebrew)","body":""},{"hash":"5ea9d11","date":"2026-06-18 18:45:07 -0700","author":"SteveStudio2","subject":"Add measure_jobs schema (migration 023) — Council Idea #5 lead-handoff capture table","body":""},{"hash":"8c10bac","date":"2026-06-02 08:59:17 -0700","author":"SteveStudio2","subject":"Add P3 order-handoff to /measure: PDP deep-link prefill + host-allowlisted shop CTA","body":"- /measure reads sanitized ?sku/name/from/repeat/format/panelw/shop from the\n  launch URL, shows a 'Sizing for <product>' banner, prefills the calculator,\n  and points 'Shop the look' back at the exact PDP.\n- shop return URL host-allowlisted (DW/wallco/philipperomano/novasuede +subs)\n  to prevent open redirect; product strings EJS-escaped, prefill JSON escapes <.\n- 7 route tests (tests/measure-route.test.js), wired into npm test. 50/50 pass.\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"},{"hash":"313ab55","date":"2026-06-01 12:41:06 -0700","author":"SteveStudio2","subject":"Add standalone AR Wall Measurement Kit at /measure (P0+P1)","body":"New designer/DIY tool that reuses the /book RoomMeasure engine (WebXR AR +\ngyro clinometer) on a bare page and adds a rolls/panels calculator.\n\n- public/js/measure-kit.js — pure UMD calc brain (US/Euro rolls + mural panels,\n  per-wall door/window deductions, repeat-aware waste). 14 unit tests, all pass.\n- views/public/measure.ejs — device-aware hero CTA, multi-wall measure + manual\n  entry, live result card with plain-English buy line + dual handoff (shop DW /\n  find an installer). Everything client-side; no server storage.\n- routes/public.js — GET /measure + sitemap entry. header.ejs — nav link.\n- DATA_POLICY.md §11 — on-device-only measurement, no AR frames leave phone.\n- package.json — measure-kit tests wired into npm test (now 43/43).\n\n/book installer flow untouched. Local only — deploy is Steve-gated."},{"hash":"b18f55a","date":"2026-05-31 16:32:35 -0700","author":"SteveStudio2","subject":"Add per-site favicon (kills /favicon.ico 404)","body":""},{"hash":"55b6c34","date":"2026-05-31 00:00:34 -0700","author":"SteveStudio2","subject":"fix: add rel=noopener noreferrer to target=_blank links in admin views","body":"Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"},{"hash":"ec8d505","date":"2026-05-19 13:49:04 -0700","author":"SteveStudio2","subject":"Hold back ai_agent call mode at launch (DTD verdict 2026-05-19)","body":"The DTD panel (2/1) ruled ai_agent should not ship in the initial roster:\nan AI autonomously representing a customer to a business is a different\nrisk class than hold/bridge and needs a dedicated compliance + UX review\nbefore it is customer-visible. The ai_agent radio is gated behind an\nif(false) block; the route and Butlr API still accept the mode, so\nre-enabling is a one-token change once the review is done.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"9f104a2","date":"2026-05-19 13:34:56 -0700","author":"SteveStudio2","subject":"Add \"Call this installer\" Butlr integration to installer pages","body":"A 3-mode calling concierge on every installer profile (all 6 template\nvariants + legacy layout). The customer taps \"Call this installer\",\npicks a mode in a modal, and NPH hands the call to Butlr's external API.\n\nModes:\n  hold      — Butlr waits through IVR/hold, calls the customer back\n  bridge    — click-to-call, joins customer + installer on one live call\n  ai_agent  — AI agent delivers the customer's project brief, reports back\n\nlib/butlr.js          — thin client for Butlr POST /api/external/place-call\nroutes/call-installer.js — POST /installer/:slug/call-installer, validates,\n                        normalizes phones, maps Butlr's 429 cap to a clear msg\npartials/call-installer.ejs — button + modal, brief field shown only for\n                        ai_agent, inline AJAX result\nserver.js             — mounts route, 6 calls/hr/IP rate limit\npublic.js             — passes butlrEnabled (butlr.isConfigured()) to render\n\nButton hidden site-wide when BUTLR_EXTERNAL_SECRET is unset or the studio\nhas no phone on file. CSRF-protected via the global middleware.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"d08c846","date":"2026-05-19 12:15:29 -0700","author":"SteveStudio2","subject":"feat: add WebXR AR + gyro-clinometer wall measurement to /book room-capture tool","body":""},{"hash":"098e20c","date":"2026-05-19 08:03:55 -0700","author":"Steve","subject":"JSON-serialise booking-page analytics values so a stray quote or newline cannot break the script","body":""},{"hash":"258f66a","date":"2026-05-19 08:00:18 -0700","author":"Steve","subject":"Constrain profile hero_url to the installer's own uploads, not an arbitrary URL","body":""},{"hash":"50ae349","date":"2026-05-19 07:59:20 -0700","author":"Steve","subject":"Validate the plan tier on billing checkout so installers cannot self-upgrade for free","body":""},{"hash":"1e77ada","date":"2026-05-19 07:58:30 -0700","author":"Steve","subject":"Actually limit verified-brand chips to 3 — LIMIT next to array_agg was a no-op","body":""},{"hash":"124a38f","date":"2026-05-19 07:57:08 -0700","author":"Steve","subject":"Coerce a garbage COI project-value to null instead of 500ing the insert","body":""},{"hash":"50d107f","date":"2026-05-19 07:55:39 -0700","author":"Steve","subject":"Reject malformed emails at signup and claim instead of just checking for an @","body":""},{"hash":"a3b0896","date":"2026-05-19 07:53:49 -0700","author":"Steve","subject":"Validate admin portfolio uploads by magic bytes, not the spoofable mimetype","body":""},{"hash":"aaf1edb","date":"2026-05-19 07:52:51 -0700","author":"Steve","subject":"Reject bookings that fall outside the installer's published hours","body":""},{"hash":"47d0f2e","date":"2026-05-18 20:26:42 -0700","author":"SteveStudio2","subject":"Guard booking lifecycle transitions against reviving terminal bookings","body":"POST /admin/bookings/:id/{confirm,decline,complete} each ran an\nunconditional UPDATE that overwrote status regardless of the booking's\ncurrent state. A stale admin tab, a back-button re-POST, or a\ndouble-submit could therefore flip a completed, canceled, or declined\nbooking back to confirmed — silently, with no feedback either way since\nthese routes (unlike every other admin POST) skipped the session flash.\n\nEach UPDATE now guards on the valid prior states in its WHERE clause:\nconfirm only from 'pending'; decline and complete only from\n'pending'/'confirmed'. A 0-row result means the transition no longer\napplies and is flashed back to the installer instead of redirecting as\na silent success. Test inserts bookings in terminal states and asserts\nconfirm/decline cannot touch them while the pending->confirmed->completed\npath still works.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"362b0cc","date":"2026-05-18 20:15:48 -0700","author":"SteveStudio2","subject":"Carry the IDOR-gate token on the customer's booking-confirmation link","body":"/bookings/:uuid is access-gated three ways; for a customer (who is not\nthe owning installer) the only way in is the HMAC ?t= token — without it\nthe page 404s so booking UUIDs can't be enumerated. But the customer\nconfirmation email built the link as a bare plain-text URL,\n`${publicUrl}/bookings/${booking.uuid}`, with no ?t= param. Every\ncustomer who clicked through from their own confirmation email hit a\n404 and could not view, reschedule, or cancel their booking.\n\nThe booking object passed to bookingConfirmationCustomer already carries\nview_token (set in routes/api.js after signing). The link now appends\n?t=<view_token> and is a real <a> anchor, matching the installer\nnotification email's idiom. Unit test asserts the emitted HTML links to\n/bookings/:uuid?t=<token> and that the embedded token verifies against\nthe uuid.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"da5acf0","date":"2026-05-18 20:10:58 -0700","author":"SteveStudio2","subject":"Validate availability time windows instead of silently breaking the calendar","body":"POST /admin/availability checked only that day_of_week was 0-6 and that\nthe time fields were non-empty — unlike POST /admin/time-off right below\nit, which validates dates and ordering. So an installer could save an\navailability window with a malformed time (e.g. \"99:99\"), which 500s on\nthe ::time-cast INSERT, or an inverted one (start_time 17:00 >\nend_time 09:00), which saves cleanly with a 201 and shows in the\ncalendar UI but produces zero bookable slots: slots.js builds an invalid\nluxon Interval and silently drops the whole window, so the installer\ngets no bookings and no error explaining why.\n\nA clockMinutes() helper in lib/utils.js parses \"H:MM\" / \"HH:MM\" /\n\"HH:MM:SS\" to minute-of-day (or null when malformed); the route now\nrejects malformed input with 400 invalid_time and inverted windows with\n400 invalid_time_range. Unit-tested across valid forms, every malformed\nshape, and ordering.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"3fa1e62","date":"2026-05-18 17:38:33 -0700","author":"SteveStudio2","subject":"Coerce garbage numeric booking fields to null instead of 500ing","body":"A booking POSTed directly with a non-numeric square_feet,\nceiling_height_ft, or roll_count_estimate ran parseFloat/parseInt → NaN\nstraight into an INTEGER/NUMERIC column, so the INSERT failed with\n\"invalid input syntax for type integer: NaN\" and the customer got an\nopaque 500. A numOrNull() helper now parses each field and yields null\nwhen the value isn't a finite number; square_feet additionally falls\nback to the measured-walls total. Verified: a booking with all three\nfields set to junk now returns 201 with the columns stored as null.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"515caa4","date":"2026-05-18 17:34:01 -0700","author":"SteveStudio2","subject":"Stop clobbering the customer's hand-entered square footage","body":"room-capture.js promised \"customer can override\" the square_feet field\nbut serialize() overwrote it with the measured-walls total on every box\ndrag, keystroke, or wallpaper change — so the override only held when\nzero rooms were measured. A userEditedSqft flag now latches on the\nfield's first real 'input' event (a programmatic .value set never fires\none), and the auto-fill is skipped once it trips. e2e proves a typed\n999 survives a later room re-measure.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"3e66967","date":"2026-05-18 17:30:41 -0700","author":"SteveStudio2","subject":"Validate booking-media uploads by magic bytes, not the spoofable mimetype","body":"The unauthenticated /api/booking-media endpoint trusted the browser's\nContent-Type to pick the stored extension, so a 40 MB blob mislabelled\nimage/png cleared multer's fileFilter and landed in public/uploads/.\nNew lib/media-sniff.js derives the extension from the file's leading\nbytes (JPEG/PNG/WebP/WebM + ISO-BMFF brands for HEIC/AVIF/MP4/MOV);\ncontent that isn't a real image/video is now rejected. e2e adds an\nassertion proving a text blob with an image/png Content-Type fails.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"d69c431","date":"2026-05-18 17:11:44 -0700","author":"SteveStudio2","subject":"Render true half-drop in the on-wall wallpaper preview","body":"When the customer picks half-drop match, the preview now composites a\ncanvas 'super-tile' — two panels wide with the right column dropped by\nhalf the vertical repeat (plus a wrap copy so it tiles seamlessly) — and\ntiles that straight at double width. Falls back to straight tiling while\nthe image is still loading. Output is a data: URL (allowed by the page\nCSP). e2e gains a half-drop assertion; suite 184 pass · 0 fail.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"51b33df","date":"2026-05-18 17:00:10 -0700","author":"SteveStudio2","subject":"Harden the room-capture flow for mobile + touch","body":"Mobile is the capture flow's primary surface (phone camera), so:\n- @media (pointer:coarse) enlarges the box drag handles to 34px finger\n  targets; @media (max-width:560px) stacks the spec inputs, full-widths\n  the file buttons, and caps the photo at 58vh.\n- wireBox now attaches pointermove/up to window instead of relying on\n  setPointerCapture — the drag survives the pointer leaving the small\n  handle, on mouse and touch alike.\ne2e-room-capture gains a corner-drag assertion and a Phase 4 that loads\n/book at 390px with touch — no horizontal overflow, finger-sized handles.\nSuite 183 pass · 0 fail.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"5c5c9df","date":"2026-05-18 16:53:50 -0700","author":"SteveStudio2","subject":"e2e-room-capture: 14-assertion test for the camera quote flow","body":"Covers the capture UI (photo upload → measure stage → box-aspect width\nderivation → ceiling-height recompute → wallpaper preview + serialization),\nthe upload endpoint type-guard, and a room_captures DB round-trip proving\nthe sanitiser drops an off-site photo URL. Auto-discovered by run-e2e.js;\nsuite now 180 pass · 0 fail. Skips cleanly (78) without Chromium/fixture.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"a1dfc05","date":"2026-05-18 16:49:55 -0700","author":"SteveStudio2","subject":"Surface room captures in the installer brief — admin page + email","body":"Admin booking-detail gets a 'Room captures' section: per room the wall\nphoto, walkthrough-video link, confirmed dimensions, and — when the\ncustomer supplied their own wallpaper — its specs plus a true-scale tiled\npreview on a wall of the measured proportions. The installer notification\nemail gets a matching 'Room captures' summary with absolute photo/video\nlinks. Installer quotes from real footage instead of a typed guess.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"6557a78","date":"2026-05-18 16:47:13 -0700","author":"SteveStudio2","subject":"Persist room_captures on booking POST + derive sq ft from measured walls","body":"New lib/room-captures.js sanitizes the customer-supplied capture JSON:\nevery photo/video/wallpaper URL must match the exact content-addressed\nshape /api/booking-media produces (off-site URLs and path traversal are\ndropped), numbers are clamped, the array is capped at 20 rooms, and a\nwallpaper missing its width or repeat is dropped. The booking POST stores\nthe clean array and, when square_feet was left blank, backfills it from\nthe captured wall total. Verified: 7 sanitizer cases incl. off-site URL +\ntraversal + overflow; full e2e suite still 166/166.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"d2f12ee","date":"2026-05-18 16:43:50 -0700","author":"SteveStudio2","subject":"Add camera capture & measure step to the /book wizard","body":"Per room the customer photographs a wall, drags a box to frame it, and\nconfirms the ceiling height — wall width is derived from the box aspect\n(estimate-then-confirm, never a silent measurement). Optional room\nwalkthrough video. Optional 'preview your own wallpaper': upload the\nwallpaper image + its exact panel width and vertical repeat, and it tiles\nonto the wall photo at true real-world scale. Everything serializes into\nthe hidden room_captures field that posts with the booking. Measured walls\nauto-total into the square-feet field. Verified via a 9-assertion\nPlaywright run (upload, box-drag aspect math, wallpaper preview, multi-room).\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"9df4b87","date":"2026-05-18 16:34:02 -0700","author":"SteveStudio2","subject":"Add POST /api/booking-media — public upload for the room-capture flow","body":"Unauthenticated (the customer has no account) so it's IP-rate-limited at\n40/hr and uses sha256 content-addressed storage — the client cannot\ninfluence the path. Accepts JPG/PNG/WebP/HEIC images + MP4/MOV/WebM video\nup to 40 MB. CSRF is satisfied via the X-CSRF-Token header (multipart\nbodies aren't parsed when the CSRF middleware runs). robots.txt now\ndisallows /uploads/bookings/ so customer media isn't indexed.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"81398eb","date":"2026-05-18 16:32:06 -0700","author":"SteveStudio2","subject":"Migration 022: room_captures JSONB on bookings","body":"Stores camera-captured rooms for the quote brief — per-wall photo/video,\ncustomer-confirmed dimensions, and optional customer-supplied wallpaper\n(exact panel width + pattern repeat) for an on-wall scaled preview.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"841f9be","date":"2026-05-18 16:25:45 -0700","author":"SteveStudio2","subject":"Surface the structured booking brief in the installer notification email","body":"UX backlog #6's promise is 'the installer arrives prepped' — but the new\nbooking email showed only customer/when/type/address/notes. All the brief\ndata (ceiling height, surface state, access, brand/SKU, rolls, sq ft,\nmaterial, who's ordering, wallpaper-selected) was captured and stored but\nnever delivered where the installer first sees it. Added a 'Project brief'\nsection via prettyEnum + infoRow helpers (empty fields omitted; handles\nboth string req.body and boolean DB-row shapes).\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"2e14b8b","date":"2026-05-18 16:21:02 -0700","author":"SteveStudio2","subject":"Fix /api/near-me: accept county/town/zip filters without coordinates","body":"The handler computed countyHit/townHit/zipHit matches but the guard\nrejected every request lacking lat/lng with 400 — so those three match\npaths were unreachable dead logic. Now accepts a request with coordinates\nOR any county/town/zip filter; distance_miles is null for filter-only\nmatches and they sort last. (Endpoint had zero callers since the /map\nrewrite — keeping it as a working JSON API rather than shipping it broken.)\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"283685a","date":"2026-05-18 16:17:05 -0700","author":"SteveStudio2","subject":"Clear all 6 npm audit vulns — audit fix + bcrypt 5.x→6.0.0","body":"npm audit fix resolved 3 moderate (brace-expansion, express-rate-limit,\nip-address). bcrypt bumped to 6.0.0, which drops the vulnerable\n@mapbox/node-pre-gyp + tar chain (3 high) — 46 transitive packages removed.\nAPI-compatible: only hash()/compare() are used, and bcrypt 6 still\nvalidates the existing $2b$10$ hashes in the installers table. Verified\nvia login + claim-flow e2e. npm audit now reports 0 vulnerabilities.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"71aa66d","date":"2026-05-18 16:15:51 -0700","author":"SteveStudio2","subject":"Split geoLimiter — map JSON and helpful-votes get independent buckets","body":"Same shared-bucket fix as claimLimiter: /api/installers.geo and the\npaper-comment helpful-vote route shared one 60/min bucket, so heavy map\nbrowsing could exhaust a reader's vote budget. Added a minuteLimiter(max)\nfactory; geoLimiter and voteLimiter are now separate instances.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"868356a","date":"2026-05-18 16:13:08 -0700","author":"SteveStudio2","subject":"Split shared claimLimiter into per-route buckets (claim/notify/COI/comment)","body":"A single claimLimiter instance was applied to four unrelated routes, so they\nshared one 5/hr/IP bucket — a designer who requested 5 COIs could no longer\nclaim a profile, and burst commenters were blocked by unrelated traffic. Each\nroute now gets its own independent store via an hourLimiter(prodMax) factory:\nclaim 5/hr (unchanged, sensitive), notify 10, COI 20, comments 20. Non-prod\nstays widened to 100.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"f4d5c8f","date":"2026-05-18 16:09:41 -0700","author":"SteveStudio2","subject":"smoke.test.js: update /map assertion for address-driven near-me rewrite","body":"/map (commit b3b4e14) no longer client-fetches /api/installers.geo —\nmarkers are server-rendered from an address form. Assert leaflet.css +\nthe near-me form instead. The geo endpoint stays covered by its own test.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"4d68e2a","date":"2026-05-18 16:09:41 -0700","author":"SteveStudio2","subject":"tests/app.js: mount csrfMiddleware to mirror server.js","body":"The COI-request partial references csrfToken; the test app factory never\ngot the CSRF middleware server.js added, so installer pages 500'd under\nsupertest. GET smoke tests pass through unaffected.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"0cf8dae","date":"2026-05-18 16:09:41 -0700","author":"SteveStudio2","subject":"claimLimiter: widen to 100/hr in non-prod so back-to-back e2e suites don't 429","body":"Shared by claim/notify/COI-request/paper-comments routes. The 5/hr cap\nexhausted across suites, 429ing every POST after the first 5 — same class\nof bug the loginLimiter commit fixed. Prod stays at 5.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"d8b8e58","date":"2026-05-13 08:57:57 -0700","author":"Steve","subject":"snapshot: 2 file(s) changed, +1 new, ~1 modified","body":""},{"hash":"6346a38","date":"2026-05-12 16:46:45 -0700","author":"Steve","subject":"wia-bb: log session-minutes to cost-tracker via shared helper","body":""},{"hash":"b3b4e14","date":"2026-05-10 21:39:40 -0700","author":"SteveStudio2","subject":"/map → address-driven near-me view + service_counties/towns/zips","body":"Steve's brief: 'make sure the map feature showing current location and\ninstallers servicing which area … allow user to select their address …\nno map of entire country needed.'\n\nSchema (migration 021):\n- installers.service_counties TEXT[]\n- installers.service_towns    TEXT[]\n- installers.service_zips     TEXT[]\n- GIN indexes on each.\nDefault ARRAY[]::TEXT[] so the 524 seeded studios keep radius-only\nbehavior until they fill in counties/towns.\n\nRoute /map (rewritten):\n- No-address visit: just the address-input form + 'Use my location' button.\n  No country pin map.\n- Address visit: server-side geocode via OSM Nominatim (lib/geocode.js,\n  no API key, 10-min in-memory cache). Returns lat/lng + county + town +\n  zip + state.\n- Filter: installer matches if (within service_radius_miles) OR\n  (county in service_counties) OR (town in service_towns) OR\n  (zip in service_zips). Match reasons surface per result.\n- Sort: tier-paid first within 5-mi bucket, then distance ascending.\n- Renders zoomed Leaflet map (auto-fits to results, max-zoom 12) +\n  result cards with distance, response time, brand-trained count, tier.\n\nRoute /api/near-me — JSON API for downstream widgets. Takes lat/lng +\noptional county/town/zip, returns filtered installers + distance.\n60s edge cache.\n\nAdmin /admin/profile:\n- New form fields for service_counties / service_towns / service_zips\n  (comma-separated, capped per-entry + total count).\n- POST handler arrays them safely, caps total list size.\n\nCSP:\n- connectSrc adds nominatim.openstreetmap.org for the client-side\n  'Use my location' reverse-geocode.\n\nCleanup:\n- map.ejs no longer pulls markercluster (unused — single-city result\n  sets don't need clustering).\n\nSmoke-tested:\n- /map → 200, renders form + Use-my-location button.\n- /map?address=90210 → 200, geocodes to 34.09 / -118.41 (Beverly Hills),\n  16 studios service the area, map markers + cards render."},{"hash":"58309cd","date":"2026-05-10 20:42:23 -0700","author":"SteveStudio2","subject":"loginLimiter: widen to 100/15min in non-prod (CI flakiness fix)","body":"Production stays at 10/15min/IP. NODE_ENV !== 'production' gets 100,\nbecause the chained npm run test:e2e runs several login-requiring\nsuites back-to-back and the 10-cap was tripping the limiter\nmid-suite, masking real-vs-flaky test results."},{"hash":"5918434","date":"2026-05-10 20:36:07 -0700","author":"SteveStudio2","subject":"yolo tick 5: expand /papers seed library 5→17 + helpful-vote e2e","body":"Post-shipping polish for backlog #3. The 5 seed threads from migration\n018 covered the obvious benchmarks; this adds 12 more from the\nworking-installer canon — patterns that get DM'd about weekly:\n\n  de Gournay St Laurent, Fromental Non Such, Phillip Jeffries Shoji,\n  Elitis Perles, Schumacher Iconic Leopard, Thibaut Tulia, Cole & Son\n  Cow Parsley, Zoffany Elswick, Zuber Eldorado, Arte Katagami, Hermès\n  Faubourg, Flavor Paper Pop Art\n\nDistribution across categories now: specialty 5, chinoiserie 4, mural 2,\nfloral 2, grasscloth 2, damask 1, metallic 1. Filter pills on /papers\nwill all populate.\n\nAll on conflict (slug) do nothing — re-runnable. Reversible by slug.\n\nE2E: tests/e2e-paper-helpful-vote.js — 7/0 standalone validates the\nvote endpoint: button renders, click bumps helpful_count 0→1 +\ninserts paper_comment_votes row, repeat vote from same IP-hash is\nidempotent (count stays 1, vote table still 1 row), button label\nreflects new count, mismatched slug rejected with 403/404.\n\nSuite environment note: chained npm run test:e2e shows cascade\nfailures (paper-comments 5/5, coi-request 4/4) due to login rate-\nlimit exhaustion across the suite — login limiter is 10/15min/IP and\neach test that logs in chews 1-2. Solo runs of each test are clean:\n  - e2e-coi-request:      14/0\n  - e2e-paper-comments:   10/0\n  - e2e-paper-helpful-vote: 7/0\nFor CI we should either widen the login limiter for localhost or\nstagger the test runs. Out of scope for this tick."},{"hash":"aa09d6e","date":"2026-05-10 18:57:03 -0700","author":"SteveStudio2","subject":"yolo tick 4: paper-thread contribution count + helpful votes — backlog #3 SHIPPED","body":"Final polish for UX backlog #3. Two pieces:\n\n1. Helpful votes on individual comments (migration 019):\n   - New table paper_comment_votes (comment_id, ip_hash, created_at)\n     with PK (comment_id, ip_hash) — one vote per IP-hash per comment.\n   - POST /papers/:slug/comments/:id/helpful — no auth (designer\n     participation is the value). Rate-limited via geoLimiter\n     (60/min/IP). IP hashed sha256-prefix-16 same as installer_interest.\n   - ON CONFLICT DO NOTHING + RETURNING — only first vote bumps the\n     denormalized helpful_count. Repeat votes silently no-op.\n   - Comments sorted by helpful_count DESC then created_at ASC, so\n     useful notes rise.\n   - Smoke tested: 1st vote 302 + count 3→4, 2nd vote same IP-hash\n     302 + count still 4, paper_comment_votes has exactly 1 row.\n\n2. Installer-profile selection signal:\n   - routes/public.js /installer/:slug now computes paperContributions\n     {count, helpful, recent[3]} via two cheap queries on the indexed\n     paper_comments table.\n   - views/partials/paper-contributions.ejs renders nothing when\n     count=0, otherwise: \"N contributions · X found helpful\" + list\n     of 3 recent threads + link to /papers.\n   - Included by all 7 templates: installer.ejs (in sidebar) + the 6\n     installer-tpl-*.ejs variants (before coi-request include).\n   - Designers see at a glance which studios contribute craft\n     knowledge — selection signal no generic directory has.\n\nUX_CREATIVE_BACKLOG.md: item #3 marked SHIPPED.\n\nEntire 7-item UX backlog now ships:\n  #1 In-the-seams portfolio  ✓\n  #2 Brand-trained badges    ✓\n  #3 Paper threads           ✓ (this tick)\n  #4 Equipment fleet card    ✓\n  #5 Live COI request        ✓\n  #6 Structured booking      ✓\n  #7 Acceptance-rate badge   ✓\n\nLoop stops here per the tick-4 prompt condition."},{"hash":"4a79e6f","date":"2026-05-10 18:21:11 -0700","author":"SteveStudio2","subject":"yolo tick 3: paper-thread comment submission + ops moderation","body":"Write-side for UX backlog #3. Ticks 1+2 shipped schema+seeds+read-only\npublic routes; this tick adds installer-only writes + ops moderation.\n\nPublic POST /papers/:slug/comments:\n- requireInstaller gate (existing middleware) — installer-only writes.\n- claimLimiter (5/hr/IP) mounted in server.js, same as /coi-request +\n  /notify-when-live.\n- Body validation: 20-4000 chars; reject with flash.error on failure.\n- Trigger paper_threads_recount() bumps thread.comment_count on INSERT.\n- Comments post immediately and are public-readable; no pre-mod queue\n  (the friction would kill the format). Ops can flag post-hoc to hide.\n\nDetail view:\n- Replaced \"ships in next tick\" placeholder with the actual form\n  (textarea + character hint + submit).\n- Flash bubble renders both ok + error states above the form.\n- Anchors: #comment-form and #comments for redirect targets.\n\nOps moderation (/admin/papers-moderation):\n- requireOpsInstaller gate (env-driven allow-list, same as\n  /admin/ops/credentials and /admin/ops/watch).\n- Filter pills: all / visible / flagged.\n- Per-comment Flag/Unflag toggle — reversible; flag = hide from public,\n  not deletion. Schema preserved so audit trail stays intact.\n- public detail query already filters c.flagged = false (was wired in\n  tick 2), so flagging immediately hides without re-deploying.\n\nE2E: tests/e2e-paper-comments.js — 10/0 standalone:\n  1. Unauth detail renders + login CTA shown\n  2. Login as installer\n  3. Detail now shows comment form\n  4. Submit valid note → row + trigger fires + renders\n  5. Too-short note → rejected via flash, no row written\n  + cleanup deletes test row\n\nNext tick: \"X contributions to paper threads\" badge on installer\nprofiles (selection signal for designers) + polish."},{"hash":"4492052","date":"2026-05-10 17:44:11 -0700","author":"SteveStudio2","subject":"yolo tick 2: public /papers list + /papers/:slug detail (read-only)","body":"Continues UX backlog #3. Tick 1 landed the schema; this tick exposes\nthe 5 seed threads via public read-only routes.\n\n- GET /papers — list page sorted by comment_count DESC, brand/paper_name\n  fallback. Category filter pills (chinoiserie/grasscloth/specialty/\n  metallic). Renders 2-line description preview + paste/category badges\n  + note count per row.\n- GET /papers/:slug — thread detail. Header with brand + paper_name +\n  category/paste/SKU/comment-count meta row, blockquote-style description,\n  comments section ordered by helpful_count DESC then created_at ASC.\n  Each comment attributes to its installer with link to profile + city\n  + verified badge — gives designers a selection signal (which studios\n  contribute thoughtful craft knowledge).\n- 404 on missing slug.\n- /papers added to primary nav (between Map and Watch).\n- /papers + /papers/:slug added to sitemap.xml with weekly/0.6.\n- Read-only: no comment submission yet. Detail page CTA tells logged-in\n  installers \"comment submission ships next tick\"; non-logged-in users\n  get an /login CTA.\n\nNext tick: POST /papers/:slug/comments + admin moderation queue."},{"hash":"4dca72e","date":"2026-05-10 17:09:30 -0700","author":"SteveStudio2","subject":"yolo tick 1: paper_threads + paper_comments schema + 5 seed threads","body":"Migration 018 lands the schema for UX backlog #3 (\"This Paper\" peer-\ninstaller commentary). Schema only — no public routes yet, so the\ntables are inert and the feature isn't visible to users.\n\npaper_threads (id, slug, brand, paper_name, sku, paste_type, category,\ndescription, seeded_by, comment_count, timestamps). 5 seed rows for\nthe most-commented papers in luxury wallcovering: de Gournay Earlham,\nFromental Bois, Phillip Jeffries Manila Hemp, Maya Romanoff Ajiro,\nSchumacher Vassily.\n\npaper_comments (thread_id, installer_id, body, helpful_count, flagged,\ntimestamps) — public-readable, installer-only writable via existing\nrequireInstaller middleware (wired in next tick).\n\nTrigger paper_threads_recount() keeps comment_count denormalized so\nsort-by-activity is cheap.\n\nNext tick: public /papers list + /papers/:slug detail (read-only)."},{"hash":"b681b43","date":"2026-05-10 15:15:23 -0700","author":"SteveStudio2","subject":"UX_CREATIVE_BACKLOG: mark #5 (Live COI) shipped v0","body":""},{"hash":"66c6c0d","date":"2026-05-10 15:14:21 -0700","author":"SteveStudio2","subject":"Live COI request flow (UX backlog #5)","body":"Designer-driven Certificate of Insurance request, end-to-end. Replaces\nthe 3-day phone-tag in luxury commercial/hospitality jobs where the\ndesigner's primary insurance requires the installer to be named as\nadditional-insured on a fresh COI per project.\n\nSchema (migration 017):\n- installers.insurance JSONB — carrier, policy_number, limits.{general_aggregate_usd,per_occurrence_usd}, expiry, broker_{name,email,phone}, auto_attach_to_email\n- 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}\n\nPublic flow:\n- /installer/:slug — shared partial views/partials/coi-request.ejs renders\n  the \"Request COI →\" CTA + reveal-on-click structured form. Gated on\n  insurance_on_file && claimed. Included by all 6 template variants\n  (editorial/trade-pro/concierge/studio/heritage/bilingue) and the\n  legacy installer.ejs.\n- POST /installer/:slug/coi-request — validates designer/insured fields,\n  writes coi_requests row, fans out 3 emails (installer + broker if set\n  + designer confirmation) via existing lib/email Purelymail transport.\n  Rate-limited via claimLimiter (5/hr/IP).\n- views/public/coi-request-result.ejs — confirmation page.\n\nAdmin flow:\n- /admin/insurance (GET/POST) — installer enters structured insurance\n  metadata + broker contact. Insurance link added to admin nav.\n- /admin/coi-requests (GET) — request inbox with status badges, project\n  details, additional-insured name, broker-notified state.\n- /admin/coi-requests/:id/status (POST) — installer can mark requests\n  acknowledged/fulfilled/declined. fulfilled_pdf_url validated as http(s).\n\nE2E: tests/e2e-coi-request.js — 14/0/0 standalone, full flow:\n  1. Public profile renders COI section\n  2. Reveal form + submit\n  3. DB row created with correct fields\n  4. Login as installer, request appears in admin inbox\n  5. Mark fulfilled, DB status flips + fulfilled_at set\n  6. Cleanup deletes test row"},{"hash":"c8e0e1a","date":"2026-05-10 14:07:34 -0700","author":"SteveStudio2","subject":"e2e-stripe-flow: real installer-session fixture + Connect URL hardening","body":"Phases 1-3 now drive the actual Stripe Connect + checkout flow with a\nplaywright-logged-in installer (rivera-installs-miami / demo+rivera).\nForm submits go through the page (csrf + cookies ride along) but don't\nfollow Stripe's cross-origin redirects — instead they assert on the DB\nside-effect (stripe_account_id populated, tier flipped, etc.) so the\ntest never has to load connect.stripe.com or checkout.stripe.com.\n\nSafety gates:\n- STRIPE_KEY_LIVE detection: when the test env has sk_live_ keys, phases\n  2 + 3 SKIP cleanly with a clear message — they would otherwise create\n  REAL Connect accounts + Checkout sessions in Steve's live Stripe. Opt\n  in with STRIPE_E2E_FULL=1 only against a sk_test_ server.\n- Webhook probe: phase 5 sends a no-op probe event first; if the server\n  rejects with 400 (sig enforced, no STRIPE_DEV_ACCEPT_UNSIGNED on the\n  server side), phases 5+6 SKIP cleanly instead of failing.\n- Output format aligned with run-e2e.js parser ([result] N pass · N fail).\n\nReal bug fixed in lib/stripe.js — Connect account creation passed\ninstaller.website straight through as business_profile.url, which\nStripe rejects with url_invalid for placeholder URLs (example.com,\nlocalhost, .test). New validBusinessUrl() filter parses + whitelists\nreal public hosts only. Caught by trying to onboard rivera (seeded\nwith https://example.com/rivera) → 500 from Stripe.\n\nLocal suite now: 109 pass · 0 fail · 3 skipped (claim/template/stripe\nskip when login limiter is exhausted from chained runs — pre-existing\nsoft-skip behavior)."},{"hash":"6f348e2","date":"2026-05-10 09:00:52 -0700","author":"SteveStudio2","subject":"nph-stripe-plumbing: scaffold e2e-stripe-flow.js + synthesis doc","body":"Plannator Run A audit revealed the Stripe Connect + booking-deposit\nplumbing is already shipped end-to-end on main (Connect onboarding,\ncheckout, 8-event webhook with idempotency, /admin/billing payouts\ndashboard with platform rollup). Real gap was a single missing e2e\ntest, now scaffolded with 6 phases gated on STRIPE_SECRET_KEY or\nSTRIPE_DEV_ACCEPT_UNSIGNED=1.\n\nPLANNATOR-SYNTHESIS.md documents the assumed-vs-actual delta and the\nremaining human-only steps (Google + LinkedIn console clicks, PG-pw\nrotation when Steve is at the keyboard)."},{"hash":"51036bf","date":"2026-05-07 23:19:09 -0700","author":"Steve","subject":"rel=alternate JSON + HTTP Link header on /find — applies VCL pattern","body":"New /api/find route mirrors /find filter logic (q, segment, material, zip,\nstate, show) and returns JSON wrapped in {query, pagination, results}.\nHTML head adds <link rel=\"alternate\" type=\"application/json\"> when\nalternateJsonUrl is in the render context. /find route now sets HTTP\nLink header (RFC 8288) with rel=canonical + rel=alternate.\n\nSmoke-tested locally on :9765 — 522 installers no-filter, 20 NY,\n0 CA+residential (correctly applied; market_segments uses\n'luxury_residential' not 'residential' — feature, not bug).\n\nSame pattern shipped on Ventura Claw Leads earlier today (commit fbd4be4).\nNow applied to NationalPaperHangers. Future targets: lawyer-directory,\nlacountyeats, professional-directory, animals.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"e6efc97","date":"2026-05-07 17:57:44 -0700","author":"Steve","subject":"Fix legal-page ZIP in CAN-SPAM footer: 91335→91403","body":"Steve confirmed 2026-05-07. Address now correct in both occurrences\non /legal — privacy and terms blocks both reference the same line.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"ab11e6e","date":"2026-05-07 12:29:28 -0700","author":"Steve","subject":"tighten .gitignore: add missing standing-rule patterns (build/ .next/)","body":""},{"hash":"8efbbdb","date":"2026-05-07 08:44:52 -0700","author":"Steve","subject":"tests: e2e-business-invariants — sitemap.xml + robots.txt regression checks (528 URLs, ref to sitemap, /admin Disallow). 41 prod assertions","body":""},{"hash":"442cc6c","date":"2026-05-07 08:02:15 -0700","author":"Steve","subject":"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","body":""},{"hash":"d145b79","date":"2026-05-06 21:17:26 -0700","author":"Steve","subject":"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","body":""},{"hash":"2ff46f6","date":"2026-05-06 20:44:17 -0700","author":"Steve","subject":"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","body":""},{"hash":"a0527b7","date":"2026-05-06 19:57:58 -0700","author":"Steve","subject":"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","body":""},{"hash":"cead641","date":"2026-05-06 19:55:02 -0700","author":"Steve","subject":"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)","body":""},{"hash":"c75cd32","date":"2026-05-06 19:53:57 -0700","author":"SteveStudio2","subject":"/find shows ALL installers by default (was 5 claimed-only) · LIMIT 600","body":"Steve override 2026-05-07: '/find' was hiding 519 of 524 installers behind\n?show=all because the 2026-05-06 UX critic flipped default to 'claimed'.\nSteve wants the directory's full breadth visible by default — flipping\nback: default is now 'all', ?show=claimed opts down to 5 verified.\n\nToggle href reverses direction accordingly. LIMIT bumped 200 → 600 so\nthe full 524-installer list returns in one page.\n\nLive verified: https://nationalpaperhangers.com/find renders 524 cards."},{"hash":"698afa0","date":"2026-05-06 19:37:33 -0700","author":"Steve","subject":"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","body":""},{"hash":"eb62adf","date":"2026-05-06 19:20:10 -0700","author":"Steve","subject":"tests: e2e claim flow — landing form → email-domain check → token mint → single-use verify → password set → /admin landing. 10/10 pass with auto-cleanup","body":""},{"hash":"0420926","date":"2026-05-06 18:11:55 -0700","author":"Steve","subject":"tests: e2e for /find sort + density controls — 7/7, locks in CLAUDE.md standing rule","body":""},{"hash":"72e3f64","date":"2026-05-06 18:10:46 -0700","author":"Steve","subject":"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)","body":""},{"hash":"7c77afb","date":"2026-05-06 17:37:36 -0700","author":"Steve","subject":"tests: e2e buyer wizard — 5 steps, conditional brand reveal, back-nav persistence, pill state. 16/16 pass","body":""},{"hash":"efd7990","date":"2026-05-06 17:04:17 -0700","author":"Steve","subject":"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","body":""},{"hash":"6546f0f","date":"2026-05-06 16:40:18 -0700","author":"Steve","subject":"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","body":""},{"hash":"d15181a","date":"2026-05-06 16:05:55 -0700","author":"SteveStudio2","subject":"Remove 'Schedule install' booking option — initial visit only","body":"NPH books the initial consultation; install scheduling, pricing, and contract\nhappen directly between consumer and installer afterward. Keeps NPH on the\nlead-coordination side of CSLB $7026 — booking install work through NPH\nwould expose us to general-contractor classification.\n\n- Drop visit-picker fieldset (consultation+install radio cards)\n- Replace with brass-bordered note: 'You're scheduling an initial visit\n  with {studio}. Install scheduling, contracts, and pricing for the work\n  itself happen directly between you and the studio.'\n- Drop visit-picker JS (no more project_type-swap on click)\n- Drop dead .visit-picker / .vp-* CSS\n\nThe hidden project_type input still ships 'consultation' to /api/installers/:slug/book.\nExisting API still validates the install enum value for legacy bookings already\nin the DB; nothing new will hit that path.\n\nProd-verified: 0 picker strings, replacement note present."},{"hash":"31cdca8","date":"2026-05-06 15:58:00 -0700","author":"SteveStudio2","subject":"Reposition NPH as referral/lead-coordination service (CSLB §7026 hygiene)","body":"Steve's call after the 25% fee bump — cleaning up CA contractor-licensing\nexposure surface area. Five micro-edits across 8 files:\n\n1. Strip 'Concierge matching' and 'project oversight' everywhere they\n   appeared (home.ejs hero+JSONLD, head.ejs default meta, footer.ejs tag,\n   routes/public.js home metaDescription). Replaced with 'directory',\n   'lead routing', and 'search and schedule consultations directly with\n   installation studios' phrasing.\n\n2. footer-fineprint now carries the substantive disclaimer:\n   'NPH is a referral and lead-coordination service. The contract for any\n   wallcovering installation is between the consumer and the installer\n   directly. NPH does not warrant, supervise, or guarantee installation\n   work.' Plus CSLB verification link.\n\n3. /terms (legal.ejs terms branch): replaced placeholder with 9-section\n   substantive ToS positioning NPH as referral service. Sections explicit\n   about what NPH does NOT do (perform install work, set price, supervise,\n   warrant, employ). CSLB verification call-out for CA $500+ contracts.\n   Liability cap at 12mo of fees paid.\n\n4. /privacy (legal.ejs privacy branch): replaced placeholder with full\n   policy. CCPA/GDPR rights, retention windows (active+90d, bookings 7yr,\n   suppression indefinite, logs 30d), Stripe/Purelymail/GA processor list.\n\n5. installer.ejs reserve-banner + book.ejs deposit fieldset: added\n   'installation contract is between you and {studio} directly' + CSLB\n   link near every Book CTA.\n\nAudit on prod (2026-05-06): 0 bad strings (Concierge matching / project\noversight / 10% marketplace fee) across 9 key surfaces; 1+ defensive\nstrings (referral disclaimer / CSLB link) on every public page.\n\nThis is hygiene, not legal advice — Steve still needs a CA construction-law\nattorney consult before the marketplace scales materially."},{"hash":"bec9ac1","date":"2026-05-06 15:51:49 -0700","author":"SteveStudio2","subject":"Bump platform fee 10%→25% + reframe as 'lead-coordination fee' in copy","body":"Steve's call 2026-05-06. NPH_PLATFORM_FEE_BPS default 1000→2500; enterprise\ndiscount tier 700→1500. The label change from 'platform fee' / 'marketplace\nfee' to 'lead-coordination fee' is intentional positioning — a 25% take rate\ndescribed as a marketplace cut invites contractor-licensing scrutiny in CA\n($7026), whereas a referral/lead-coordination fee is the standard home-services\nlead-gen model (HomeAdvisor, Angi, Yelp).\n\nTouched: lib/stripe.js DEFAULT_PLATFORM_FEE_BPS, views/admin/dashboard.ejs\n(2 callouts), views/admin/billing.ejs, views/public/installer.ejs reserve banner.\nNPH_PLATFORM_FEE_BPS=2500 set on Kamatera prod .env. 26/26 tests pass."},{"hash":"f21468c","date":"2026-05-06 15:46:51 -0700","author":"SteveStudio2","subject":"v0.7 #31: auto-email worker for installer_interest queue","body":"scripts/notify-interest-queue.js — scans installer_interest WHERE notified_at\nIS NULL and the installer is now in 'ready' state (claim_status in self/claimed\nAND tier in pro/signature/enterprise AND ≥1 active installer_availability),\nsends a one-shot 'they're live' email via Purelymail SMTP, marks notified_at\n+ notify_msg_id on the queue row, audits to comms_send_audit.\n\nCompliance:\n- Pre-flight assertSendCompliance() — refuses to enter the loop if MAILING_ADDRESS\n  / SESSION_SECRET / PUBLIC_URL / suppression tables are misconfigured.\n- Per-recipient isSuppressed() scrub. Suppressed rows still get notified_at\n  marked (so they don't sit in the queue forever) plus a 'skipped' audit row.\n- 1-click unsubscribe token + List-Unsubscribe headers (RFC 2369/8058).\n- complianceFooter with MAILING_ADDRESS in every email.\n\nCron: */15 * * * * on Kamatera root crontab → /var/log/nph-notify-interest.log.\n\nLive test on prod 2026-05-06:\n- Seed row → run → sent (msgId @nationalpaperhangers.com) → audit row 'sent' → queue.notified_at populated.\n- Second run → 0 candidates (idempotent).\n\nFlags: --dry-run (no DB write, no SMTP, just log), --limit N (cap iteration)."},{"hash":"f339c34","date":"2026-05-06 15:03:17 -0700","author":"SteveStudio2","subject":"v0.6: notify-when-live capture form on unclaimed /book","body":"Turns the dead-end /book page on unclaimed installers into a capture funnel.\n\n- db/migrations/016_installer_interest.sql: new table (installer_id, email,\n  source, zip, notes, ip_hash, created_at, notified_at, notify_msg_id) with\n  UNIQUE(installer_id, email) for per-studio dedup. Partial index on un-notified\n  rows for fast queue scans.\n- routes/public.js: POST /installer/:slug/notify-when-live — basic email shape\n  validation, ip_hash via SESSION_SECRET-salted sha256, ON CONFLICT DO NOTHING\n  on the dedup constraint. Silent no-op when slug is already claimed (don't\n  leak that someone else's interest worked). Renders book-notify-result.\n- views/public/book-notify-result.ejs: success/error landing page.\n- views/public/book.ejs: capture form on the unclaimed branch only — email +\n  ZIP fields, CSRF token, micro-copy promising one email when they're live.\n- server.js: claimLimiter applied to the new route (5 req/hour/IP).\n\nLive test on prod (demo-unclaimed-ridgeway-paper, 2026-05-06): form renders,\nPOST returns 'Got it', row lands in installer_interest with the correct\nemail/zip/source. Auto-email-on-claim is v0.7."},{"hash":"196af63","date":"2026-05-06 14:53:42 -0700","author":"SteveStudio2","subject":"v0.5: NPH email through Purelymail SMTP + tighter /book calendar gate","body":"lib/email.js — direct nodemailer→smtp.purelymail.com:587 with\nINFO_NATIONALPAPERHANGERS_COM_PASSWORD. Was sending through George account=info\nwhich actually maps to a Gmail label on Steve's DW Workspace, so From was\nsteve@designerwallcoverings.com — wrong domain for SPF/DKIM/DMARC alignment.\nVerified live on Kamatera 2026-05-06: messageId\n<...@nationalpaperhangers.com> (Purelymail SMTP, not Gmail).\n\nroutes/public.js + book.ejs — /book calendar now requires THREE conditions,\nnot just paid tier:\n  1. claim_status in (self, claimed) — real installer-user behind the listing\n  2. tier in (pro, signature, enterprise) — paid plan\n  3. ≥1 active installer_availability window\nAny missing → fall through to contact-direct callout with reason-specific\ncopy (calendarBlockedReason: unclaimed | free_tier | no_schedule). The\nunclaimed branch points the visitor to /installer/:slug#claim with prompt\nto claim their listing."},{"hash":"8dd327f","date":"2026-05-06 14:48:36 -0700","author":"SteveStudio2","subject":"v0.5: bootstrap Stripe products+prices+webhook for NPH","body":"- 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.\n- 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.\n- 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.\n\nSaved 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."},{"hash":"de5a034","date":"2026-05-06 14:13:04 -0700","author":"SteveStudio2","subject":"v0.4 #5 follow-up: hide brand-name + tighten header actions at <769px","body":"QA pass on https://nationalpaperhangers.com showed 2px overflow at 380 viewport\n(.header-actions = 382px) and 12px overflow on /watch (auto-fill 360px minmax\nforced single columns wider than viewport). Fixes:\n\n- header-inner: padding 14/16 (was 32 desktop), wrap actions\n- header-actions .btn: 6/10 padding, 12px font (down from 14)\n- brand-name: display none — keep N·P·H mark only on phone\n- watch-grid: force 1-col w/ !important to override the 360px auto-fill minmax\n- watch-page padding 24/16 (was 48/32)\n\nPlaywright headless QA at 380×720 against prod: zero overflow on /find /watch\n/installer/:slug /map (leaflet tile-pane is overflow:hidden so its tiles\naren't real horizontal overflow)."},{"hash":"9bcbde8","date":"2026-05-06 13:21:40 -0700","author":"SteveStudio2","subject":"v0.4 #5: load theme-toggle.js + /find↔/map filter parity + tablet/phone breakpoints","body":"- 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)\n- views/partials/head.ejs: <script src=/js/theme-toggle.js defer> — the file existed but was never loaded (regression)\n- views/public/find.ejs: 'View on map' button in result-bar\n- views/public/map.ejs: 'View as list' button + read q/segment/material from URL on load + sync list-link href on filter change\n- routes/public.js: build mapHref preserving q/segment/material and pass to find.ejs\n\n26/26 tests pass."},{"hash":"939d55b","date":"2026-05-06 12:52:26 -0700","author":"Steve","subject":"watch: linkedin embed support + industry company-page strip (verified manufacturer + WIA pages)","body":""},{"hash":"fdd57f9","date":"2026-05-06 12:37:55 -0700","author":"Steve","subject":"templates: hide all /book schedule CTAs for unclaimed (directory-listing) studios — claimed-only after sign-up; show studio info only","body":""},{"hash":"dd2fcde","date":"2026-05-06 12:07:54 -0700","author":"Steve","subject":"claude-codex r3 cleanup: iframe width/height (CLS fix, MEDIUM#9) + JSONB CHECK on featured_video_urls (MEDIUM#7) — both applied to prod","body":""},{"hash":"8c19628","date":"2026-05-06 11:47:59 -0700","author":"Steve","subject":"admin/uploads: per-installer rate limit (30/hr) keyed on installer.id — protects upload dir from runaway/hostile authenticated client","body":""},{"hash":"a7e5ecf","date":"2026-05-06 11:43:13 -0700","author":"Steve","subject":"watch page: ops-curated YT videos about pro installations + social_verified gate on per-installer handles (no fake seeds)","body":""},{"hash":"350a7bb","date":"2026-05-06 11:35:10 -0700","author":"Steve","subject":"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","body":""},{"hash":"4ecde9c","date":"2026-05-06 11:34:03 -0700","author":"Steve","subject":"claude-codex r3: fix null-element crash in 4 templates, session-save race in OAuth callbacks, hostname-spoof in social-embed, missing Vimeo CSP","body":""},{"hash":"a9f49d0","date":"2026-05-06 11:17:18 -0700","author":"Steve","subject":"watch the work: YT + IG reels + TikTok embeds via partial across all 7 templates","body":""},{"hash":"f240b01","date":"2026-05-06 11:11:42 -0700","author":"Steve","subject":"auth: regenerate session on OAuth sign-in (fixation defense — match claim.js)","body":""},{"hash":"f65e1d4","date":"2026-05-06 11:11:18 -0700","author":"SteveStudio2","subject":"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)","body":""},{"hash":"0b791b8","date":"2026-05-06 10:57:52 -0700","author":"Steve","subject":"find: default to verified-only, add 'Show directory listings' toggle (UX critic P1)","body":""},{"hash":"324d2ba","date":"2026-05-06 10:26:48 -0700","author":"Steve","subject":"post-launch QA fixes — header Dashboard label bug, /book gate for unclaimed, deposit copy, GA4 CSP, oakwood active, kill broken portfolio placeholders","body":""},{"hash":"07b632e","date":"2026-05-06 10:25:13 -0700","author":"Steve","subject":"post-deploy: deploy scripts v2/v3, LinkedIn auth route, 6 templates live in prod","body":""},{"hash":"4606c77","date":"2026-05-06 10:22:17 -0700","author":"Steve","subject":"post-launch v0.2 design fixes — sticky reserve banner, drop response-sla badge clutter from /find cards, title-case segment dropdown options","body":""},{"hash":"dfd87ae","date":"2026-05-06 10:18:07 -0700","author":"Steve","subject":"snapshot before claude-codex debate","body":""}]}