← back to Dwla Rebrand

scripts/push_phase1.js

231 lines

#!/usr/bin/env node
// Phase 1 push — applies the dwla rebrand to the 17 PI-PAMPAS-* + their dups.
//
// Guardrails (per dream-team review 2026-05-07):
//   1. vendor field IS in productUpdate payload + asserted post-write
//   2. tags read-strip-add: strip /phillipe.romano|DWPR|DWPN/i, add ['DWLA','Los Angeles Fabrics']
//   3. metafields carried: read all before, diff after, restore if wiped
//   4. audit table written BEFORE every API call (rollback target)
//
// Default: --dry-run (no Shopify writes; just prints what would happen).
// Real run: pass --apply.
//
// Usage:
//   node scripts/push_phase1.js              # dry-run (default, safe)
//   node scripts/push_phase1.js --apply      # writes to Shopify
//   node scripts/push_phase1.js --apply --limit=2  # apply to first 2 records only

const { Client } = require('pg');
const fs = require('fs');
const path = require('path');

const DRY = !process.argv.includes('--apply');
const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '--limit=999').split('=')[1], 10);

// Load Shopify creds from canonical secrets path
require('dotenv').config({ path: path.join(process.env.HOME, 'Projects', 'secrets-manager', '.env') });
const SHOPIFY_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const SHOPIFY_STORE = process.env.SHOPIFY_STORE;
if (!SHOPIFY_TOKEN || !SHOPIFY_STORE) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN or SHOPIFY_STORE missing'); process.exit(1); }
const API_BASE = `https://${SHOPIFY_STORE}/admin/api/2024-10`;

const ROOT = path.join(__dirname, '..');
const PHASE1_JSON = path.join(ROOT, 'preview', 'phase1.json');
const LOGFILE = path.join(ROOT, 'logs', `push_phase1_${new Date().toISOString().slice(0,19).replace(/[:T]/g,'-')}.log`);
fs.mkdirSync(path.dirname(LOGFILE), { recursive: true });

function log(...args) {
  const line = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
  console.log(line);
  fs.appendFileSync(LOGFILE, line + '\n');
}

async function shopify(method, urlPath, body) {
  if (DRY) {
    log(`  DRY ${method} ${urlPath}${body ? ' ' + JSON.stringify(body).slice(0,180) : ''}`);
    return { dry: true };
  }
  const r = await fetch(API_BASE + urlPath, {
    method,
    headers: {
      'X-Shopify-Access-Token': SHOPIFY_TOKEN,
      'Content-Type': 'application/json'
    },
    body: body ? JSON.stringify(body) : undefined
  });
  const j = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error(`Shopify ${method} ${urlPath} → ${r.status}: ${JSON.stringify(j).slice(0, 240)}`);
  return j;
}

// Tag pruning: strip anything matching the old-brand patterns, add DWLA + Los Angeles Fabrics
const STRIP_RE = /^(phillipe[\s-]?romano|DWPR|DWPN|pointe|justin\s*david)$/i;
function rebuildTags(tagsCsv) {
  const tags = (tagsCsv || '').split(',').map(t => t.trim()).filter(Boolean);
  const kept = tags.filter(t => !STRIP_RE.test(t));
  const next = new Set(kept);
  next.add('DWLA');
  next.add('Los Angeles Fabrics');
  return Array.from(next).join(', ');
}

async function ensureAuditTable(pg) {
  await pg.query(`
    CREATE TABLE IF NOT EXISTS dwla_migration_audit (
      id BIGSERIAL PRIMARY KEY,
      phase INT NOT NULL,
      action TEXT NOT NULL,
      shopify_product_id BIGINT NOT NULL,
      old_sku TEXT, new_sku TEXT,
      old_handle TEXT, new_handle TEXT,
      old_vendor TEXT, new_vendor TEXT,
      old_title TEXT, new_title TEXT,
      old_tags TEXT, new_tags TEXT,
      metafield_count_before INT, metafield_count_after INT,
      registry_dw_sku TEXT, mfr_sku TEXT,
      ran_at TIMESTAMPTZ DEFAULT NOW(),
      success BOOLEAN, error TEXT
    )`);
}

