[object Object]

← back to NationalPaperHangers

Persist room_captures on booking POST + derive sq ft from measured walls

6557a780955ba9b3c3aa8bfb179a69641f28caed · 2026-05-18 16:47:13 -0700 · SteveStudio2

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>

Files touched

Diff

commit 6557a780955ba9b3c3aa8bfb179a69641f28caed
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Mon May 18 16:47:13 2026 -0700

    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>
---
 lib/room-captures.js | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 routes/api.js        | 19 +++++++++++---
 2 files changed, 89 insertions(+), 4 deletions(-)

diff --git a/lib/room-captures.js b/lib/room-captures.js
new file mode 100644
index 0000000..209ca78
--- /dev/null
+++ b/lib/room-captures.js
@@ -0,0 +1,74 @@
+// Sanitiser for the customer-supplied room_captures JSON posted from the
+// /book camera-capture flow (UX #6). The customer fully controls this blob,
+// so every URL is checked against the exact content-addressed shape our own
+// upload endpoint (/api/booking-media) produces — an attacker cannot make a
+// booking reference an off-site or arbitrary-path resource.
+
+const IMG_RE = /^\/uploads\/bookings\/[0-9a-f]{24}\.(jpg|png|webp|avif|heic|heif)$/;
+const VID_RE = /^\/uploads\/bookings\/[0-9a-f]{24}\.(mp4|mov|webm)$/;
+const MATCH_TYPES = new Set(['straight', 'half_drop']);
+const MAX_ROOMS = 20;
+
+function posNum(v, max) {
+  const n = parseFloat(v);
+  if (!isFinite(n) || n <= 0) return null;
+  return Math.round(Math.min(n, max) * 100) / 100;
+}
+
+// Returns a clean array; drops anything malformed. Never throws.
+function sanitize(raw) {
+  let arr;
+  try { arr = typeof raw === 'string' ? JSON.parse(raw) : raw; }
+  catch (e) { return []; }
+  if (!Array.isArray(arr)) return [];
+
+  const out = [];
+  for (const item of arr.slice(0, MAX_ROOMS)) {
+    if (!item || typeof item !== 'object') continue;
+    const photo = typeof item.photo_url === 'string' && IMG_RE.test(item.photo_url)
+      ? item.photo_url : null;
+    if (!photo) continue; // a capture with no valid photo is meaningless
+
+    const entry = {
+      room: String(item.room || 'Room').trim().slice(0, 80) || 'Room',
+      wall_width_ft: posNum(item.wall_width_ft, 200),
+      wall_height_ft: posNum(item.wall_height_ft, 200),
+      photo_url: photo,
+      video_url: typeof item.video_url === 'string' && VID_RE.test(item.video_url)
+        ? item.video_url : null,
+      wallpaper: null
+    };
+
+    const wp = item.wallpaper;
+    if (wp && typeof wp === 'object'
+        && typeof wp.image_url === 'string' && IMG_RE.test(wp.image_url)) {
+      const widthIn = posNum(wp.width_in, 240);
+      const repeatIn = posNum(wp.repeat_in, 240);
+      // Width + repeat are mandatory — without them the paper can't be shown
+      // at true scale, so an under-specified wallpaper is dropped entirely.
+      if (widthIn && repeatIn) {
+        entry.wallpaper = {
+          image_url: wp.image_url,
+          width_in: widthIn,
+          repeat_in: repeatIn,
+          match_type: MATCH_TYPES.has(wp.match_type) ? wp.match_type : 'straight'
+        };
+      }
+    }
+    out.push(entry);
+  }
+  return out;
+}
+
+// Sum of confirmed wall areas (sq ft), rounded — null when nothing measured.
+function totalSqFt(captures) {
+  let t = 0;
+  for (const c of captures) {
+    if (c.wall_width_ft > 0 && c.wall_height_ft > 0) {
+      t += c.wall_width_ft * c.wall_height_ft;
+    }
+  }
+  return t > 0 ? Math.round(t) : null;
+}
+
+module.exports = { sanitize, totalSqFt };
diff --git a/routes/api.js b/routes/api.js
index 1411b6f..7bbb14d 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -8,6 +8,7 @@ const db = require('../lib/db');
 const slots = require('../lib/slots');
 const email = require('../lib/email');
 const bookingToken = require('../lib/booking-token');
+const roomCapturesLib = require('../lib/room-captures');
 const stripe = require('../lib/stripe');
 const { requireInstaller } = require('../lib/auth');
 const router = express.Router();
@@ -104,6 +105,7 @@ router.post('/installers/:slug/book', async (req, res, next) => {
   const client = await db.pool.connect();
   let booked = null;
   let installer = null;
+  let roomCaptures = [];
   try {
     // Read installer (no transaction yet) just to validate existence + tier.
     installer = await db.one(
@@ -182,6 +184,14 @@ router.post('/installers/:slug/book', async (req, res, next) => {
                          : null;
     const consumerAccountId = (req.session && req.session.consumerAccountId) || null;
 
+    // Camera-captured rooms (UX #6) — validated against our own upload-path
+    // shape. When the customer measured walls but left square_feet blank,
+    // fall back to the captured total so the installer always has a number.
+    roomCaptures = roomCapturesLib.sanitize(f.room_captures);
+    const squareFeet = f.square_feet
+      ? parseInt(f.square_feet, 10)
+      : roomCapturesLib.totalSqFt(roomCaptures);
+
     const ins = await client.query(
       `INSERT INTO bookings
          (installer_id, customer_name, customer_email, customer_phone,
@@ -189,18 +199,18 @@ router.post('/installers/:slug/book', async (req, res, next) => {
           surfaces, rooms, square_feet, roll_count_estimate, budget_band,
           ceiling_height_ft, surface_state, access_constraints,
           address_line1, address_line2, city, state, zip,
-          customer_role, product_sourced, consumer_account_id,
+          customer_role, product_sourced, consumer_account_id, room_captures,
           scheduled_start, scheduled_end, customer_notes, status, source)
-       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,'pending','web')
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26::jsonb,$27,$28,$29,'pending','web')
        RETURNING id, uuid`,
       [
         installer.id,
         f.customer_name, f.customer_email, f.customer_phone || null,
         f.project_type || 'consultation', f.market_segment || null, f.material || null, f.brand || null, brandSku,
-        f.surfaces || null, f.rooms || null, f.square_feet ? parseInt(f.square_feet,10) : null, rollCountEst, f.budget_band || null,
+        f.surfaces || null, f.rooms || null, squareFeet, rollCountEst, f.budget_band || null,
         ceilingHeightFt, surfaceState, accessCon,
         f.address_line1 || null, f.address_line2 || null, f.city || null, (f.state || '').toUpperCase() || null, f.zip || null,
-        customerRole, productSourced, consumerAccountId,
+        customerRole, productSourced, consumerAccountId, JSON.stringify(roomCaptures),
         start.toISOString(), end.toISOString(), f.customer_notes || null
       ]
     );
@@ -222,6 +232,7 @@ router.post('/installers/:slug/book', async (req, res, next) => {
     ...req.body, id: booked.id, uuid: booked.uuid,
     scheduled_start: new Date(req.body.scheduled_start),
     scheduled_end: new Date(req.body.scheduled_end),
+    room_captures: roomCaptures,   // sanitized array, not the raw posted string
     view_token: sig
   };
 

← d2f12ee Add camera capture & measure step to the /book wizard  ·  back to NationalPaperHangers  ·  Surface room captures in the installer brief — admin page + a1dfc05 →