← back to Dw Rotation Activator

rotate-activate.js

398 lines

#!/usr/bin/env node
/*
 * rotate-activate.js — the ROTATION ACTIVATOR.
 *
 * Companion to dw-five-field-step0/bulk-fivefield-exec.py. That drain builds the
 * MISSING Sample/roll variants for the ~5k drafts that need field-fixes. THIS
 * activator handles the OTHER ~10k drafts that are ALREADY 5-field-complete and
 * just need to go live — plus any field-fixed product that now passes the gate.
 *
 * Each run it takes the next N drafts from the CANONICAL ordered queue
 * (lib/rotation-order.js — textures-first + vendor round-robin, identical to the
 * calendar's projection), RE-VERIFIES the 5-field gate LIVE per product
 * (sample variant + sellable variant + price>0 + description + >=2 tags + width),
 * and ONLY flips status→ACTIVE + adds the 'New Arrival' tag + publishes to the
 * Online Store on a PASS. Incomplete products are SKIPPED and logged (never
 * activate a broken product) — they'll be picked up once the field-fix drain
 * completes them, and re-tried on the next rotation pass.
 *
 * HARD RAILS:
 *   - Never touches Schumacher (excluded at the SQL source — internal-only).
 *   - Never activates without image AND width AND real price AND sample variant.
 *   - Daily activation cap (default 500/day) via its OWN date-stamped ledger
 *     (activation ≠ variant-create, so it does NOT touch budget.cjs; the two
 *     lanes are independent and neither starves the other).
 *   - Idempotent + resumable: already-ACTIVE products fall out of the DRAFT query;
 *     the audit JSONL records every decision.
 *   - Never mass-activate: per-run cap (--max) drives a small per-slot batch.
 *   - GMC exclusion: publishes to all channels EXCEPT Google & YouTube (the $4.25
 *     sample-price-leak rule), mirroring activate-gated.js.
 *
 * Store = designer-laboratory-sandbox.myshopify.com (LIVE), API 2024-10.
 *
 * Usage:
 *   node rotate-activate.js --dry-run [--max N]   # project next N, no writes
 *   node rotate-activate.js --max N --commit      # activate up to N gate-passers
 *   node rotate-activate.js --commit              # use the daily activation remainder
 */
'use strict';
const https = require('https');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const { ROTATION_ORDER_SQL } = require('./lib/rotation-order.js');
const { SettlementGate } = require('./lib/settlement-gate.js');
const { validateBeforeActivate, toImageList } =
  require(path.join(os.homedir(), 'Projects/Designer-Wallcoverings/shopify/scripts/lib/validate-before-activate.js'));

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 COMMIT = flag('--commit');
const DRY = flag('--dry-run') || !COMMIT;   // safe-by-default: no --commit ⇒ dry-run
const CLI_MAX = val('--max', null);

const STORE = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const OUTDIR = path.join(__dirname, 'out');
const TODAY = new Date().toISOString().slice(0, 10);
const AUDIT = path.join(OUTDIR, `rotation-activations-${TODAY}.jsonl`);
// Daily activation ledger — its OWN lane (activation is not a variant-create, so it
// must not debit budget.cjs). Simple date-stamped counter file.
const LEDGER = path.join(OUTDIR, `activation-ledger-${TODAY}.json`);
const DAILY_ACTIVATION_CAP = parseInt(process.env.DW_ACTIVATION_CAP || '500', 10);
const GOOGLE_PUBLICATION_ID = 'gid://shopify/Publication/29646651457';

fs.mkdirSync(OUTDIR, { recursive: true });

const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function psql(sql) {
  return execFileSync('psql', ['-At', '-F', '\t', '-d', 'dw_unified', '-c', sql],
    { encoding: 'utf8', maxBuffer: 1 << 28 }).trim();
}

function gql(query, variables) {
  return new Promise((res) => {
    const data = JSON.stringify({ query, variables });
    const req = https.request({
      host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(data) },
    }, (r) => { let d = ''; r.on('data', (c) => d += c);
      r.on('end', () => { try { res({ status: r.statusCode, json: JSON.parse(d) }); }
        catch { res({ status: r.statusCode, raw: d.slice(0, 400) }); } }); });
    req.on('error', (e) => res({ status: 0, err: e.message }));
    req.write(data); req.end();
  });
}
async function gqlRetry(q, v) {
  for (let a = 0; a < 5; a++) {
    const r = await gql(q, v);
    const t = r.json && r.json.errors && JSON.stringify(r.json.errors).includes('THROTTLED');
    if (r.status === 429 || t) { await sleep(2000 * (a + 1)); continue; }
    await sleep(300); return r;
  }
  return { status: 429, raw: 'throttled' };
}

