← 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); });