← back to Designer Wallcoverings

mailers/tools/pj-asset-swap.mjs

172 lines

#!/usr/bin/env node
/**
 * pj-asset-swap.mjs — TK-10899
 *
 * Re-hosts the 5 vendor-CDN images in the Phillip Jeffries Fall 2026 mailer onto the
 * DW Shopify Files CDN (the house pattern every other DW mailer already uses), then
 * rewrites the mailer's <img src> to the new cdn.shopify.com URLs.
 *
 * WHY: hotlinking a vendor CDN in an email is a standing liability — the vendor can
 * rotate or expire the object at any time and every ALREADY-DELIVERED email breaks
 * retroactively (there is no way to fix a mail that is already in an inbox). It also
 * leaks recipient opens/IPs to the vendor. All 5 local copies are byte-identical
 * SHA-256 to the live vendor objects, so this is a lossless 1:1 substitution.
 *
 * SAFETY MODEL:
 *   - DRY-RUN BY DEFAULT. Without --apply it performs ZERO writes: it verifies local
 *     files, re-checks SHA parity against the vendor CDN, and prints the exact swap map.
 *   - --apply is a Shopify write and is STEVE-GATED. Do not run it without approval.
 *   - Every --apply writes a restore map to tools/pj-asset-swap-restore.json BEFORE
 *     touching the HTML, so rollback is mechanical (see --rollback).
 *   - --rollback restores the original vendor-CDN src values from the restore map and
 *     prints the fileDelete GIDs (file deletion stays a deliberate, separate action).
 */
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const MAILER = path.join(ROOT, 'phillip-jeffries-fall-2026-launch.html');
const ASSETS = path.join(ROOT, 'assets', 'pj-fall-2026');
const RESTORE = path.join(__dirname, 'pj-asset-swap-restore.json');

// vendor CDN url  ->  byte-identical local copy
const MAP = [
  ['https://api.phillipjeffries.com/images/product_lines/WN-SC_395_355x518.jpg', 'pj-coco-weave.jpg'],
  ['https://api.phillipjeffries.com/images/product_lines/WN-SC_435_355x518.jpg', 'pj-plush-pillars.jpg'],
  ['https://api.phillipjeffries.com/images/product_lines/WN-SC_454_355x518.jpg', 'pj-cirque-de-soho.jpg'],
  ['https://api.phillipjeffries.com/images/product_lines/WN-SC_504_355x518.jpg', 'pj-idyllic.jpg'],
  ['https://cdn2.webdamdb.com/1280_EU2NBNq1Wno66UmS.jpg?1787686038',            'pj-hero-idyllic-room.jpg'],
];

const args = new Set(process.argv.slice(2));
const APPLY = args.has('--apply');
const ROLLBACK = args.has('--rollback');
const SHOP = process.env.SHOPIFY_STORE_DOMAIN || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_FULL_ACCESS_TOKEN; // narrow ADMIN token lacks write_files
const API = `https://${SHOP}/admin/api/2024-10/graphql.json`;

const sha = b => crypto.createHash('sha256').update(b).digest('hex');
const log = (...a) => console.log(...a);

async function gql(query, variables = {}) {
  const r = await fetch(API, {
    method: 'POST',
    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });
  const j = await r.json();
  if (j.errors) throw new Error('GraphQL: ' + JSON.stringify(j.errors).slice(0, 400));
  return j.data;
}

async function rollback() {
  if (!fs.existsSync(RESTORE)) return log('No restore map at', RESTORE, '— nothing to roll back.');
  const rec = JSON.parse(fs.readFileSync(RESTORE, 'utf8'));
  let html = fs.readFileSync(MAILER, 'utf8');
  let n = 0;
  for (const e of rec.swaps) {
    if (html.includes(e.newUrl)) { html = html.split(e.newUrl).join(e.originalUrl); n++; }
  }
  fs.writeFileSync(MAILER, html);
  log(`Restored ${n}/${rec.swaps.length} src values to the original vendor CDN URLs.`);
  log('Uploaded Shopify file GIDs (delete deliberately, separately if desired):');
  rec.swaps.forEach(e => log('  ', e.gid, e.newUrl));
  log('Also: `git checkout -- mailers/phillip-jeffries-fall-2026-launch.html` fully reverts the HTML.');
}