// ── daily activation ledger (own lane) ────────────────────────────────────────
function ledgerUsed() {
  try { return JSON.parse(fs.readFileSync(LEDGER, 'utf8')).used || 0; } catch { return 0; }
}
function ledgerBump(n) {
  const used = ledgerUsed() + n;
  try { fs.writeFileSync(LEDGER, JSON.stringify({ date: TODAY, used })); } catch (_) {}
  return used;
}

// ── publish helpers (mirror activate-gated.js; GMC-excluded) ──────────────────
let PUBS = null;
async function loadPubs() {
  if (PUBS) return PUBS;
  const r = await gqlRetry(`{publications(first:50){edges{node{id name}}}}`, {});
  PUBS = (r.json?.data?.publications?.edges || []).map((e) => e.node)
    .filter((p) => p.id !== GOOGLE_PUBLICATION_ID);
  return PUBS;
}
async function publishToChannels(pid) {
  const pubs = await loadPubs();
  if (!pubs.length) return { published: false, why: 'no-publications-scope' };
  const input = pubs.map((p) => ({ publicationId: p.id }));
  const r = await gqlRetry(
    `mutation($id:ID!,$input:[PublicationInput!]!){publishablePublish(id:$id,input:$input){userErrors{field message}}}`,
    { id: pid, input });
  const ue = r.json?.data?.publishablePublish?.userErrors || [];
  const real = ue.filter((e) => !/already|cannot be published to itself/i.test(e.message || ''));
  if (real.length) return { published: false, errors: real };
  return { published: true, channels: pubs.length };
}

// ── load the canonical ordered DRAFT queue (textures-first + round-robin) ──────
// Enrich each row from its vendor staging table would be costly across 62 vendors;
// instead we re-verify the gate from the LIVE Shopify product (images, variants,
// metafields, body, tags) which is authoritative at activation time. The order
// and identity come from shopify_products via lib/rotation-order.js.
function loadQueue() {
  const raw = psql(ROTATION_ORDER_SQL);
  if (!raw) return [];
  return raw.split('\n').map((l) => {
    const [shopify_id, vendor, dw_sku, title, product_type, mat_tier, rr] = l.split('\t');
    return { shopify_id, vendor, dw_sku, title, product_type,
      mat_tier: parseInt(mat_tier, 10), rr: parseInt(rr, 10) };
  });
}

const STATUS_Q = `query($ids:[ID!]!){nodes(ids:$ids){... on Product{
  id status title vendor descriptionHtml tags
  images(first:50){nodes{url}}
  variants(first:20){nodes{price sku}}
  metafields(first:80){nodes{namespace key value}}
}}}`;
const mfVal = (mfs, ns, key) => { const m = (mfs || []).find((x) => x.namespace === ns && x.key === key); return m ? m.value : ''; };
const ACTIVATE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){product{id status} userErrors{field message}}}`;
const TAGS_ADD = `mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{field message}}}`;
// internal.settlement_hold metafield — marks a DRAFT held by the settlement gate.
const SET_HOLD = `mutation($mfs:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mfs){userErrors{field message}}}`;
async function setSettlementHold(pid, verdict, reason) {
  const value = JSON.stringify({ verdict, reason: String(reason).slice(0, 400),
    ts: new Date().toISOString(), by: 'dw-rotation-activator' });
  return gqlRetry(SET_HOLD, { mfs: [{ ownerId: pid, namespace: 'internal', key: 'settlement_hold',
    type: 'json', value }] });
}
// primary image + material from the live product node (reused by the gate).
function primaryImageUrl(n) { return (n.images?.nodes || [])[0]?.url || ''; }
function materialFromNode(n) {
  const mfs = n.metafields?.nodes || [];
  return mfVal(mfs, 'global', 'material') || mfVal(mfs, 'custom', 'material') || mfVal(mfs, 'specs', 'material') || '';
}

function pidToGid(shopify_id) {
  // shopify_id already looks like gid://shopify/Product/NNN in this table.
  return shopify_id.startsWith('gid://') ? shopify_id : `gid://shopify/Product/${String(shopify_id).replace(/.*\//, '')}`;
}

