[object Object]

← back to Wallco Ai

rooms: unified dual-path resolver (resolveRoomUrl/resolveDesignRooms) across ALL room surfaces — /api/rooms+gallery, PDP See-in-a-room figure+toggle, tearsheet, brief. Restores ~1,217 legacy /uploads designs regressed 2026-05-29; survives snapshot rebuilds; file-verified so no black/orphan tiles. Closes PARKED #1+#1b

39851c69c92f2eec448b2faf5f0a564f34e730d1 · 2026-05-30 08:18:00 -0700 · Steve Abrams

Files touched

Diff

commit 39851c69c92f2eec448b2faf5f0a564f34e730d1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 30 08:18:00 2026 -0700

    rooms: unified dual-path resolver (resolveRoomUrl/resolveDesignRooms) across ALL room surfaces — /api/rooms+gallery, PDP See-in-a-room figure+toggle, tearsheet, brief. Restores ~1,217 legacy /uploads designs regressed 2026-05-29; survives snapshot rebuilds; file-verified so no black/orphan tiles. Closes PARKED #1+#1b
---
 server.js | 140 ++++++++++++++++++++++++++++++++++++++++++--------------------
 1 file changed, 95 insertions(+), 45 deletions(-)

diff --git a/server.js b/server.js
index 17e4c92..ffd847d 100644
--- a/server.js
+++ b/server.js
@@ -183,6 +183,52 @@ app.use('/designs/room', express.static(path.join(__dirname, 'data', 'rooms'), {
   setHeaders: (res) => res.setHeader('Cache-Control', 'public, max-age=604800')
 }));
 
