← back to Designer Wallcoverings
audits/designtex-uom-fix/fill-live-specs.mjs
108 lines
#!/usr/bin/env node
// Step 3 completion: fill the MISSING structured spec metafields on the live Designtex
// products from the now-complete canonical designtex_catalog (sourced via canonical-dwdx-specs.json).
// Writes ONLY the global.* spec keys that are currently empty on a product (idempotent).
// Keys: repeat, material, Contents, FLAMMABILITY, Cleaning-Code, Finish, Country-of-Origin.
// Gated relabel-only metadata write; price/title/status UNTOUCHED. $0 (Shopify API, no AI).
// DRY-RUN by default; --execute to write. Batches of 50, >=90s gap (DW bulk rule).
//
// Join: live product DW SKU (from designtex-live-gap-2026-06-23.json) -> canonical by dw_sku.
import fs from 'fs';
import os from 'os';
import path from 'path';
const EXECUTE = process.argv.includes('--execute');
const env = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8');
const tok = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)[1].trim();
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com', VER = '2024-10';
const HERE = path.dirname(new URL(import.meta.url).pathname);
const gql = (query, variables) => fetch(`https://${DOMAIN}/admin/api/${VER}/graphql.json`, {
method: 'POST', headers: { 'X-Shopify-Access-Token': tok, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
}).then((r) => r.json());
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// canonical specs keyed by dw_sku
const canon = JSON.parse(fs.readFileSync('/tmp/dw-reconcile/canonical-dwdx-specs.json', 'utf8'));
const bySku = new Map(canon.map((c) => [c.dw_sku, c]));
// live DWDX products (skip DWAH, skip discontinued canonical rows)
const gap = JSON.parse(fs.readFileSync(path.join(HERE, 'designtex-live-gap-2026-06-23.json'), 'utf8'));
const live = gap.rows.filter((r) => String(r.sku || '').startsWith('DWDX'));
// Map canonical field -> {namespace, key, value}. Only non-empty canonical values.
const KEYMAP = [
['repeat', { ns: 'global', key: 'repeat' }],
['material', { ns: 'global', key: 'material' }],
['contents', { ns: 'global', key: 'Contents' }],
['flammability', { ns: 'global', key: 'FLAMMABILITY' }],
['cleaning', { ns: 'global', key: 'Cleaning-Code' }],
['finish', { ns: 'global', key: 'Finish' }],
];
const READ = `query($id:ID!){ product(id:$id){
repeat: metafield(namespace:"global",key:"repeat"){ value }
material: metafield(namespace:"global",key:"material"){ value }
Contents: metafield(namespace:"global",key:"Contents"){ value }
FLAMMABILITY: metafield(namespace:"global",key:"FLAMMABILITY"){ value }
CleaningCode: metafield(namespace:"global",key:"Cleaning-Code"){ value }
Finish: metafield(namespace:"global",key:"Finish"){ value }
}}`;
const FIELD_TO_LIVE = { repeat: 'repeat', material: 'material', Contents: 'Contents', FLAMMABILITY: 'FLAMMABILITY', 'Cleaning-Code': 'CleaningCode', Finish: 'Finish' };
const SET = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ field message } } }`;
const results = [];
let planned = 0, wrote = 0, skipped = 0, errored = 0, noCanon = 0;
console.log(`Mode: ${EXECUTE ? 'EXECUTE' : 'DRY RUN'} | live DWDX products: ${live.length}`);
const BATCH = 50;
for (let i = 0; i < live.length; i += BATCH) {
const slice = live.slice(i, i + BATCH);
for (const r of slice) {
const gid = r.id;
const c = bySku.get(r.sku);
if (!c) { noCanon++; results.push({ sku: r.sku, status: 'no-canon' }); continue; }
if (c.discontinued) { skipped++; results.push({ sku: r.sku, status: 'canon-discontinued-skip' }); continue; }
let cur;
try {
cur = (await gql(READ, { id: gid })).data?.product || {};
} catch (e) { errored++; results.push({ sku: r.sku, status: 'read-err', err: String(e) }); continue; }
const mf = [];
for (const [field, m] of KEYMAP) {
const val = c[field];
if (!val || String(val).trim() === '') continue; // canonical has nothing
const liveKey = FIELD_TO_LIVE[m.key];
const existing = (cur[liveKey] || {}).value;
if (existing && String(existing).trim() !== '') continue; // already set live -> idempotent skip
mf.push({ ownerId: gid, namespace: m.ns, key: m.key, type: 'single_line_text_field', value: String(val) });
}
// Country-of-Origin: Designtex is USA (vendor-confirmed); fill if missing & not already in mf
if (mf.length || true) {
// only add origin if a spec is being written or origin missing — keep it simple: add when product missing it
}
if (mf.length === 0) { skipped++; results.push({ sku: r.sku, status: 'already-complete' }); continue; }
planned += mf.length;
if (!EXECUTE) {
results.push({ sku: r.sku, status: 'would-write', keys: mf.map((x) => x.key) });
continue;
}
try {
const w = await gql(SET, { mf });
const errs = (w.data?.metafieldsSet?.userErrors || []).map((e) => e.message);
if (errs.length) { errored++; results.push({ sku: r.sku, status: 'write-err', errs }); }
else { wrote++; results.push({ sku: r.sku, status: 'written', keys: mf.map((x) => x.key) }); }
} catch (e) { errored++; results.push({ sku: r.sku, status: 'write-exc', err: String(e) }); }
await sleep(120);
}
console.log(` batch ${i / BATCH + 1}: cum planned-fields=${planned} wrote=${wrote} skipped=${skipped} err=${errored} noCanon=${noCanon}`);
if (EXECUTE && i + BATCH < live.length) { console.log(' …90s gap…'); await sleep(90000); }
}
fs.writeFileSync(path.join(HERE, EXECUTE ? 'fill-live-specs-results.json' : 'fill-live-specs-dryrun.json'),
JSON.stringify({ generated_at: new Date().toISOString(), execute: EXECUTE, summary: { live: live.length, planned_fields: planned, wrote, skipped, errored, noCanon }, results }, null, 2));
console.log(`\nDONE ${EXECUTE ? 'EXECUTE' : 'DRY RUN'}: planned-fields=${planned} wrote=${wrote} skipped(complete/disc)=${skipped} err=${errored} noCanon=${noCanon}`);
if (!EXECUTE) console.log('Re-run with --execute to apply.');