← back to Rebel Walls Push
scripts/activate.js
230 lines
#!/usr/bin/env node
'use strict';
/*
* Rebel Walls activation pass — make already-pushed products fully live/sellable.
* Steve-authorized 2026-06-04: "publish all to active, assign all sales channels,
* and inventory at 2026 for all — rebel walls."
*
* For every rebelwalls_catalog row that HAS a checkpointed shopify_product_id:
* 1. status -> ACTIVE (productUpdate)
* 2. publish to ALL publications (publishablePublish)
* 3. inventory = 2026 on BOTH variants (inventoryItemUpdate tracked=true,
* then inventorySetQuantities available=2026
* at the store location)
* inventoryPolicy already CONTINUE from the pusher; we keep it.
*
* Idempotent + resumable. Progress checkpointed to PG column
* rebelwalls_catalog.activated_at (added if missing) + a JSONL log.
*
* Usage:
* node scripts/activate.js --canary 5 # first 5 pushed rows
* node scripts/activate.js --all # all pushed rows not yet activated
* node scripts/activate.js --all --limit 100
* node scripts/activate.js --all --reactivate # ignore activated_at, redo all
*/
const fs = require('fs');
const path = require('path');
const { gqlRetry, psql, sleep } = require('./_shop.js');
const QTY = 2026;
const LOG_DIR = path.join(__dirname, '..', 'data');
const ACT_LOG = path.join(LOG_DIR, 'activate-progress.jsonl');
const args = process.argv.slice(2);
const flag = n => args.includes(n);
const val = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
const CANARY = val('--canary', null);
const DO_ALL = flag('--all');
const LIMIT = val('--limit', null);
const REACT = flag('--reactivate');
const DRY = flag('--dry-run');
function sql_(s) { return String(s).replace(/'/g, "''"); }
// Ensure the checkpoint column exists.
function ensureColumn() {
psql(`ALTER TABLE rebelwalls_catalog ADD COLUMN IF NOT EXISTS activated_at timestamptz;`);
}
function fetchRows() {
let where = "shopify_product_id IS NOT NULL AND shopify_product_id <> ''";
if (!REACT) where += ' AND activated_at IS NULL';
let lim = '';
if (CANARY) lim = `LIMIT ${parseInt(CANARY, 10)}`;
else if (LIMIT) lim = `LIMIT ${parseInt(LIMIT, 10)}`;
const raw = psql(
`SELECT id, dw_sku, shopify_product_id FROM rebelwalls_catalog ` +
`WHERE ${where} ORDER BY id ${lim};`
).trim();
if (!raw) return [];
return raw.split('\n').map(line => {
const f = line.split('\t');
return { id: f[0], dw_sku: f[1], pid: f[2] };
});
}
function markActivated(pgId) {
psql(`UPDATE rebelwalls_catalog SET activated_at = NOW() WHERE id = ${parseInt(pgId, 10)};`);
}
// --- GraphQL ops ----------------------------------------------------------
const Q_PROD = `query($id:ID!){
product(id:$id){
id status
variants(first:10){edges{node{
id sku inventoryPolicy
inventoryItem{ id tracked
inventoryLevels(first:10){edges{node{ location{id} quantities(names:["available"]){name quantity} }}}
}
}}}
}
}`;
const M_UPDATE_STATUS = `mutation($input:ProductInput!){
productUpdate(input:$input){ product{id status} userErrors{field message} }
}`;
const M_PUBLISH = `mutation($id:ID!,$input:[PublicationInput!]!){
publishablePublish(id:$id, input:$input){ userErrors{field message} }
}`;
const M_INV_TRACK = `mutation($id:ID!,$input:InventoryItemInput!){
inventoryItemUpdate(id:$id, input:$input){ inventoryItem{id tracked} userErrors{field message} }
}`;
const M_SET_QTY = `mutation($input:InventorySetQuantitiesInput!){
inventorySetQuantities(input:$input){
inventoryAdjustmentGroup{createdAt reason}
userErrors{field message}
}
}`;
let PUBLICATION_IDS = null;
async function loadPublications() {
const r = await gqlRetry(`{publications(first:50){edges{node{id name}}}}`, {}, 'publications');
// GMC source-fix (Steve policy, 2026-07-08): exclude "Google & YouTube"
// (publication 29646651457). Shopify auto-syncs minVariantPrice ($4.25 sample)
// to Merchant Center → price disapproval. Google is fed by the controlled TSV
// feed only. Rollback: delete the .filter() below.
PUBLICATION_IDS = r.json.data.publications.edges.map(e => e.node)
.filter(p => p.id !== 'gid://shopify/Publication/29646651457');
return PUBLICATION_IDS;
}
async function activateRow(row, locationId) {
const gid = `gid://shopify/Product/${row.pid}`;
const out = { pg_id: row.id, dw_sku: row.dw_sku, pid: row.pid, steps: {} };
// 1. fetch current state
const pr = await gqlRetry(Q_PROD, { id: gid }, 'product');
const prod = pr.json.data && pr.json.data.product;
if (!prod) throw new Error(`product ${row.pid} not found (deleted?)`);
// 1a. status -> ACTIVE (skip if already)
if (prod.status !== 'ACTIVE') {
if (!DRY) {
const r = await gqlRetry(M_UPDATE_STATUS, { input: { id: gid, status: 'ACTIVE' } }, 'status');
const ue = r.json.data.productUpdate.userErrors;
if (ue && ue.length) throw new Error('status: ' + JSON.stringify(ue));
}
out.steps.status = 'set-active';
} else out.steps.status = 'already-active';
// 2. publish to ALL publications
if (!DRY) {
const input = PUBLICATION_IDS.map(p => ({ publicationId: p.id }));
const r = await gqlRetry(M_PUBLISH, { id: gid, input }, 'publish');
const ue = r.json.data.publishablePublish.userErrors;
// tolerate "already published" style errors; surface real ones
if (ue && ue.length) {
const real = ue.filter(e => !/already/i.test(e.message || ''));
if (real.length) throw new Error('publish: ' + JSON.stringify(real));
}
}
out.steps.publish = `all(${PUBLICATION_IDS.length})`;
// 3. inventory = QTY on both variants
const variants = prod.variants.edges.map(e => e.node);
out.steps.inventory = [];
const setQ = []; // batch inventorySetQuantities
for (const v of variants) {
const itemId = v.inventoryItem.id;
// 3a. ensure tracked=true (required for quantities to surface)
if (!v.inventoryItem.tracked) {
if (!DRY) {
const r = await gqlRetry(M_INV_TRACK, { id: itemId, input: { tracked: true } }, 'track');
const ue = r.json.data.inventoryItemUpdate.userErrors;
if (ue && ue.length) throw new Error('track: ' + JSON.stringify(ue));
}
}
setQ.push({ inventoryItemId: itemId, locationId, quantity: QTY });
out.steps.inventory.push(v.sku);
}
// 3b. set quantities (one call per product covers both variants)
if (!DRY && setQ.length) {
const r = await gqlRetry(M_SET_QTY, {
input: {
name: 'available',
reason: 'correction',
ignoreCompareQuantity: true,
quantities: setQ,
},
}, 'setqty');
const ue = r.json.data.inventorySetQuantities.userErrors;
if (ue && ue.length) throw new Error('setqty: ' + JSON.stringify(ue));
}
return out;
}
// Discover the store location id from an already-pushed variant's inventory level.
async function discoverLocation(samplePid) {
const r = await gqlRetry(Q_PROD, { id: `gid://shopify/Product/${samplePid}` }, 'loc');
const prod = r.json.data.product;
for (const e of prod.variants.edges) {
const lvls = e.node.inventoryItem.inventoryLevels.edges;
if (lvls.length) return lvls[0].node.location.id;
}
throw new Error('could not discover location id from sample product');
}
(async () => {
if (!CANARY && !DO_ALL) { console.error('specify --canary N | --all'); process.exit(1); }
ensureColumn();
await loadPublications();
console.log(`[activate] publications: ${PUBLICATION_IDS.map(p => p.name).join(', ')}`);
const rows = fetchRows();
console.log(`[activate] ${rows.length} pushed rows to activate (dry=${DRY}, reactivate=${REACT})`);
if (!rows.length) { console.log('nothing to do'); return; }
const locationId = await discoverLocation(rows[0].pid);
console.log(`[activate] location: ${locationId} inventory target: ${QTY}`);
let ok = 0, fail = 0, variantsSet = 0;
const failures = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
try {
const res = await activateRow(row, locationId);
if (!DRY) markActivated(row.id);
variantsSet += res.steps.inventory.length;
ok++;
fs.appendFileSync(ACT_LOG, JSON.stringify({ ts: new Date().toISOString(), ...res }) + '\n');
if (ok % 25 === 0 || i === rows.length - 1)
console.log(` activated ${ok}/${rows.length} (last ${row.dw_sku}; ${variantsSet} variants @${QTY})`);
} catch (e) {
fail++;
failures.push({ pg_id: row.id, dw_sku: row.dw_sku, pid: row.pid, error: e.message });
console.error(` FAIL [${row.id}] ${row.dw_sku}: ${e.message}`);
fs.appendFileSync(ACT_LOG, JSON.stringify({ ts: new Date().toISOString(), pg_id: row.id, dw_sku: row.dw_sku, error: e.message }) + '\n');
}
if (!DRY) await sleep(250);
}
console.log(`\n[activate] DONE ok=${ok} fail=${fail} variants-set=${variantsSet}`);
if (failures.length) {
fs.writeFileSync(path.join(LOG_DIR, 'activate-failures.json'), JSON.stringify(failures, null, 2));
console.log(' failures -> data/activate-failures.json');
}
})().catch(e => { console.error('FATAL', e); process.exit(1); });