async function main() {
  if (ROLLBACK) return rollback();

  if (!fs.existsSync(MAILER)) throw new Error('Mailer not found: ' + MAILER);
  let html = fs.readFileSync(MAILER, 'utf8');
  log(`Mailer: ${MAILER}\n  bytes=${html.length} sha=${sha(Buffer.from(html)).slice(0,12)}\n`);

  // --- Preflight: local file exists, is in the HTML, and matches the vendor object ---
  const plan = [];
  for (const [url, file] of MAP) {
    const p = path.join(ASSETS, file);
    if (!fs.existsSync(p)) throw new Error('Missing local asset: ' + p);
    const local = fs.readFileSync(p);
    if (!html.includes(url)) throw new Error('src not present in mailer: ' + url);
    let parity = 'SKIPPED';
    try {
      const r = await fetch(url, { signal: AbortSignal.timeout(30000) });
      const remote = Buffer.from(await r.arrayBuffer());
      parity = (sha(remote) === sha(local)) ? 'IDENTICAL' : `MISMATCH(remote ${sha(remote).slice(0,8)})`;
    } catch { parity = 'UNREACHABLE(using local)'; }
    plan.push({ url, file, p, bytes: local.length, sha: sha(local).slice(0, 12), parity });
    log(`  ${parity.padEnd(24)} ${file.padEnd(26)} ${local.length} B  <- ${url}`);
  }
  if (plan.some(x => x.parity.startsWith('MISMATCH')))
    throw new Error('Aborting: a local copy no longer matches the vendor object. Re-download before swapping.');

  if (!APPLY) {
    log('\nDRY RUN — no writes performed. 0 network writes, 0 file changes.');
    log('This is a Shopify write and is STEVE-GATED. To execute after approval:');
    log('  SHOPIFY_FULL_ACCESS_TOKEN=... node mailers/tools/pj-asset-swap.mjs --apply');
    log('Rollback after apply:  node mailers/tools/pj-asset-swap.mjs --rollback');
    return;
  }

  if (!TOKEN) throw new Error('SHOPIFY_FULL_ACCESS_TOKEN not set (the narrow ADMIN token lacks write_files).');
  const swaps = [];

  for (const item of plan) {
    log(`\nUploading ${item.file} ...`);
    // 1) staged target
    const st = await gql(`mutation($input:[StagedUploadInput!]!){ stagedUploadsCreate(input:$input){
      stagedTargets{ url resourceUrl parameters{ name value } } userErrors{ field message } } }`,
      { input: [{ filename: item.file, mimeType: 'image/jpeg', resource: 'FILE', httpMethod: 'POST' }] });
    if (st.stagedUploadsCreate.userErrors?.length) throw new Error(JSON.stringify(st.stagedUploadsCreate.userErrors));
    const t = st.stagedUploadsCreate.stagedTargets[0];

    // 2) POST bytes to the staged target
    const form = new FormData();
    for (const p of t.parameters) form.append(p.name, p.value);
    form.append('file', new Blob([fs.readFileSync(item.p)], { type: 'image/jpeg' }), item.file);
    const up = await fetch(t.url, { method: 'POST', body: form });
    if (!up.ok) throw new Error(`staged upload failed ${up.status}: ${(await up.text()).slice(0,300)}`);

    // 3) register the file
    const fc = await gql(`mutation($files:[FileCreateInput!]!){ fileCreate(files:$files){
      files{ id fileStatus ... on MediaImage { image { url } } } userErrors{ field message } } }`,
      { files: [{ originalSource: t.resourceUrl, contentType: 'IMAGE', alt: `Phillip Jeffries Fall 2026 — ${item.file}` }] });
    if (fc.fileCreate.userErrors?.length) throw new Error(JSON.stringify(fc.fileCreate.userErrors));
    const gid = fc.fileCreate.files[0].id;

    // 4) fileCreate returns UPLOADED, not READY — the CDN url is only populated after
    //    processing. Poll, or we would write an empty src="" into the mailer.
    let cdn = null;
    for (let i = 0; i < 30 && !cdn; i++) {
      await new Promise(r => setTimeout(r, 2000));
      const q = await gql(`query($id:ID!){ node(id:$id){ ... on MediaImage { fileStatus image { url } } } }`, { id: gid });
      if (q.node?.fileStatus === 'READY' && q.node.image?.url) cdn = q.node.image.url;
      if (q.node?.fileStatus === 'FAILED') throw new Error('Shopify reported FAILED for ' + item.file);
    }
    if (!cdn) throw new Error('Timed out waiting for READY: ' + item.file);
    // Shopify Files RE-ENCODES uploads at quality=85 (measured PSNR ~30dB vs the original,
    // worst on woven/high-frequency textures - i.e. exactly what this line sells). ?quality=100
    // serves the pixel-exact original bytes back, which is what makes this a lossless swap.
    cdn = cdn + (cdn.includes('?') ? '&' : '?') + 'quality=100';
    log(`  READY -> ${cdn}`);
    swaps.push({ originalUrl: item.url, file: item.file, gid, newUrl: cdn, sha: item.sha });
  }

  // restore map BEFORE mutating the HTML
  fs.writeFileSync(RESTORE, JSON.stringify({ ticket: 'TK-10899', at: new Date().toISOString(), mailer: MAILER, swaps }, null, 2));
  log('\nRestore map written ->', RESTORE);

  for (const s of swaps) html = html.split(s.originalUrl).join(s.newUrl);
  fs.writeFileSync(MAILER, html);
  const left = MAP.filter(([u]) => html.includes(u));
  log(`Mailer rewritten. Remaining vendor-CDN hotlinks: ${left.length} (expected 0).`);
  if (left.length) throw new Error('Swap incomplete: ' + left.map(x => x[0]).join(', '));
  log('\nNOTE: this updates the LOCAL mailer only. Pushing the swapped HTML into the staged');
  log('Constant Contact draft (54105fd3-...) is a separate, separately-gated step.');
}

main().catch(e => { console.error('\nFAILED:', e.message); process.exit(1); });