← 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 →