← 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); });