← back to Interiordesignershowroom

scripts/reconcile-dead-images.js

93 lines

// TK-10390 reconciliation — find (and optionally NULL) rooms.scene_image /
// guides.hero_image values that point to a local file which does NOT exist, so the
// affected pages fall back cleanly instead of emitting a dead <img> src (and a dead
// image in the OG / Article JSON-LD tags). Complementary to the client-side
// graceful-degrade (commit 63069ec): the degrade HIDES broken images in the browser;
// this removes the dead reference server-side (kills the 404 request + fixes OG/SEO).
//
// This is the INTERIM cleanup path. The PROPER fix is regenerating + persisting the
// missing scene PNGs (lib/roomgen, claude-email's auto-room lane) — NOT this script.
//
// SAFE BY DEFAULT: dry-run (read-only) unless --apply is passed. --apply is a GATED
// prod DB write; it snapshots every old value to a timestamped JSON first (reversible)
// and runs inside a single transaction (rollback on any error).
//
//   node scripts/reconcile-dead-images.js            # dry-run: report only ($0 local)
//   node scripts/reconcile-dead-images.js --apply     # GATED: NULL dead paths (snapshot-first)
//
// External (http/https) image URLs are left untouched — only our own local
// /img/... paths are checked against the filesystem.
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const db = require('../lib/db');

const APPLY = process.argv.includes('--apply');
const PUBLIC = path.join(__dirname, '..', 'public');

const isExternal = (v) => /^https?:\/\//i.test(v);
const fileMissing = (v) => !isExternal(v) && !fs.existsSync(path.join(PUBLIC, v.replace(/^\//, '')));

async function scan(table, idCol, imgCol) {
  const { rows } = await db.query(
    `SELECT ${idCol} AS id, ${imgCol} AS img FROM ${table} WHERE ${imgCol} IS NOT NULL`);
  const dead = rows.filter((r) => fileMissing(r.img));
  return { table, idCol, imgCol, total: rows.length, dead };
}

(async () => {
  const targets = [
    await scan('rooms', 'slug', 'scene_image'),
    await scan('guides', 'slug', 'hero_image'),
  ];

  let deadTotal = 0;
  for (const t of targets) {
    deadTotal += t.dead.length;
    console.log(`\n[${t.table}.${t.imgCol}] with-image=${t.total} dead-file=${t.dead.length}`);
    t.dead.slice(0, 12).forEach((r) => console.log(`   ${t.idCol}=${r.id}  img=${r.img}`));
    if (t.dead.length > 12) console.log(`   … +${t.dead.length - 12} more`);
  }

  console.log(`\nTOTAL dead-file image references: ${deadTotal}   cost: $0 (local DB)`);

  if (!deadTotal) { console.log('Nothing to reconcile — clean.'); process.exit(0); }
  if (!APPLY) {
    console.log('\nDRY-RUN (no writes). Re-run with --apply to NULL these (snapshot-first, GATED).');
    process.exit(0);
  }

  // --- GATED apply path: snapshot-first, single transaction ---
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
  const snapDir = path.join(__dirname, '..', 'tmp');
  fs.mkdirSync(snapDir, { recursive: true });
  const snapFile = path.join(snapDir, `reconcile-dead-images-${stamp}.json`);
  const snapshot = targets.flatMap((t) =>
    t.dead.map((r) => ({ table: t.table, idCol: t.idCol, id: r.id, col: t.imgCol, old: r.img })));
  fs.writeFileSync(snapFile, JSON.stringify(snapshot, null, 2));
  console.log(`\nsnapshot -> ${snapFile} (${snapshot.length} rows) — restore with the SQL printed at the end`);

  const client = await db.pool.connect();
  try {
    await client.query('BEGIN');
    let n = 0;
    for (const t of targets) {
      for (const r of t.dead) {
        const res = await client.query(
          `UPDATE ${t.table} SET ${t.imgCol}=NULL WHERE ${t.idCol}=$1 AND ${t.imgCol}=$2`, [r.id, r.img]);
        n += res.rowCount;
      }
    }
    await client.query('COMMIT');
    console.log(`APPLIED: NULLed ${n} dead image references.`);
    console.log(`Reversible: for each snapshot row -> UPDATE <table> SET <col>='<old>' WHERE <idCol>='<id>';`);
  } catch (e) {
    await client.query('ROLLBACK');
    console.error('ROLLED BACK —', e.message);
    process.exitCode = 1;
  } finally {
    client.release();
  }
  process.exit(process.exitCode || 0);
})().catch((e) => { console.error('ERR', e.message); process.exit(1); });