← back to Dw Unbuyable Recovery Pilot
tk11124-dwve-velvet/apply.mjs
164 lines
#!/usr/bin/env node
/**
* apply.mjs — TK-11124. Make 18 sample-only ACTIVE "Velvet Walls by Phillipe Romano"
* (DWVE) products BUYABLE by ADDING one sellable per-yard variant, matching the live
* working sibling DWVE-430760 exactly. Steve-approved, in-session, customer-facing.
*
* Per product (verified live, sample-only, ACTIVE):
* • ADD variant Size:"Sold Per Yard" @ $359.00, sku=<DWSKU>-Yard, inventoryPolicy CONTINUE,
* inventoryItem.tracked=true (DEFAULT strategy — the product already has a real "Size"
* option w/ value "Sample", so this appends a 2nd option value; the $4.25 Sample is untouched).
* • inventoryActivate the new item @ Location/5795643504 (15442 Ventura Blvd) + set on_hand 2026
* — REQUIRES write_inventory ⇒ FULL token (lib/shopify.mjs prefers SHOPIFY_FULL_ACCESS_TOKEN …2ea5).
* • Align product global.unit_of_measure "Full Roll" -> "Sold per Yard" (records old value).
* • KEEP the $4.25 Sample variant exactly as-is. Do NOT change product status (stays ACTIVE).
*
* SAFETY:
* • DRY-RUN by default. --apply --i-am-steve to write (Steve gave explicit in-session approval).
* • Idempotent: skips any product that already has a non-sample sellable variant, is not
* sample-only, is not ACTIVE, or whose lone variant is the implicit "Default Title" (would
* be consumed — never risk the sample).
* • Rollback map per product -> out/rollback.jsonl (undo = productVariantsBulkDelete the added
* variant + metafieldsSet unit_of_measure back to old value).
* • Self-verify AFTER each write: re-query, confirm sample survived AND sellable buyable.
* • Ledger each applied product to ~/.claude/yolo-queue/executed-reversible/ledger.jsonl.
* COST: $0 (local + Shopify Admin API; no metered service).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { gql, SHOP, TOKEN } from '../../designerwallcoverings/scripts/lib/shopify.mjs';
const __dir = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.join(__dir, 'out');
fs.mkdirSync(outDir, { recursive: true });
const APPLY = process.argv.includes('--apply') && process.argv.includes('--i-am-steve');
const LIMIT = (process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1];
const LOCATION = 'gid://shopify/Location/5795643504'; // 15442 Ventura Blvd (primary; = getLocation() + sibling)
const QTY = 2026;
const PRICE = '359.00';
const OPT_NAME = 'Size';
const OPT_VALUE = 'Sold Per Yard'; // match sibling DWVE-430760 casing exactly
const UNIT_OLD = 'Full Roll'; // expected current value (verified)
const UNIT_NEW = 'Sold per Yard'; // line-consistent phrasing ("Sold per Bolt" etc.)
const TARGETS = [
['DWVE-429560','6621005643827'],['DWVE-429660','6621005676595'],['DWVE-429760','6621005709363'],
['DWVE-429960','6621005774899'],['DWVE-430160','6621005873203'],['DWVE-430260','6621005905971'],
['DWVE-430360','6621005938739'],['DWVE-430460','6621005971507'],['DWVE-430560','6621006004275'],
['DWVE-430660','6621006069811'],['DWVE-430860','6621006135347'],['DWVE-430960','6621006200883'],
['DWVE-431060','6621006233651'],['DWVE-431160','6621006266419'],['DWVE-431260','6621006299187'],
['DWVE-431360','6621006331955'],['DWVE-431560','6621006397491'],['DWVE-431660','6621006463027'],
];
const gidP = id => `gid://shopify/Product/${id}`;
const Q = `query($id:ID!){ product(id:$id){ id legacyResourceId title handle status
options{name values}
unit: metafield(namespace:"global", key:"unit_of_measure"){ value }
variants(first:25){ nodes{ id sku price selectedOptions{name value} inventoryItem{ id tracked } } } } }`;
const C = `mutation($productId:ID!,$variants:[ProductVariantsBulkInput!]!){
productVariantsBulkCreate(productId:$productId, variants:$variants){
productVariants{ id sku price selectedOptions{name value} inventoryItem{ id } } userErrors{ field message } } }`;
const ACT = `mutation($iid:ID!,$loc:ID!){ inventoryActivate(inventoryItemId:$iid, locationId:$loc){ inventoryLevel{ id } userErrors{ message } } }`;
const SETQ = `mutation($input:InventorySetQuantitiesInput!){ inventorySetQuantities(input:$input){ userErrors{ message code } } }`;
const MF = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ metafields{ id key value } userErrors{ field message } } }`;
const ledgerPath = path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible/ledger.jsonl');
const rbStream = fs.createWriteStream(path.join(outDir, 'rollback.jsonl'), { flags: 'a' });
const results = [];
let applied = 0, skipped = 0, errored = 0;
console.log(`TK-11124 add-per-yard-variant — token …${(TOKEN||'').slice(-4)} — mode: ${APPLY ? '⚠️ LIVE APPLY' : 'DRY-RUN'} — store ${SHOP}`);
const RUN = LIMIT ? TARGETS.slice(0, parseInt(LIMIT, 10)) : TARGETS;
for (const [dwsku, id] of RUN) {
const d = await gql(Q, { id: gidP(id) });
const p = d?.product;
if (!p) { results.push({ dwsku, id, action: 'SKIP', reason: 'not-found' }); skipped++; continue; }
const vs = p.variants.nodes;
const sample = vs.find(v => /-sample$/i.test(v.sku||'') || parseFloat(v.price) <= 4.30);
const sellable = vs.find(v => v !== sample && parseFloat(v.price) > 4.30);
const yardSku = `${dwsku}-Yard`;
if (p.status !== 'ACTIVE') { results.push({ dwsku, id, action: 'SKIP', reason: 'not-active:'+p.status }); skipped++; continue; }
if (sellable || vs.some(v => (v.sku||'').toUpperCase() === yardSku.toUpperCase() || /sold per yard/i.test(v.selectedOptions?.map(o=>o.value).join('|')||''))) {
results.push({ dwsku, id, action: 'SKIP', reason: 'already-has-sellable', existing: sellable?.sku }); skipped++; continue;
}
if (!(vs.length === 1 && sample)) { results.push({ dwsku, id, action: 'SKIP', reason: 'not-sample-only', nvar: vs.length }); skipped++; continue; }
const soleOpt = (sample.selectedOptions?.[0]?.value || '').toLowerCase();
if (soleOpt === 'default title') { results.push({ dwsku, id, action: 'SKIP', reason: 'default-title-would-consume-sample' }); skipped++; continue; }
const preUnit = p.unit?.value ?? null;
const plan = { dwsku, id: p.legacyResourceId, title: p.title, sample_sku: sample.sku, sample_price: sample.price, yard_sku: yardSku, price: PRICE, opt: `${OPT_NAME}:${OPT_VALUE}`, unit_from: preUnit, unit_to: UNIT_NEW };
if (!APPLY) { results.push({ dwsku, id: p.legacyResourceId, action: 'WOULD-APPLY', ...plan }); continue; }
const errs = [];
// 1) create the sellable per-yard variant (DEFAULT strategy — appends 2nd Size value)
const cr = await gql(C, { productId: gidP(id), variants: [{
price: PRICE, optionValues: [{ optionName: OPT_NAME, name: OPT_VALUE }],
inventoryPolicy: 'CONTINUE', inventoryItem: { sku: yardSku, tracked: true },
}] });
if (cr?.__err) { errored++; results.push({ dwsku, id, action: 'FAIL', reason: 'create:'+JSON.stringify(cr.__err).slice(0,160) }); continue; }
const ue = cr.productVariantsBulkCreate?.userErrors || [];
if (ue.length) { errored++; results.push({ dwsku, id, action: 'FAIL', reason: 'create-uerr:'+JSON.stringify(ue) }); continue; }
const nv = (cr.productVariantsBulkCreate.productVariants || []).find(v => (v.sku||'').toUpperCase() === yardSku.toUpperCase()) || cr.productVariantsBulkCreate.productVariants[0];
const iid = nv?.inventoryItem?.id;
// record rollback BEFORE further steps so the created variant is always reversible
rbStream.write(JSON.stringify({ dwsku, product_id: p.legacyResourceId, added_variant_id: nv.id, yard_sku: yardSku, unit_of_measure_old: preUnit, unit_of_measure_new: UNIT_NEW }) + '\n');
// 2) inventoryActivate + set on_hand 2026 (FULL token)
if (iid) {
const a = await gql(ACT, { iid, loc: LOCATION });
(a?.inventoryActivate?.userErrors || []).filter(e => !/already/i.test(e.message)).forEach(e => errs.push('activate:'+e.message));
if (a?.__err) errs.push('activate:'+JSON.stringify(a.__err).slice(0,120));
const sq = await gql(SETQ, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true,
quantities: [{ inventoryItemId: iid, locationId: LOCATION, quantity: QTY }] } });
(sq?.inventorySetQuantities?.userErrors || []).forEach(e => errs.push('setqty:'+e.message));
if (sq?.__err) errs.push('setqty:'+JSON.stringify(sq.__err).slice(0,120));
} else errs.push('no-inventoryItem-on-created-variant');
// 3) align unit_of_measure metafield
const mf = await gql(MF, { mf: [{ ownerId: gidP(id), namespace: 'global', key: 'unit_of_measure', type: 'single_line_text_field', value: UNIT_NEW }] });
(mf?.metafieldsSet?.userErrors || []).forEach(e => errs.push('unit-mf:'+e.message));
if (mf?.__err) errs.push('unit-mf:'+JSON.stringify(mf.__err).slice(0,120));
// 4) self-verify: re-query, sample survived + sellable present + status still ACTIVE
const re = await gql(Q, { id: gidP(id) });
const rv = re?.product?.variants?.nodes || [];
const sampleOk = rv.some(v => parseFloat(v.price) <= 4.30);
const sellOk = rv.some(v => (v.sku||'').toUpperCase() === yardSku.toUpperCase() && parseFloat(v.price) > 4.30);
const unitOk = (re?.product?.unit?.value || '') === UNIT_NEW;
const statusOk = re?.product?.status === 'ACTIVE';
const minPrice = Math.min(...rv.map(v => parseFloat(v.price)));
if (!sampleOk) {
errs.push('SAMPLE-LOST');
results.push({ dwsku, id: p.legacyResourceId, action: 'FAIL-SAMPLE-LOST', added_variant_id: nv.id, errs });
console.log(`⛔ ${dwsku} SAMPLE LOST after add — ABORT (rollback the added variant ${nv.id}). Evidence out/rollback.jsonl`);
errored++; break; // never churn further
}
const action = (sellOk && statusOk && !errs.length) ? 'APPLIED' : 'APPLIED-WITH-WARN';
results.push({ dwsku, id: p.legacyResourceId, action, added_variant_id: nv.id, yard_sku: yardSku, min_variant_price: minPrice, unit_ok: unitOk, status: re?.product?.status, errs });
applied++;
// ledger this reversible customer-facing write
fs.appendFileSync(ledgerPath, JSON.stringify({
ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-11124',
action: `add per-yard sellable variant ${yardSku} @ $${PRICE} (Size:${OPT_VALUE}, CONTINUE, inv ${QTY}@Ventura) + unit_of_measure "${preUnit}"->"${UNIT_NEW}" on ${dwsku} product ${p.legacyResourceId} (${SHOP}) — makes sample-only ACTIVE product buyable`,
blast_radius: 18,
undo_cmd: `cd ~/Projects/dw-unbuyable-recovery-pilot/tk11124-dwve-velvet && node rollback.mjs --apply --i-am-steve # productVariantsBulkDelete ${nv.id} on product ${p.legacyResourceId} + metafieldsSet global.unit_of_measure="${preUnit}"; map out/rollback.jsonl`,
verify: `product ${p.legacyResourceId} variant ${yardSku} present @ $${PRICE}, sample $4.25 intact, status ACTIVE`,
}) + '\n');
await new Promise(r => setTimeout(r, 150));
}
rbStream.end();
fs.writeFileSync(path.join(outDir, `results-${APPLY?'apply':'dryrun'}.json`), JSON.stringify({ ticket: 'TK-11124', at: new Date().toISOString(), apply: APPLY, applied, skipped, errored, results }, null, 2));
console.log(`\n=== ${APPLY ? 'APPLIED' : 'DRY-RUN'} — ${applied} applied / ${skipped} skipped / ${errored} failed ===`);
for (const r of results) console.log(` ${String(r.action).padEnd(20)} ${r.dwsku}${r.yard_sku?(' +'+r.yard_sku):''}${r.min_variant_price!==undefined?(' min$'+r.min_variant_price):''}${r.reason?(' reason='+r.reason):''}${r.errs&&r.errs.length?(' ERRS='+r.errs.join(';')):''}`);
console.log(`\nrollback map: out/rollback.jsonl | results: out/results-${APPLY?'apply':'dryrun'}.json`);
if (!APPLY) console.log('To execute: node apply.mjs --apply --i-am-steve');