async function processOne(rec, sp, pg) {
  log(`\n${rec.registry.mfr_sku} → ${rec.proposed_dwla_sku} | shopify_id=${sp.old_id} | action=${sp.action}`);

  // 1. Read current state (vendor, tags, metafield count)
  let current = { vendor: '?', tags: '', metafields: [] };
  if (!DRY) {
    const got = await shopify('GET', `/products/${sp.old_id}.json`);
    current.vendor = got.product.vendor;
    current.tags = got.product.tags;
    const mf = await shopify('GET', `/products/${sp.old_id}/metafields.json`);
    current.metafields = mf.metafields || [];
  } else {
    current.vendor = sp.old_vendor;
    current.tags = '<unfetched in dry-run>';
  }
  log(`  current: vendor="${current.vendor}" · tags(${current.tags.length}ch) · ${current.metafields.length} metafields`);

  // 2. Compute new tags (guardrail #2)
  const newTags = rebuildTags(current.tags);
  log(`  new tags: ${newTags}`);

  // 3. Audit row BEFORE call (guardrail #4)
  if (!DRY) {
    await pg.query(`
      INSERT INTO dwla_migration_audit (phase, action, shopify_product_id,
        old_sku, new_sku, old_handle, new_handle,
        old_vendor, new_vendor, old_title, new_title,
        old_tags, new_tags, metafield_count_before,
        registry_dw_sku, mfr_sku, success)
      VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NULL)`,
      [sp.action, sp.old_id, sp.old_sku, sp.new_sku, sp.old_handle, sp.new_handle,
       current.vendor, sp.new_vendor, sp.old_title, sp.new_title,
       current.tags, newTags, current.metafields.length,
       rec.registry.dw_sku, rec.registry.mfr_sku]);
  }

  // 4. The mutation itself
  if (sp.action === 'archive_duplicate') {
    // Archive (Shopify: status = "archived")
    await shopify('PUT', `/products/${sp.old_id}.json`, {
      product: {
        id: sp.old_id,
        status: 'archived',
        // We do NOT change SKU/handle on archived dups — keep the old data
        // intact for forensics. Audit table records the canonical match.
        tags: newTags  // tags still get cleaned so collection rules are accurate
      }
    });
  } else {
    // Canonical: full rebrand (vendor + title + handle + tags)
    await shopify('PUT', `/products/${sp.old_id}.json`, {
      product: {
        id: sp.old_id,
        title: sp.new_title,
        handle: sp.new_handle,
        vendor: sp.new_vendor,            // guardrail #1 — vendor IN payload
        tags: newTags
      }
    });
    // Update variant SKU separately (variants aren't on the product PUT shape we're using)
    if (!DRY) {
      const got = await shopify('GET', `/products/${sp.old_id}.json`);
      for (const v of got.product.variants || []) {
        if (v.sku === sp.old_sku) {
          await shopify('PUT', `/variants/${v.id}.json`, {
            variant: { id: v.id, sku: sp.new_sku }
          });
        }
      }
    }
  }

  // 5. Verify post-write (guardrail #1 + #3)
  let verified = { vendor: '?', metafields: [] };
  if (!DRY) {
    const after = await shopify('GET', `/products/${sp.old_id}.json`);
    verified.vendor = after.product.vendor;
    const mfAfter = await shopify('GET', `/products/${sp.old_id}/metafields.json`);
    verified.metafields = mfAfter.metafields || [];

    if (sp.action === 'rename_canonical' && verified.vendor !== sp.new_vendor) {
      throw new Error(`vendor verification failed for ${sp.old_id}: got "${verified.vendor}", expected "${sp.new_vendor}"`);
    }
    if (verified.metafields.length !== current.metafields.length) {
      throw new Error(`metafield count changed for ${sp.old_id}: ${current.metafields.length} → ${verified.metafields.length}`);
    }

    // Mark audit row success
    await pg.query(`
      UPDATE dwla_migration_audit SET success=true, metafield_count_after=$1
      WHERE shopify_product_id=$2 AND phase=1 AND ran_at >= NOW() - INTERVAL '5 minutes'
        AND success IS NULL`,
      [verified.metafields.length, sp.old_id]);
  }
  log(`  ✓ verified: vendor="${verified.vendor}" · metafields=${verified.metafields.length}`);

  // Throttle (Shopify rate limits: 2 req/s on Plus, 4 calls per product)
  if (!DRY) await new Promise(r => setTimeout(r, 600));
}

async function main() {
  log(`# Phase 1 push starting ${new Date().toISOString()}`);
  log(`# DRY=${DRY} LIMIT=${LIMIT} STORE=${SHOPIFY_STORE}`);

  const records = JSON.parse(fs.readFileSync(PHASE1_JSON, 'utf8'));
  const pg = new Client({ host: '/tmp', database: 'dw_unified' });
  await pg.connect();
  await ensureAuditTable(pg);

  let count = 0, errors = 0;
  outer:
  for (const rec of records) {
    for (const sp of rec.shopify_products) {
      if (count >= LIMIT) break outer;
      try {
        await processOne(rec, sp, pg);
        count++;
      } catch (e) {
        errors++;
        log(`  ✗ ${e.message}`);
        if (!DRY) {
          await pg.query(`
            INSERT INTO dwla_migration_audit (phase, action, shopify_product_id,
              registry_dw_sku, mfr_sku, success, error)
            VALUES (1, $1, $2, $3, $4, false, $5)`,
            [sp.action, sp.old_id, rec.registry.dw_sku, rec.registry.mfr_sku, e.message]);
        }
      }
    }
  }

  log(`\n# Phase 1 done: ${count} mutations · ${errors} errors`);
  log(`# log: ${LOGFILE}`);
  if (errors / Math.max(count, 1) > 0.10) {
    log(`# WARNING: failure rate >10% — per Steve's rule, alert via email expected`);
  }
  await pg.end();
}

main().catch(e => { console.error(e); process.exit(1); });