+// ── Dual-path room URL resolver (2026-05-30) ──────────────────────────────
+// A room render lives in ONE of two places: the canonical
+// data/rooms/design_<id>_<type>.png (served at /designs/room) OR the legacy
+// upload at the DB image_path (/uploads/rooms/room_<id>_<type>_<ts>.png, served
+// at /uploads). Earlier room surfaces checked only ONE of these, so ~1,217
+// legacy designs whose rooms live under /uploads silently lost their room
+// previews. resolveRoomUrl returns a SERVABLE url (or null) for a single room.
+// resolveDesignRooms returns the design's visible rooms (deduped by type,
+// dual-path) straight from the durable wallco_rooms table — used by every
+// room surface (PDP gallery via /api/rooms, PDP "See in a room" figure,
+// tearsheet, brief) so none of them can drift from the others again.
+const _DATA_ROOMS_DIR = path.join(__dirname, 'data', 'rooms');
+const _PUBLIC_DIR = path.join(__dirname, 'public');
+function resolveRoomUrl(designId, roomType, imagePath) {
+  try {
+    if (fs.existsSync(path.join(_DATA_ROOMS_DIR, `design_${designId}_${roomType}.png`)))
+      return `/designs/room/design_${designId}_${roomType}.png`;
+  } catch {}
+  try {
+    if (imagePath) {
+      const rel = String(imagePath).replace(/^\/+/, '');
+      if (fs.existsSync(path.join(_PUBLIC_DIR, rel))) return imagePath;
+    }
+  } catch {}
+  return null;
+}
+function resolveDesignRooms(designId) {
+  const id = parseInt(designId, 10);
+  if (!Number.isInteger(id)) return [];
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(json_build_object(
+        'room_type', room_type, 'image_path', image_path) ORDER BY created_at DESC), '[]')
+      FROM wallco_rooms WHERE design_id=${id} AND removed_at IS NULL`);
+    const rows = JSON.parse(raw || '[]');
+    const seen = new Set(); const out = [];
+    for (const r of rows) {
+      if (seen.has(r.room_type)) continue;
+      const url = resolveRoomUrl(id, r.room_type, r.image_path);
+      if (!url) continue;
+      seen.add(r.room_type);
+      out.push({ room_type: r.room_type, url });
+    }
+    return out;
+  } catch { return []; }
+}
+
 // ── Serve baked colorway og:image JPGs (one per saved colorway id).
 // Files live at data/og-cw/cw_<cwid>.jpg, baked on-demand by the
 // /design/:id?cw=<N> route via scripts/bake-og-cw.py. Long-lived cache —
@@ -11552,24 +11598,16 @@ app.get('/design/:id', (req, res) => {
     { id: 'commercial',  label: 'Commercial',  candidates: ['office','boardroom','retail'] },
     { id: 'hospitality', label: 'Hospitality', candidates: ['hotel_lobby','restaurant','hotel_corridor'] }
   ];
-  // Durable room source (2026-05-29): design.room_mockups comes from the
-  // designs.json snapshot, which resets to [] after a snapshot rebuild even
-  // when wallco_rooms holds live renders (the room PNGs serve fine, but the PDP
-  // room section was gated on the stale snapshot field — rooms silently vanished
-  // from the customer product page). Fall back to the authoritative wallco_rooms
-  // table, keeping only room_types whose canonical /designs/room PNG exists on
-  // disk so we never render a broken img.
-  if (!(design.room_mockups || []).length) {
-    try {
-      const _rr = psqlQuery(`SELECT COALESCE(json_agg(DISTINCT room_type),'[]') FROM wallco_rooms
-        WHERE design_id=${id} AND removed_at IS NULL`);
-      const _roomsDir = path.join(__dirname, 'data', 'rooms');
-      const _types = JSON.parse(_rr || '[]').filter(rt =>
-        fs.existsSync(path.join(_roomsDir, `design_${id}_${rt}.png`)));
-      if (_types.length) design.room_mockups = _types;
-    } catch { /* PG hiccup — leave the snapshot value as-is */ }
-  }
-  const haveRooms = new Set(design.room_mockups || []);
+  // Durable + dual-path room resolution (2026-05-30): resolve this design's
+  // visible rooms straight from the authoritative wallco_rooms table
+  // (file-verified, dual-path — data/rooms canonical OR legacy /uploads
+  // image_path), independent of the designs.json snapshot (which resets to []
+  // on rebuild). roomUrlMap gives the "See in a room" figure a SERVABLE url per
+  // room_type — fixes legacy /uploads designs that the hardcoded /designs/room
+  // path 404'd, and survives snapshot rebuilds.
+  const _resolvedRooms = resolveDesignRooms(id);
+  const roomUrlMap = Object.fromEntries(_resolvedRooms.map(r => [r.room_type, r.url]));
+  const haveRooms = new Set(_resolvedRooms.map(r => r.room_type));
   const roomCtx = ROOM_CONTEXTS
     .map(c => { const room = c.candidates.find(r => haveRooms.has(r)); return room ? { id: c.id, label: c.label, room } : null; })
     .filter(Boolean);
@@ -15765,7 +15803,7 @@ ${(() => {
         </div>
         <figure style="margin:0;border-radius:10px;overflow:hidden;background:rgba(0,0,0,.04)">
           <img id="room-toggle-img"
-               src="/designs/room/design_${design.id}_${roomCtx[0].room}.png"
+               src="${roomUrlMap[roomCtx[0].room] || ('/designs/room/design_' + design.id + '_' + roomCtx[0].room + '.png')}"
                alt="${design.title} — ${roomCtx[0].label.toLowerCase()}"
                loading="lazy"
                style="display:block;width:100%;height:auto;border-radius:10px 10px 0 0">
@@ -15778,6 +15816,7 @@ ${(() => {
           var cap   = document.getElementById('room-toggle-cap');
           var did   = ${design.id};
           var labels = ${JSON.stringify(Object.fromEntries(roomCtx.map(c => [c.room, c.label])))};
+          var urls   = ${JSON.stringify(roomUrlMap)};
           pills.forEach(function(p){
             p.addEventListener('click', function(){
               pills.forEach(function(x){
@@ -15791,7 +15830,7 @@ ${(() => {
               p.style.background = '#1a1816';
               p.style.color = '#fff';
               var room = p.dataset.room;
-              img.src = '/designs/room/design_' + did + '_' + room + '.png';
+              img.src = urls[room] || ('/designs/room/design_' + did + '_' + room + '.png');
               img.alt = ${JSON.stringify(design.title)} + ' — ' + (labels[room] || '').toLowerCase();
               cap.textContent = (labels[room]||'') + ' · ' + room.replace(/_/g,' ') + ' — Curated rendering';
             });
@@ -17550,8 +17589,8 @@ ${FOOTER}
           '</div>'
         : '';
       return '<figure style="margin:0;background:var(--paper,#faf8f2);border:1px solid var(--rule,#e5e5e0);position:relative;' + dim + '">' +
-        '<a href="/designs/room/design_' + r.design_id + '_' + r.room_type + '.png" target="_blank" style="display:block;aspect-ratio:1/1;overflow:hidden;background:#000;" rel="noopener noreferrer">' +
-          '<img src="/designs/room/design_' + r.design_id + '_' + r.room_type + '.png" alt="Room with this pattern — ' + tag + '" loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block;background:#1a1816;" />' +
+        '<a href="' + (r.display_url || ('/designs/room/design_' + r.design_id + '_' + r.room_type + '.png')) + '" target="_blank" style="display:block;aspect-ratio:1/1;overflow:hidden;background:#000;" rel="noopener noreferrer">' +
+          '<img src="' + (r.display_url || ('/designs/room/design_' + r.design_id + '_' + r.room_type + '.png')) + '" alt="Room with this pattern — ' + tag + '" loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block;background:#1a1816;" />' +
         '</a>' +
         adminCtrls +
         '<figcaption style="padding:8px 10px 10px;font:11px/1.4 ui-sans-serif,system-ui,sans-serif;color:var(--ink-faint,#6e6e68);display:flex;justify-content:space-between;gap:8px;">' +
@@ -17644,11 +17683,15 @@ app.get('/design/:id/tearsheet', (req, res) => {
   if (!Number.isFinite(id) || id < 1) return res.status(404).send('Not found');
   let design = DESIGNS.find(d => d.id === id);
   if (!design) return res.status(404).send('Design not found');
-  const roomKey = (design.room_mockups || []).includes('living_room') ? 'living_room'
-                : (design.room_mockups || []).includes('bedroom') ? 'bedroom'
-                : (design.room_mockups || []).includes('office')  ? 'office'
-                : (design.room_mockups || [])[0];
-  const roomImg = roomKey ? `/designs/room/design_${design.id}_${roomKey}.png` : null;
+  // Dual-path room resolve (2026-05-30) — was snapshot-only + hardcoded
+  // /designs/room path → lost the in-room preview on legacy designs and after
+  // snapshot rebuilds. resolveDesignRooms is file-verified + dual-path.
+  const _tsRooms = resolveDesignRooms(design.id);
+  const _tsRoom = _tsRooms.find(r => r.room_type === 'living_room')
+               || _tsRooms.find(r => r.room_type === 'bedroom')
+               || _tsRooms.find(r => r.room_type === 'office')
+               || _tsRooms[0];
+  const roomImg = _tsRoom ? _tsRoom.url : null;
   const motifs = (design.motifs || []).slice(0, 6).join(' · ');
   const created = design.created_at ? new Date(design.created_at).toISOString().slice(0,10) : '';
   const designUrl = `https://wallco.ai/design/${design.id}`;
