← back to Philipperomano

scripts/sample-add.mjs

394 lines

/**
 * 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);
});