// Build the validate-before-activate shape from the LIVE product node and re-run
// the SINGLE canonical gate. This is the "re-verify 5-field gate live" step.
function gateFromLive(n, dwSku, vendor) {
  const mfs = n.metafields?.nodes || [];
  const liveImgs = (n.images?.nodes || []).map((x) => x.url);
  const width = mfVal(mfs, 'global', 'width') || mfVal(mfs, 'custom', 'width');
  const material = mfVal(mfs, 'global', 'material') || mfVal(mfs, 'custom', 'material') || mfVal(mfs, 'specs', 'material');
  const specs = {
    width, length: mfVal(mfs, 'global', 'length'), repeat: mfVal(mfs, 'global', 'repeat'),
    material, unitOfMeasure: mfVal(mfs, 'global', 'unit_of_measure') || '',
  };
  return validateBeforeActivate({
    title: n.title, dwSku: dwSku || '', vendor, tags: n.tags || [],
    descriptionHtml: n.descriptionHtml || '',
    specs,
    // vendorSpecs mirrors specs: we treat what's present on the live product as the
    // provided set (we're not blocking on a spec the vendor genuinely lacks; width
    // stays hard-required inside the validator).
    vendorSpecs: specs,
    images: liveImgs, vendorImages: liveImgs,
    variants: (n.variants?.nodes || []).map((v) => ({ sku: v.sku })),
  });
}

// Extra "5-field" invariants the prompt calls out explicitly, layered on top of
// the canonical gate: a sellable (non-sample) variant with price>0, a sample
// variant, and >=2 tags. (The canonical gate already covers width+image+desc+
// sample+title-guards; this makes the price>0 + sellable-variant + >=2-tags
// requirement explicit and independent of vendor quote-only exemptions.)
function fiveFieldExtra(n) {
  const variants = n.variants?.nodes || [];
  const hasSample = variants.some((v) => /-sample$/i.test(v.sku || ''));
  const sellable = variants.filter((v) => !/-sample$/i.test(v.sku || ''));
  const hasSellablePriced = sellable.some((v) => parseFloat(v.price) > 0);
  const isQuoteOnly = (n.tags || []).includes('quotes');
  const tagCount = (n.tags || []).length;
  const reasons = [];
  if (!hasSample) reasons.push('no-sample-variant');
  // Designated quote-only lines (the `quotes` tag — e.g. Koroseal, Vahallan) legitimately
  // ship sample-only: the customer requests a per-project quote, so there is NO sellable
  // (non-sample) variant AND no roll price. Exempt BOTH the sellable-variant and the
  // price>0 requirements for them (2026-07-23, per memory quote-only-ok-lines /
  // no-cost-no-sellable-variant). Non-quote lines are unaffected — they still require both.
  if (!sellable.length && !isQuoteOnly) reasons.push('no-sellable-variant');
  if (!hasSellablePriced && !isQuoteOnly) reasons.push('sellable-price-not-gt-0');
  if (tagCount < 2) reasons.push('fewer-than-2-tags');
  return { ok: reasons.length === 0, reasons };
}

// ── PRIVATE-LABEL LEAK GUARD ──────────────────────────────────────────────
// A product must NEVER go customer-facing (DRAFT→ACTIVE) with the upstream SOURCE
// name in its title or vendor. Durable fix for the 2026-07-21 incident where the
// activator flipped Greenland cork duplicates ACTIVE with "| Greenland" still in
// the title (vendor="Greenland" should be "Phillipe Romano"). Fail-safe: this can
// only BLOCK an activation, never cause one.
//
// SELF-CONTAINED COPY of the canonical denylist — kept decoupled by design
// (DTD verdict B, 2026-07-21: a ~/Projects app must not cross-import a portable
// ~/.claude/skills dir). CANONICAL SOURCE OF TRUTH:
//   ~/.claude/skills/dw-leak-scanner/denylist.json  (+ memory dw-leak-scanner)
// When that denylist changes, mirror the change here.
//
// HARD tokens are never a legitimate pattern/color name → checked in title AND vendor.
// VENDOR-only tokens can appear as a real pattern/place name (e.g. a "Chesapeake"
// pattern or a "York"/"Seabrook" motif) → blocked only when they are the VENDOR
// (the source), never merely a word in the title. ('schumacher' is intentionally
// omitted — the denylist treats it as a legitimately-shown repped brand, not a leak.)
// WORD-BOUNDARY matched (\btoken\b), NOT substring — critical so 'versa' matches
// "Versa Designed Surfaces" (a Momentum leak) but NOT "Versace" (a legit luxury
// brand DW sells), and 'york' matches the York source but not "Yorkshire".
const PL_LEAK_HARD = ['wallquest', 'nextwall', 'command54', 'command 54', 'desima', 'carlsten',
  'greenland', 'yorkwall', 'lillian august', 'nicolette mayer', 'versa designed surfaces',
  'rigo', 'rigowall', 'rigo wallcovering'];
