[object Object]

← back to Philipperomano

TK-10869: PhilRomano sample-add script + rollback (Steve-approved 2026-08-26)

b36ef94a57d5d3ae18c69f773bf94de3c54ba080 · 2026-08-28 04:16:29 -0700 · Steve

Files touched

Diff

commit b36ef94a57d5d3ae18c69f773bf94de3c54ba080
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 28 04:16:29 2026 -0700

    TK-10869: PhilRomano sample-add script + rollback (Steve-approved 2026-08-26)
---
 scripts/sample-add-rollback.mjs | 120 ++++++++++++
 scripts/sample-add.mjs          | 393 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 513 insertions(+)

diff --git a/scripts/sample-add-rollback.mjs b/scripts/sample-add-rollback.mjs
new file mode 100644
index 0000000..3a5c8a2
--- /dev/null
+++ b/scripts/sample-add-rollback.mjs
@@ -0,0 +1,120 @@
+/**
+ * PhilRomano Sample-Add ROLLBACK Script
+ * TK-10869 — reverses sample-add.mjs via restore-map
+ *
+ * Usage:
+ *   node sample-add-rollback.mjs --restore <restore-map.json>
+ *   DRY_RUN=true node sample-add-rollback.mjs
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
+  const envPath = '/Users/macstudio3/Projects/secrets-manager/.env';
+  if (fs.existsSync(envPath)) {
+    const lines = fs.readFileSync(envPath, 'utf8').split('\n');
+    for (const l of lines) {
+      const m = l.match(/^SHOPIFY_ADMIN_TOKEN=(.+)/);
+      if (m) return m[1].trim();
+    }
+  }
+  throw new Error('SHOPIFY_ADMIN_TOKEN not found');
+})();
+
+const DRY_RUN = process.env.DRY_RUN === 'true';
+
+// Find restore map path from args
+const args = process.argv.slice(2);
+const restoreIdx = args.indexOf('--restore');
+const RESTORE_MAP_PATH = restoreIdx >= 0
+  ? args[restoreIdx + 1]
+  : path.join(__dirname, '../data/sample-add-restore-map.json');
+
+async function shopifyPut(path, body) {
+  const url = `https://${SHOP}/admin/api/2024-10/${path}`;
+  const resp = await fetch(url, {
+    method: 'PUT',
+    headers: {
+      'X-Shopify-Access-Token': TOKEN,
+      'Content-Type': 'application/json'
+    },
+    body: JSON.stringify(body)
+  });
+  if (!resp.ok) {
+    const text = await resp.text();
+    throw new Error(`PUT ${path} → ${resp.status}: ${text.slice(0, 200)}`);
+  }
+  return resp.json();
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+async function main() {
+  console.log('=== PhilRomano Sample-Add ROLLBACK TK-10869 ===');
+  console.log(`DRY_RUN: ${DRY_RUN}`);
+  console.log(`Restore map: ${RESTORE_MAP_PATH}`);
+
+  if (!fs.existsSync(RESTORE_MAP_PATH)) {
+    console.error('ERROR: Restore map not found:', RESTORE_MAP_PATH);
+    process.exit(1);
+  }
+
+  const restoreMap = JSON.parse(fs.readFileSync(RESTORE_MAP_PATH, 'utf8'));
+  const entries = Object.values(restoreMap);
+  console.log(`\nRestoring ${entries.length} products...\n`);
+
+  let ok = 0, err = 0;
+
+  for (const entry of entries) {
+    const numId = entry.productId;
+
+    if (DRY_RUN) {
+      console.log(`DRY  ${numId} — ${entry.productTitle?.slice(0, 50)}`);
+      ok++;
+      continue;
+    }
+
+    try {
+      // Restore original options and variants
+      const restoreBody = {
+        product: {
+          id: numId,
+          options: entry.originalOptions.map(o => ({
+            name: o.name,
+            values: o.values
+          })),
+          variants: entry.originalVariants.map(v => ({
+            id: v.id,
+            option1: v.option1,
+            option2: v.option2,
+            price: v.price,
+            sku: v.sku,
+            inventory_management: v.inventory_management,
+            inventory_policy: v.inventory_policy
+          }))
+        }
+      };
+
+      await shopifyPut(`products/${numId}.json`, restoreBody);
+      console.log(`OK   ${numId} — ${entry.productTitle?.slice(0, 50)}`);
+      ok++;
+      await sleep(400);
+    } catch (e) {
+      console.error(`ERR  ${numId}: ${e.message}`);
+      err++;
+      await sleep(1000);
+    }
+  }
+
+  console.log(`\nRollback complete: OK=${ok} ERR=${err}`);
+}
+
+main().catch(e => {
+  console.error('FATAL:', e);
+  process.exit(1);
+});
diff --git a/scripts/sample-add.mjs b/scripts/sample-add.mjs
new file mode 100644
index 0000000..f0ba883
--- /dev/null
+++ b/scripts/sample-add.mjs
@@ -0,0 +1,393 @@
+/**
+ * PhilRomano Sample-Add Script
+ * TK-10869 — approved 2026-08-26 by Steve
+ *
+ * Adds $4.25 Sample variant to quote-only Phillipe Romano products that only
+ * have a "Default Title" variant at $0. Renames option "Title" → "Size" and
+ * renames "Default Title" → "Per Yard (Quote)" to clarify the roll option.
+ *
+ * HARD RAILS:
+ *  - do_not_price=TRUE: NO sell price written (roll stays $0)
+ *  - Sample only: $4.25 / -sample SKU suffix / no inventory tracking
+ *  - Batches of 50 with 90s gaps
+ *  - Saves restore-map BEFORE every write
+ *  - Skip idempotently if Sample already present
+ *
+ * Usage:
+ *   DRY_RUN=true   node sample-add.mjs          # dry run, no writes
+ *   CANARY=100     node sample-add.mjs           # canary: first 100 only
+ *   node sample-add.mjs                          # full run
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+// --- Config ---
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
+  // Load from secrets manager .env
+  const envPath = '/Users/macstudio3/Projects/secrets-manager/.env';
+  if (fs.existsSync(envPath)) {
+    const lines = fs.readFileSync(envPath, 'utf8').split('\n');
+    for (const l of lines) {
+      const m = l.match(/^SHOPIFY_ADMIN_TOKEN=(.+)/);
+      if (m) return m[1].trim();
+    }
+  }
+  throw new Error('SHOPIFY_ADMIN_TOKEN not found');
+})();
+
+const DRY_RUN = process.env.DRY_RUN === 'true';
+const CANARY_LIMIT = process.env.CANARY ? parseInt(process.env.CANARY) : Infinity;
+const BATCH_SIZE = 50;
+const BATCH_GAP_MS = 90000; // 90 seconds between batches
+
+// Restore map path
+const RESTORE_MAP_PATH = path.join(__dirname, '../data/sample-add-restore-map.json');
+const RESULTS_PATH = path.join(__dirname, '../data/sample-add-results.json');
+const LEDGER_PATH = '/Users/macstudio3/.claude/yolo-queue/executed-reversible/ledger.jsonl';
+
+// Ensure data dir exists
+fs.mkdirSync(path.join(__dirname, '../data'), { recursive: true });
+
+// --- Shopify REST helpers ---
+async function shopifyGet(path) {
+  const url = `https://${SHOP}/admin/api/2024-10/${path}`;
+  const resp = await fetch(url, {
+    headers: { 'X-Shopify-Access-Token': TOKEN }
+  });
+  if (!resp.ok) {
+    const body = await resp.text();
+    throw new Error(`GET ${path} → ${resp.status}: ${body.slice(0, 200)}`);
+  }
+  return resp.json();
+}
+
+async function shopifyPut(path, body) {
+  const url = `https://${SHOP}/admin/api/2024-10/${path}`;
+  const resp = await fetch(url, {
+    method: 'PUT',
+    headers: {
+      'X-Shopify-Access-Token': TOKEN,
+      'Content-Type': 'application/json'
+    },
+    body: JSON.stringify(body)
+  });
+  if (!resp.ok) {
+    const text = await resp.text();
+    throw new Error(`PUT ${path} → ${resp.status}: ${text.slice(0, 200)}`);
+  }
+  return resp.json();
+}
+
+async function shopifyPost(path, body) {
+  const url = `https://${SHOP}/admin/api/2024-10/${path}`;
+  const resp = await fetch(url, {
+    method: 'POST',
+    headers: {
+      'X-Shopify-Access-Token': TOKEN,
+      'Content-Type': 'application/json'
+    },
+    body: JSON.stringify(body)
+  });
+  if (!resp.ok) {
+    const text = await resp.text();
+    throw new Error(`POST ${path} → ${resp.status}: ${text.slice(0, 200)}`);
+  }
+  return resp.json();
+}
+
+function sleep(ms) {
+  return new Promise(r => setTimeout(r, ms));
+}
+
+// --- Fetch all quote-only PhilRomano products via REST pagination ---
+async function fetchQuoteOnlyProducts() {
+  const products = [];
+  let url = `https://${SHOP}/admin/api/2024-10/products.json?vendor=Phillipe+Romano&status=active&limit=250`;
+
+  console.log('Fetching Phillipe Romano active products from Shopify...');
+
+  while (url) {
+    const resp = await fetch(url, {
+      headers: { 'X-Shopify-Access-Token': TOKEN }
+    });
+
+    if (!resp.ok) throw new Error(`Products list → ${resp.status}`);
+
+    const data = await resp.json();
+    const batch = data.products || [];
+
+    // Filter: only quote-only (tags contains "quote-only") AND has Default Title variant
+    for (const p of batch) {
+      const tags = (p.tags || '').toLowerCase();
+      const isQuoteOnly = tags.includes('quote-only');
+      if (!isQuoteOnly) continue;
+
+      const variants = p.variants || [];
+      // Check if already has a Sample variant
+      const hasSample = variants.some(v =>
+        v.title.toLowerCase() === 'sample' ||
+        (v.sku || '').toLowerCase().endsWith('-sample') ||
+        v.price === '4.25'
+      );
+
+      if (hasSample) continue; // Already done, skip
+
+      // Check it's a Default Title product
+      const hasDefaultTitle = variants.some(v => v.title === 'Default Title');
+      if (!hasDefaultTitle) continue;
+
+      products.push(p);
+    }
+
+    // Get next page from Link header
+    const linkHeader = resp.headers.get('link');
+    url = null;
+    if (linkHeader) {
+      const nextMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
+      if (nextMatch) url = nextMatch[1];
+    }
+
+    console.log(`  Fetched ${products.length} qualifying so far, page processed (${batch.length} products)...`);
+    if (url) await sleep(500); // polite gap between pages
+  }
+
+  return products;
+}
+
+// --- Process a single product ---
+async function processProduct(product, restoreMap, dryRun) {
+  const id = product.id;
+  const variants = product.variants || [];
+  const options = product.options || [];
+
+  // Find the Default Title variant
+  const defaultVariant = variants.find(v => v.title === 'Default Title');
+  if (!defaultVariant) {
+    return { skipped: true, reason: 'no Default Title variant' };
+  }
+
+  // Derive sample SKU from base SKU
+  const baseSku = defaultVariant.sku || '';
+  const sampleSku = baseSku ? `${baseSku}-sample` : '';
+
+  // Save restore map entry BEFORE any write
+  restoreMap[id] = {
+    productId: id,
+    productTitle: product.title,
+    originalOptions: options.map(o => ({ id: o.id, name: o.name, values: o.values })),
+    originalVariants: variants.map(v => ({
+      id: v.id,
+      title: v.title,
+      sku: v.sku,
+      price: v.price,
+      option1: v.option1,
+      option2: v.option2,
+      inventory_management: v.inventory_management,
+      inventory_policy: v.inventory_policy
+    })),
+    timestamp: new Date().toISOString()
+  };
+
+  if (dryRun) {
+    return {
+      success: true,
+      dry: true,
+      productId: id,
+      title: product.title,
+      baseSku,
+      sampleSku,
+      action: 'would-add-sample-variant'
+    };
+  }
+
+  // Step 1: Update the product — rename option "Title" → "Size", rename "Default Title" → "Per Yard",
+  //          and add Sample variant in one PUT
+  //
+  // Shopify: to add a variant to a single-option product (and change option name),
+  // use PUT /products/{id}.json with updated options + variants
+
+  const updatedProduct = {
+    product: {
+      id: id,
+      options: [
+        {
+          name: 'Size',
+          values: ['Per Yard', 'Sample']
+        }
+      ],
+      variants: [
+        {
+          id: defaultVariant.id,
+          option1: 'Per Yard',
+          // Price stays 0 (do_not_price=TRUE — no sell price written)
+          price: defaultVariant.price,
+          sku: defaultVariant.sku
+        },
+        {
+          option1: 'Sample',
+          price: '4.25',
+          sku: sampleSku || undefined,
+          inventory_management: null,
+          inventory_policy: 'continue',
+          requires_shipping: true,
+          taxable: true
+        }
+      ]
+    }
+  };
+
+  const result = await shopifyPut(`products/${id}.json`, updatedProduct);
+  const updatedVariants = result.product?.variants || [];
+  const sampleVariant = updatedVariants.find(v => v.option1 === 'Sample' || v.title === 'Sample');
+
+  return {
+    success: true,
+    productId: id,
+    title: product.title,
+    baseSku,
+    sampleSku,
+    sampleVariantId: sampleVariant?.id,
+    sampleVariantSku: sampleVariant?.sku,
+    samplePrice: sampleVariant?.price
+  };
+}
+
+// --- Append to ledger ---
+function appendLedger(entry) {
+  try {
+    fs.mkdirSync(path.dirname(LEDGER_PATH), { recursive: true });
+    fs.appendFileSync(LEDGER_PATH, JSON.stringify(entry) + '\n');
+  } catch (e) {
+    console.warn('Warning: could not write to ledger:', e.message);
+  }
+}
+
+// --- Main ---
+async function main() {
+  console.log('=== PhilRomano Sample-Add TK-10869 ===');
+  console.log(`DRY_RUN: ${DRY_RUN}`);
+  console.log(`CANARY_LIMIT: ${CANARY_LIMIT === Infinity ? 'none (full run)' : CANARY_LIMIT}`);
+  console.log('');
+
+  // Load existing restore map (resume-safe)
+  let restoreMap = {};
+  if (fs.existsSync(RESTORE_MAP_PATH)) {
+    try {
+      restoreMap = JSON.parse(fs.readFileSync(RESTORE_MAP_PATH, 'utf8'));
+      console.log(`Loaded ${Object.keys(restoreMap).length} existing restore-map entries`);
+    } catch (e) {
+      console.warn('Could not load restore map, starting fresh');
+    }
+  }
+
+  // Fetch qualifying products
+  const products = await fetchQuoteOnlyProducts();
+  console.log(`\nFound ${products.length} quote-only products needing Sample variant\n`);
+
+  if (products.length === 0) {
+    console.log('Nothing to do. All quote-only products already have Sample variants.');
+    return;
+  }
+
+  // Apply canary limit
+  const toProcess = products.slice(0, CANARY_LIMIT === Infinity ? products.length : CANARY_LIMIT);
+  console.log(`Processing ${toProcess.length} products (${DRY_RUN ? 'DRY RUN' : 'LIVE'})\n`);
+
+  const results = {
+    startedAt: new Date().toISOString(),
+    dryRun: DRY_RUN,
+    totalFound: products.length,
+    toProcess: toProcess.length,
+    succeeded: 0,
+    failed: 0,
+    skipped: 0,
+    errors: [],
+    processed: []
+  };
+
+  // Process in batches of BATCH_SIZE
+  for (let batchStart = 0; batchStart < toProcess.length; batchStart += BATCH_SIZE) {
+    const batch = toProcess.slice(batchStart, batchStart + BATCH_SIZE);
+    const batchNum = Math.floor(batchStart / BATCH_SIZE) + 1;
+    const totalBatches = Math.ceil(toProcess.length / BATCH_SIZE);
+
+    console.log(`--- Batch ${batchNum}/${totalBatches} (products ${batchStart + 1}–${Math.min(batchStart + BATCH_SIZE, toProcess.length)}) ---`);
+
+    for (const product of batch) {
+      try {
+        const r = await processProduct(product, restoreMap, DRY_RUN);
+
+        if (r.skipped) {
+          results.skipped++;
+          console.log(`  SKIP  ${product.id} — ${r.reason}`);
+        } else if (r.success) {
+          results.succeeded++;
+          results.processed.push(r);
+          const tag = r.dry ? 'DRY' : 'OK';
+          console.log(`  ${tag}   ${product.title?.slice(0, 50)} | Sample SKU: ${r.sampleSku}`);
+        }
+
+        // Small gap between individual products (avoid rate limiting)
+        await sleep(300);
+
+      } catch (e) {
+        results.failed++;
+        const errEntry = { productId: product.id, title: product.title, error: e.message };
+        results.errors.push(errEntry);
+        console.error(`  ERR  ${product.id}: ${e.message}`);
+        await sleep(1000); // longer gap on error
+      }
+    }
+
+    // Save restore map after each batch
+    fs.writeFileSync(RESTORE_MAP_PATH, JSON.stringify(restoreMap, null, 2));
+    console.log(`  Restore map saved (${Object.keys(restoreMap).length} entries)`);
+
+    // Gap between batches (except last)
+    if (batchStart + BATCH_SIZE < toProcess.length) {
+      const gapSec = Math.round(BATCH_GAP_MS / 1000);
+      console.log(`  Waiting ${gapSec}s before next batch...\n`);
+      await sleep(BATCH_GAP_MS);
+    }
+  }
+
+  // Final results
+  results.completedAt = new Date().toISOString();
+  fs.writeFileSync(RESULTS_PATH, JSON.stringify(results, null, 2));
+
+  console.log('\n=== RESULTS ===');
+  console.log(`Succeeded: ${results.succeeded}`);
+  console.log(`Failed:    ${results.failed}`);
+  console.log(`Skipped:   ${results.skipped}`);
+  console.log(`Results:   ${RESULTS_PATH}`);
+  console.log(`RestoreMap: ${RESTORE_MAP_PATH}`);
+
+  if (!DRY_RUN && results.succeeded > 0) {
+    appendLedger({
+      ts: new Date().toISOString(),
+      agent: 'vp-dw-commerce',
+      ticket: 'TK-10869',
+      action: 'philromano-sample-add',
+      blast_radius: results.succeeded,
+      undo_cmd: `node ${path.join(__dirname, 'sample-add-rollback.mjs')} --restore ${RESTORE_MAP_PATH}`,
+      verify: `node ${path.join(__dirname, 'sample-add.mjs')} --verify-only`,
+      summary: `Added $4.25 Sample variant to ${results.succeeded} quote-only PhilRomano products`
+    });
+    console.log(`\nLedger entry appended: ${LEDGER_PATH}`);
+  }
+
+  if (results.failed > 0) {
+    console.log('\nFailed products:');
+    results.errors.forEach(e => console.log(`  ${e.productId}: ${e.error}`));
+  }
+}
+
+main().catch(e => {
+  console.error('FATAL:', e);
+  process.exit(1);
+});

← 9a406f2 add ungated public-safe /api/products feed (cork+wallcoverin  ·  back to Philipperomano  ·  auto-data-snapshot: 2026-08-28T04:38:35 (2 data files) — dat 130d3af →