[object Object]

← back to Designer Wallcoverings

TK-10899: add gated, reversible PJ Fall 2026 mailer asset-swap tool

a6000b9bb24c4c2ecbfa917aab552daefc3fb963 · 2026-09-10 07:36:36 -0700 · Steve

Re-hosts the 5 vendor-CDN hotlinks (4 api.phillipjeffries.com + 1 cdn2.webdamdb.com)
onto the DW Shopify Files CDN - the pattern every other DW mailer already uses. All 5
local copies verified byte-identical SHA-256 to the live vendor objects, so the swap is
a lossless 1:1 substitution.

Dry-run by default (zero writes). --apply is a Shopify write and stays Steve-gated; it
writes a restore map before touching the HTML, and --rollback reverses the src values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCFwJYZFtrKWTt2wALsWzv

Files touched

Diff

commit a6000b9bb24c4c2ecbfa917aab552daefc3fb963
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Sep 10 07:36:36 2026 -0700

    TK-10899: add gated, reversible PJ Fall 2026 mailer asset-swap tool
    
    Re-hosts the 5 vendor-CDN hotlinks (4 api.phillipjeffries.com + 1 cdn2.webdamdb.com)
    onto the DW Shopify Files CDN - the pattern every other DW mailer already uses. All 5
    local copies verified byte-identical SHA-256 to the live vendor objects, so the swap is
    a lossless 1:1 substitution.
    
    Dry-run by default (zero writes). --apply is a Shopify write and stays Steve-gated; it
    writes a restore map before touching the HTML, and --rollback reverses the src values.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01TCFwJYZFtrKWTt2wALsWzv
---
 mailers/tools/pj-asset-swap.mjs | 167 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 167 insertions(+)

diff --git a/mailers/tools/pj-asset-swap.mjs b/mailers/tools/pj-asset-swap.mjs
new file mode 100644
index 00000000..5196ac1b
--- /dev/null
+++ b/mailers/tools/pj-asset-swap.mjs
@@ -0,0 +1,167 @@
+#!/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);
+    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); });

← 43e03dce auto-data-snapshot: 2026-09-09T22:50:09 (2 data files) — scr  ·  back to Designer Wallcoverings  ·  TK-10899: add in-place PJ Fall 2026 CC draft updater (dry-ru 6197515e →