const PL_LEAK_VENDOR_ONLY = ['chesapeake', 'seabrook', 'brewster', 'york', 'momentum', 'versa'];
const _wb = (tok) => new RegExp('\\b' + tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i');
const _HARD_RE = PL_LEAK_HARD.map((t) => [t, _wb(t)]);       // [token, RegExp] pairs
const _VENDOR_RE = PL_LEAK_VENDOR_ONLY.map((t) => [t, _wb(t)]); // [token, RegExp] pairs
function leakGuard(title, vendor) {
  const t = title || '', v = vendor || '';
  for (const [tok, re] of _HARD_RE) if (re.test(t) || re.test(v)) return { ok: false, reason: `private-label-leak:${tok}` };
  for (const [tok, re] of _VENDOR_RE) if (re.test(v)) return { ok: false, reason: `private-label-leak:${tok}` };
  return { ok: true };
}

(async () => {
  const queue = loadQueue();
  const tier0 = queue.filter((q) => q.mat_tier === 0).length;
  console.log(`queue: ${queue.length} DRAFTs in canonical order (tier0 textures=${tier0}, rest=${queue.length - tier0})`);

  // SETTLEMENT GATE — construct once (runs lock.sh fail-closed). Plain textures
  // auto-pass at $0; motif/print/botanical products are Gemini-vision-gated.
  const settlement = new SettlementGate();
  const ls = settlement.lockStatus();
  console.log(`settlement gate: lock=${ls.ok ? 'PASS' : 'FAIL (' + ls.msg + ')'} — ` +
    `${ls.ok ? 'motif products vision-gated' : 'motif products HELD this run (textures still pass)'}`);
  let settlementHeld = 0, settlementBlocked = 0;

  // budget: activation lane
  const used = ledgerUsed();
  let cap;
  if (DRY) {
    cap = CLI_MAX != null ? parseInt(CLI_MAX, 10) : 50;
  } else {
    const remaining = Math.max(0, DAILY_ACTIVATION_CAP - used);
    const want = CLI_MAX != null ? parseInt(CLI_MAX, 10) : remaining;
    cap = Math.min(want, remaining);
    if (cap <= 0) {
      console.log(`[budget] daily activation cap reached (${used}/${DAILY_ACTIVATION_CAP}). Nothing to do this run.`);
      return;
    }
    console.log(`[budget] activation lane: used ${used}/${DAILY_ACTIVATION_CAP} today → activating up to ${cap} this run.`);
  }
  console.log(`mode=${DRY ? 'DRY-RUN' : 'COMMIT'}  cap_this_run=${cap}\n`);

  let activated = 0, published = 0, skipped = 0, alreadyActive = 0, scanned = 0;
  const projection = []; // for dry-run reporting

  // We walk the ordered queue, batch-fetching live status 50 at a time, and stop
  // as soon as we've ACTIVATED (or, in dry-run, projected) `cap` gate-passers.
  for (let i = 0; i < queue.length; i += 50) {
    if (activated >= cap) break;
    const batch = queue.slice(i, i + 50);
    const byGid = new Map(batch.map((q) => [pidToGid(q.shopify_id), q]));
    const r = await gqlRetry(STATUS_Q, { ids: [...byGid.keys()] });
    for (const n of (r.json?.data?.nodes || [])) {
      if (activated >= cap) break;
      if (!n) continue;
      const q = byGid.get(n.id);
      scanned++;
      if (n.status === 'ACTIVE') { alreadyActive++; continue; }
      if (n.status !== 'DRAFT') { continue; }   // ARCHIVED → leave (never un-archive)

      const gate = gateFromLive(n, q && q.dw_sku, q && q.vendor);
      const extra = fiveFieldExtra(n);
      const leak = leakGuard(n.title, q && q.vendor);
      const passes = gate.ok && extra.ok && leak.ok;
      const rec = { ts: new Date().toISOString(), shopify_id: n.id, vendor: q && q.vendor,
        dw_sku: q && q.dw_sku, mat_tier: q && q.mat_tier, rr: q && q.rr,
        title: n.title, passes,
        reasons: [...(gate.ok ? [] : gate.reasons), ...(extra.ok ? [] : extra.reasons),
                  ...(leak.ok ? [] : [leak.reason])] };

      if (!passes) {
        skipped++;
        // NEVER activate a broken product — log + skip. It flows through the
        // field-fix drain and is re-tried on the next rotation pass.
        fs.appendFileSync(AUDIT, JSON.stringify({ ...rec, action: 'skip-gate' }) + '\n');
        continue;
      }

      // ── SETTLEMENT GATE (runs AFTER the 5-field gate, BEFORE any DRAFT→ACTIVE) ──
      // Plain textures auto-pass at $0; everything else is Gemini-vision-checked.
      // Only verdict === 'PASS' may proceed; BLOCK/HELD leave the product DRAFT,
      // set internal.settlement_hold, and log to the activator + orchestrator audits.
      const sv = await settlement.evaluate({
        shopify_id: n.id, title: n.title, vendor: q && q.vendor, dw_sku: q && q.dw_sku,
        material: materialFromNode(n), imageUrl: primaryImageUrl(n),
      });
      rec.settlement = { verdict: sv.verdict, tier: sv.tier, reason: sv.reason, cost: sv.cost };
      if (sv.verdict !== 'PASS') {
        if (sv.verdict === 'BLOCK') settlementBlocked++; else settlementHeld++;
        skipped++;
        // hold + skip. In COMMIT mode set the metafield so the hold is durable on
        // the product; in DRY mode we only record it (no writes).
        if (!DRY) { try { await setSettlementHold(n.id, sv.verdict, sv.reason); } catch (_) {} }
        fs.appendFileSync(AUDIT, JSON.stringify({ ...rec,
          action: sv.verdict === 'BLOCK' ? 'settlement-block' : 'settlement-hold' }) + '\n');
        continue;
      }

      if (DRY) {
        projection.push(rec);
        activated++; // count projected activations toward the cap so the dry-run shows the real next-N
        fs.appendFileSync(AUDIT, JSON.stringify({ ...rec, action: 'dryrun-would-activate' }) + '\n');
        continue;
      }

      // COMMIT: flip → ACTIVE, add 'New Arrival', publish to channels (ex-Google).
      const ar = await gqlRetry(ACTIVATE, { id: n.id });
      const aue = ar.json?.data?.productUpdate?.userErrors;
      if (aue && aue.length) {
        skipped++;
        fs.appendFileSync(AUDIT, JSON.stringify({ ...rec, action: 'activate-error', errors: aue }) + '\n');
        continue;
      }
      activated++;
      ledgerBump(1);
      // add 'New Arrival' tag (idempotent — tagsAdd is a no-op if present)
      await gqlRetry(TAGS_ADD, { id: n.id, tags: ['New Arrival'] });
      // publish to Online Store (+ others, minus Google)
      let pub = false;
      try { const p = await publishToChannels(n.id); pub = !!p.published;
        if (!pub) console.log(`  ⚠ ${q && q.dw_sku} ACTIVE but publish failed: ${JSON.stringify(p.errors || p.why).slice(0, 120)}`);
      } catch (e) { console.log(`  ⚠ ${q && q.dw_sku} publish error: ${String(e.message).slice(0, 120)}`); }
      if (pub) published++;
      fs.appendFileSync(AUDIT, JSON.stringify({ ...rec, action: 'activated', published: pub, tag: 'New Arrival' }) + '\n');
      if (activated % 25 === 0) process.stdout.write(`\r  activated ${activated}/${cap}…`);
      await sleep(350);
    }
    await sleep(150);
  }

  const sstat = settlement.stats();
  console.log(`\n=== ROTATION ACTIVATOR ${DRY ? 'DRY-RUN' : 'RUN'} DONE ===`);
  console.log(`scanned=${scanned}  ${DRY ? 'would-activate' : 'activated'}=${activated}  published=${published}  skipped(gate-fail)=${skipped}  alreadyActive=${alreadyActive}`);
  console.log(`settlement: auto-pass-textures=${sstat.autoPassCount}  vision-calls=${sstat.visionCalls}  blocked=${settlementBlocked}  held=${settlementHeld}`);
  console.log(`settlement cost: textures $0 (local) + vision ${sstat.visionCalls} calls = $${sstat.visionCostTotal.toFixed(5)} (Gemini 2.5-flash; ~$0.0006/img)`);
  if (!DRY) console.log(`daily activation ledger: ${ledgerUsed()}/${DAILY_ACTIVATION_CAP}`);
  console.log(`audit → ${AUDIT}`);

  if (DRY && projection.length) {
    console.log(`\n--- next ${Math.min(projection.length, 50)} activations (proof: textures lead, vendors interleave) ---`);
    const pad = (s, n) => String(s == null ? '' : s).padEnd(n).slice(0, n);
    console.log(pad('#', 4) + pad('tier', 5) + pad('vendor', 24) + 'title');
    projection.slice(0, 50).forEach((p, idx) =>
      console.log(pad(idx + 1, 4) + pad(p.mat_tier === 0 ? 'TEX' : '·', 5) + pad(p.vendor, 24) + String(p.title || p.dw_sku || '').slice(0, 44)));
  }
})().catch((e) => { console.error('rotate-activate failed:', e.message); process.exit(1); });