@@ -18178,11 +18221,13 @@ app.get('/brief', (req, res) => {
   res.setHeader('Cache-Control', 'public, max-age=3600, must-revalidate');
   const today = new Date().toISOString().slice(0, 10);
   const tearsheet = (d) => {
-    const roomKey = (d.room_mockups || []).includes('living_room') ? 'living_room'
-                  : (d.room_mockups || []).includes('bedroom') ? 'bedroom'
-                  : (d.room_mockups || []).includes('office')  ? 'office'
-                  : (d.room_mockups || [])[0];
-    const roomImg = roomKey ? `/designs/room/design_${d.id}_${roomKey}.png` : null;
+    // Dual-path room resolve (2026-05-30) — see tearsheet note above.
+    const _brRooms = resolveDesignRooms(d.id);
+    const _brRoom = _brRooms.find(r => r.room_type === 'living_room')
+                 || _brRooms.find(r => r.room_type === 'bedroom')
+                 || _brRooms.find(r => r.room_type === 'office')
+                 || _brRooms[0];
+    const roomImg = _brRoom ? _brRoom.url : null;
     const motifs = (d.motifs || []).slice(0, 6).join(' · ');
     return `<section class="brief-page" style="page-break-after:always;padding:36px 28px;border-bottom:1px dashed #ccc">
       <div style="display:flex;justify-content:space-between;align-items:baseline;border-bottom:1px solid #1a1a1a;padding-bottom:8px;margin-bottom:16px">
@@ -19614,21 +19659,26 @@ app.get('/api/rooms', (req, res) => {
                    selected_for_marketing, removed_at, removed_reason, notes, created_at
               FROM wallco_rooms ${whereSQL}) t;`);
     let rooms = JSON.parse(raw || '[]');
-    // Customer-facing cleanup (2026-05-29): wallco_rooms can hold orphan rows
-    // (a room_type whose rendered PNG no longer exists on disk) and duplicate
-    // rows per type. For non-admin viewers, dedup by room_type (newest wins —
-    // rows are ordered created_at DESC) and drop any room whose canonical
-    // /designs/room PNG is missing, so the PDP "Generated rooms" gallery never
-    // shows black/broken tiles. Admins still see every row (to manage them).
-    if (!isRoomsAdmin(req)) {
-      const _roomsDir = path.join(__dirname, 'data', 'rooms');
+    // Customer-facing cleanup (2026-05-30): dual-path resolve. wallco_rooms can
+    // hold orphan rows (a room_type whose PNG exists in NEITHER location) and
+    // duplicate rows per type. For non-admin viewers, dedup by room_type (newest
+    // wins — rows ordered created_at DESC) and keep a room only if it resolves to
+    // a SERVABLE url (data/rooms canonical OR legacy /uploads image_path), then
+    // attach display_url for the gallery to use. Fixes the 2026-05-29 regression
+    // that dropped ~1,217 legacy designs by checking data/rooms only. Admins
+    // still see every row (to manage them) but also get display_url when present.
+    {
       const _seen = new Set();
-      rooms = rooms.filter(r => {
-        if (_seen.has(r.room_type)) return false;
-        if (!fs.existsSync(path.join(_roomsDir, `design_${r.design_id}_${r.room_type}.png`))) return false;
+      const _admin = isRoomsAdmin(req);
+      rooms = rooms.reduce((acc, r) => {
+        const url = resolveRoomUrl(r.design_id, r.room_type, r.image_path);
+        if (_admin) { r.display_url = url || r.image_path; acc.push(r); return acc; }
+        if (_seen.has(r.room_type) || !url) return acc;
         _seen.add(r.room_type);
-        return true;
-      });
+        r.display_url = url;
+        acc.push(r);
+        return acc;
+      }, []);
     }
     res.json({ ok: true, rooms, admin: isRoomsAdmin(req) });
   } catch (e) {

← 19b104a fleet audit 2026-05-30 — toggle + sort/density backlog (DTD  ·  back to Wallco Ai  ·  refresh_designs_snapshot: add byte-size guard (refuse <85% o 12ce9a9 →