← back to Groundworks Activation
scripts/rebrand.js
185 lines
#!/usr/bin/env node
/**
* Bucket 1 — REBRAND-IN-PLACE (fully reversible). For each of the 121 already-live
* brand-collision mfr_skus, set vendor='Groundworks' on the existing LIVE product so it
* joins the groundworks-wallcoverings smart collection (vendor=Groundworks rule). Keeps
* handle, mfr_sku, dw_sku, variants intact. Verifies the sellable variant price == MAP
* (wholesale x 1.5 from groundworks_catalog); if off-MAP, corrects it to MAP.
*
* Target-selection rule (DTD verdict A, 2026-07-22): among a mfr_sku's live products,
* rebrand exactly ONE — the single ACTIVE product; if two ACTIVE (Lee Jofa/DWKK + Phillipe
* Romano/DWWC dup pair), rebrand the Lee Jofa/DWKK one, leave the Phillipe Romano untouched.
* NEVER rebrand an ARCHIVED product (discontinued stays archived).
*
* Records before->after vendor for every product touched (data/rebrand-results.json) so
* the whole op is revertible. PG mirror updated first, then Shopify authoritative.
*
* Usage: node rebrand.js [--dry] [--limit N]
*/
const { Client } = require('pg');
const fs = require('fs');
const DRY = process.argv.includes('--dry');
const limIdx = process.argv.indexOf('--limit');
const LIMIT = limIdx >= 0 ? parseInt(process.argv[limIdx + 1], 10) : 0;
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const TOKEN = (fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
.split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN=')) || '').split('=').slice(1).join('=').trim();
if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(2); }
const GQL = `https://${DOMAIN}/admin/api/${API}/graphql.json`;
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function shopify(query, variables) {
for (let attempt = 0; attempt < 6; attempt++) {
const res = await fetch(GQL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN },
body: JSON.stringify({ query, variables }),
});
if (res.status === 429 || res.status >= 500) { await sleep(2500 * (attempt + 1)); continue; }
const j = await res.json();
if (j.errors) throw new Error('GQL: ' + JSON.stringify(j.errors).slice(0, 400));
const cost = j.extensions?.cost?.throttleStatus;
if (cost && cost.currentlyAvailable < 250) await sleep(1500);
return j.data;
}
throw new Error('shopify retries exhausted');
}
// Fetch ALL products for one mfr_sku via metafield/variant sku search is unreliable;
// instead search by the DW SKUs we already know from the mirror. We resolve product ids
// from the local mirror, then read authoritative vendor/status/price from live Shopify.
async function liveProduct(pid) {
const d = await shopify(`query($id:ID!){product(id:$id){id handle vendor status title
variants(first:15){nodes{id sku price inventoryItem{id}}}}}`, { id: `gid://shopify/Product/${pid}` });
return d.product;
}
function pickTarget(candidates) {
// candidates = [{pid,vendor,status,dw_sku}], live-verified
const active = candidates.filter(c => c.status === 'ACTIVE');
if (active.length === 1) return { target: active[0], reason: 'single-active' };
if (active.length === 0) return { target: null, reason: 'no-active' };
// >1 active: prefer the Lee Jofa / DWKK (trade) product (DTD verdict A)
const dwkk = active.find(c => (c.dw_sku || '').startsWith('DWKK'));
const leejofa = active.find(c => /lee jofa/i.test(c.vendor || ''));
const chosen = dwkk || leejofa || active[0];
return { target: chosen, reason: 'dup-pair->leejofa/DWKK', skipped: active.filter(c => c.pid !== chosen.pid) };
}
async function main() {
const pg = new Client({ host: '/tmp', database: 'dw_unified' });
await pg.connect();
const mfrs = fs.readFileSync('/tmp/gw_b1_mfrs.txt', 'utf8').trim().split('\n').filter(Boolean);
const slice = LIMIT ? mfrs.slice(0, LIMIT) : mfrs;
console.log(`Bucket-1 rebrand: ${slice.length} mfr_skus DRY=${DRY}`);
const results = [];
let rebranded = 0, priceFixed = 0, held = 0;
for (const mfr of slice) {
// resolve all live product rows from mirror for this mfr_sku
const rows = (await pg.query(
`SELECT DISTINCT regexp_replace(shopify_id,'^gid://shopify/Product/','') AS pid, dw_sku, vendor, status
FROM shopify_products WHERE mfr_sku=$1`, [mfr])).rows;
if (!rows.length) { results.push({ mfr, skip: 'no-mirror-row' }); held++; continue; }
// MAP source priority (Kravet-umbrella rule): (1) authoritative email-loaded new_map,
// else (2) staging map_price (= wholesale x 1.5). The live prices already match the
// authoritative Mar-2026 adjustment; staging map is staler, so authoritative wins.
const gc = (await pg.query(`SELECT map_price, wholesale FROM groundworks_catalog WHERE mfr_sku=$1`, [mfr])).rows[0];
const auth = (await pg.query(`SELECT new_map FROM kravet_authoritative_pricing WHERE mfr_sku=$1`, [mfr])).rows[0];
const MAP = (auth && Number(auth.new_map) > 0) ? Number(auth.new_map) : (gc ? Number(gc.map_price) : null);
const mapSrc = (auth && Number(auth.new_map) > 0) ? 'authoritative_new_map' : 'staging_map';
// live-verify each distinct pid
const candidates = [];
const seen = new Set();
for (const r of rows) {
if (!r.pid || seen.has(r.pid)) continue; seen.add(r.pid);
let lp;
try { lp = await liveProduct(r.pid); } catch (e) { results.push({ mfr, pid: r.pid, err: 'read:' + String(e.message).slice(0, 120) }); continue; }
if (!lp) continue;
candidates.push({ pid: r.pid, vendor: lp.vendor, status: lp.status, dw_sku: r.dw_sku, live: lp });
await sleep(150);
}
if (!candidates.length) { results.push({ mfr, skip: 'no-live-product' }); held++; continue; }
const { target, reason, skipped } = pickTarget(candidates);
if (!target) { results.push({ mfr, skip: 'no-active-product', candidates: candidates.map(c => ({ pid: c.pid, vendor: c.vendor, status: c.status })) }); held++; continue; }
const beforeVendor = target.vendor;
const entry = { mfr, pid: target.pid, dw_sku: target.dw_sku, before_vendor: beforeVendor, after_vendor: 'Groundworks', reason, handle: target.live.handle };
if (skipped && skipped.length) entry.left_untouched = skipped.map(s => ({ pid: s.pid, vendor: s.vendor, status: s.status, dw_sku: s.dw_sku }));
// sellable variant + current price. A product whose ONLY variant is the $4.25 sample
// (no real sellable roll variant) must NOT be rebranded into the collection — hold + flag.
const vs = target.live.variants.nodes;
const sell = vs.find(v => v.sku && !v.sku.endsWith('-Sample'));
entry.sell_sku = sell ? sell.sku : null;
entry.price_before = sell ? Number(sell.price) : null;
entry.map = MAP;
entry.map_src = mapSrc;
if (!sell) {
results.push({ mfr, pid: target.pid, dw_sku: target.dw_sku, before_vendor: beforeVendor,
skip: 'no-sellable-variant (only $4.25 sample) — needs sellable variant before activation', handle: target.live.handle });
held++; continue;
}
if (beforeVendor === 'Groundworks') { entry.note = 'already-groundworks'; }
if (DRY) {
entry.dry = true;
results.push(entry);
continue;
}
// 1) PG mirror first: flip vendor on the mirror row(s) for this pid
await pg.query(`UPDATE shopify_products SET vendor='Groundworks' WHERE regexp_replace(shopify_id,'^gid://shopify/Product/','')=$1`, [target.pid]);
// 2) Shopify authoritative: set vendor
if (beforeVendor !== 'Groundworks') {
const up = await shopify(`mutation($input:ProductInput!){productUpdate(input:$input){product{id vendor} userErrors{field message}}}`,
{ input: { id: `gid://shopify/Product/${target.pid}`, vendor: 'Groundworks' } });
const ue = up.productUpdate.userErrors;
if (ue && ue.length) { entry.err = 'vendorUpd:' + JSON.stringify(ue); results.push(entry); held++; continue; }
rebranded++;
} else {
entry.rebrand_skipped = 'already-groundworks';
}
// 3) MAP price correction on the sellable variant (Kravet-umbrella rule)
if (sell && MAP > 0 && Math.abs(Number(sell.price) - MAP) > 0.005) {
const pu = await shopify(`mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkUpdate(productId:$pid,variants:$variants){userErrors{field message}}}`,
{ pid: `gid://shopify/Product/${target.pid}`, variants: [{ id: sell.id, price: MAP.toFixed(2) }] });
const pe = pu.productVariantsBulkUpdate.userErrors;
if (pe && pe.length) { entry.price_err = JSON.stringify(pe); }
else { entry.price_after = MAP; entry.price_corrected = true; priceFixed++; }
}
// 4) writeback: allocate dw_sku/vendor on groundworks_catalog (points staging row at the rebranded live product)
await pg.query(`UPDATE groundworks_catalog SET dw_sku=COALESCE($1,dw_sku), shopify_product_id=$2 WHERE mfr_sku=$3`,
[target.dw_sku, target.pid, mfr]);
results.push(entry);
console.log(` ✓ ${mfr} pid=${target.pid} ${beforeVendor} -> Groundworks${entry.price_corrected ? ` price ${entry.price_before}->${entry.price_after}` : ''}`);
await sleep(700); // metered cadence
}
await pg.end();
fs.writeFileSync(__dirname + '/../data/rebrand-results.json', JSON.stringify(results, null, 2));
console.log(`\nDONE: rebranded=${rebranded} priceFixed=${priceFixed} held=${held} total=${results.length}`);
const errs = results.filter(r => r.err || r.price_err);
if (errs.length) console.log('ERRORS:', JSON.stringify(errs, null, 2));
// Phillipe Romano -> Groundworks explicit list
const pr = results.filter(r => /phillipe romano/i.test(r.before_vendor || ''));
console.log(`\nPhillipe-Romano -> Groundworks (${pr.length}):`);
pr.forEach(r => console.log(` ${r.mfr} pid=${r.pid} dw=${r.dw_sku} handle=${r.handle}`));
}
main().catch(e => { console.error(e); process.exit(1); });