← back to Rebel Walls Push
scripts/TK-10405-fullfix.mjs
139 lines
#!/usr/bin/env node
// TK-10405 FULL FIX (supersedes A') for the 2 held Rebel Walls Default-Title orphans.
// Steve-approved in-session (TK-10405). Per product: REORDER Mural(per m²)->pos1,
// Sample->pos2, Default-Title orphan->pos3 (productVariantsBulkReorder, all positions),
// THEN DELETE the orphan (productVariantsBulkDelete). End state = sellable Mural at pos1,
// so PDP JSON-LD offers[0] reads $117.47. Reversible: restore map captures the orphan's
// full recreate payload + original positions; undo script recreates + reorders back.
//
// SCOPE = exactly these 2 products / delete exactly these 2 orphan variants:
// 6679729078323 dwrw-76016 A City Rises -> delete 44453186830387
// 6679739629619 dwrw-76264 A Priori -> delete 44453188206643
//
// Run: node scripts/TK-10405-fullfix.mjs
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
const DOM = 'designer-laboratory-sandbox.myshopify.com';
const VER = '2024-10';
const RESTORE = new URL('../data/TK-10405-restore-map.json', import.meta.url).pathname;
const LOGEXEC = process.env.HOME + '/.claude/yolo-queue/executed-reversible/log-exec.mjs';
const env = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m)?.[1] || '').trim().replace(/^["']|["']$/g, '');
if (!TOKEN) { console.error('no SHOPIFY_FULL_ACCESS_TOKEN'); process.exit(1); }
const TARGETS = [
{ pid: 'gid://shopify/Product/6679729078323', orphan: 'gid://shopify/ProductVariant/44453186830387', handle: 'dwrw-76016', label: 'A City Rises' },
{ pid: 'gid://shopify/Product/6679739629619', orphan: 'gid://shopify/ProductVariant/44453188206643', handle: 'dwrw-76264', label: 'A Priori' },
];
async function gql(query, variables, tries = 6) {
for (let i = 0; i < tries; i++) {
const r = await fetch(`https://${DOM}/admin/api/${VER}/graphql.json`, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
if ([429, 500, 502, 503].includes(r.status) && i < tries - 1) { await new Promise(s => setTimeout(s, 1500 * (i + 1))); continue; }
return r.json();
}
}
const Q_PROD = `query($id:ID!){ product(id:$id){ id options{ name position values }
variants(first:20){ nodes{ id title position price sku taxable inventoryPolicy inventoryQuantity
selectedOptions{ name value } inventoryItem{ id tracked } } } } }`;
const M_REORDER = `mutation($productId:ID!,$positions:[ProductVariantPositionInput!]!){
productVariantsBulkReorder(productId:$productId,positions:$positions){ product{ id } userErrors{ field message } } }`;
const M_DELETE = `mutation($productId:ID!,$variantsIds:[ID!]!){
productVariantsBulkDelete(productId:$productId,variantsIds:$variantsIds){ product{ id } userErrors{ field message } } }`;
function ledger(rec) { execFileSync('node', [LOGEXEC], { input: JSON.stringify(rec), stdio: ['pipe', 'inherit', 'inherit'] }); }
const short = g => g.split('/').pop();
(async () => {
const rm = JSON.parse(fs.readFileSync(RESTORE, 'utf8'));
const ff = { ts: new Date().toISOString(), approved_by: 'Steve in-session TK-10405',
action: 'FULL FIX: reorder Mural(per m²)->1, Sample->2, orphan->3, then DELETE orphan',
undo: 'node scripts/TK-10405-fullfix-undo.mjs — recreates orphan (NEW id) via productVariantsBulkCreate from prestate + restores original positions',
products: {} };
// 1. CAPTURE pre-state (positions of all variants + full orphan recreate payload) BEFORE any write
for (const t of TARGETS) {
const p = (await gql(Q_PROD, { id: t.pid }))?.data?.product;
if (!p) { console.error(`ABORT: product ${t.label} not found`); process.exit(1); }
const vs = p.variants.nodes;
const orphan = vs.find(v => v.id === t.orphan);
const mural = vs.find(v => /^Mural/.test(v.title));
const sample = vs.find(v => v.title === 'Sample');
if (!orphan) { console.log(`SKIP ${t.label}: orphan ${short(t.orphan)} already absent (idempotent)`); t.orphanGone = true; }
if (!mural || !sample) { console.error(`ABORT: ${t.label} missing Mural/Sample survivor`); process.exit(1); }
t.mural = mural.id; t.sample = sample.id;
ff.products[short(t.pid)] = {
product_gid: t.pid, handle: t.handle, label: t.label,
options: p.options,
original_positions: vs.map(v => ({ id: v.id, title: v.title, position: v.position })),
orphan_recreate: orphan ? { id: orphan.id, title: orphan.title, price: orphan.price, sku: orphan.sku,
taxable: orphan.taxable, inventoryPolicy: orphan.inventoryPolicy, inventoryQuantity: orphan.inventoryQuantity,
selectedOptions: orphan.selectedOptions, tracked: orphan.inventoryItem?.tracked } : null,
};
}
rm.full_fix_reorder_delete = ff;
fs.writeFileSync(RESTORE, JSON.stringify(rm, null, 2));
console.log('PRE-STATE (positions + orphan recreate payload) recorded to restore map for', TARGETS.map(t => t.label).join(' + '));
// 2. REORDER then DELETE per product
for (const t of TARGETS) {
console.log(`\n=== ${t.label} (${short(t.pid)}) ===`);
const positions = [{ id: t.mural, position: 1 }, { id: t.sample, position: 2 }];
if (!t.orphanGone) positions.push({ id: t.orphan, position: 3 });
const rr = await gql(M_REORDER, { productId: t.pid, positions });
const dr = rr?.data?.productVariantsBulkReorder;
if (dr?.userErrors?.length || rr?.errors) { console.error('REORDER FAILED:', JSON.stringify(dr?.userErrors || rr.errors)); process.exit(1); }
console.log(' reordered -> Mural pos1, Sample pos2' + (t.orphanGone ? '' : ', orphan pos3'));
if (!t.orphanGone) {
const rd = await gql(M_DELETE, { productId: t.pid, variantsIds: [t.orphan] });
const dd = rd?.data?.productVariantsBulkDelete;
if (dd?.userErrors?.length || rd?.errors) { console.error('DELETE FAILED:', JSON.stringify(dd?.userErrors || rd.errors)); process.exit(1); }
console.log(' deleted orphan', short(t.orphan));
}
}
// 3. VERIFY (Admin API) — orphan absent, Mural pos1 $117.47, Sample pos2
console.log('\n===== VERIFY (Admin API) =====');
let allok = true;
for (const t of TARGETS) {
const p = (await gql(Q_PROD, { id: t.pid })).data.product;
const vs = p.variants.nodes.sort((a, b) => a.position - b.position);
const orphanGone = !vs.find(v => v.id === t.orphan);
const pos1 = vs[0];
const muralPos1 = /^Mural/.test(pos1?.title || '') && pos1?.price === '117.47';
const ok = orphanGone && muralPos1;
allok = allok && ok;
console.log(`${t.label}: orphan-absent=${orphanGone} pos1='${pos1?.title}' $${pos1?.price} (${muralPos1 ? 'OK' : 'BAD'})`);
for (const v of vs) console.log(` pos${v.position} ${short(v.id)} '${v.title}' $${v.price} sku=${v.sku}`);
}
// 4. VERIFY storefront (.json order + .js availability)
console.log('\n===== VERIFY (rendered storefront) =====');
for (const t of TARGETS) {
try {
const j = await (await fetch(`https://${DOM}/products/${t.handle}.json`, { headers: { 'User-Agent': 'Mozilla/5.0' } })).json();
const v0 = j.product.variants[0];
const hasOrphan = j.product.variants.some(x => x.title === 'Default Title');
console.log(`${t.handle}: storefront pos1='${v0.title}' $${v0.price} | Default-Title present=${hasOrphan}` +
(hasOrphan ? ' (CDN cache may lag a few min)' : ''));
} catch (e) { console.error(`${t.handle} storefront fetch failed:`, e.message); }
}
// 5. LEDGER (one entry per product: reorder+delete)
console.log('\n===== LEDGER =====');
for (const t of TARGETS) {
ledger({ agent: 'vp-dw-commerce', ticket: 'TK-10405',
action: `FULL FIX on ${t.label} (${short(t.pid)}): reordered Mural(per m²)->pos1 + Sample->pos2, DELETED Default-Title orphan ${short(t.orphan)}`,
blast_radius: 1, target: short(t.pid),
undo_cmd: `node ~/Projects/rebel-walls-push/scripts/TK-10405-fullfix-undo.mjs # recreates orphan (new id) + restores positions from restore-map.full_fix_reorder_delete`,
verify: `product ${short(t.pid)}: orphan ${short(t.orphan)} absent; pos1=Mural(per m²) $117.47; Sample pos2; storefront offers[0]=$117.47` });
}
console.log('\n' + (allok ? 'FULL FIX COMPLETE — verification PASSED for both products.' : 'FULL FIX RAN but verification found a mismatch — review above.'));
})();