← back to Koroseal Quote Only

scripts/gmc-exclude-koroseal.js

131 lines

#!/usr/bin/env node
/**
 * gmc-exclude-koroseal.js — TK-11106 (Koroseal Option A, Steve-approved 2026-09-02)
 *
 * Fix the ~212 ACTIVE Koroseal products whose ONLY sellable price is the $4.25
 * sample (sample-price leak → GMC price-mismatch disapproval risk) by
 * unpublishing them from the "Google & YouTube" channel. Mirrors the executed
 * Fentucci Option-A precedent (2026-08-19) and gmc-exclude-majilite.js.
 *
 * SAFETY:
 *  - Phase 1 VERIFY: live-fetches every target; a product is only eligible if
 *    live status=ACTIVE, min variant price <= 4.25, no real-priced variant, and
 *    currently published on Google & YouTube. Writes data/rollback-map.json
 *    BEFORE any write (undo = publishablePublish each id back).
 *  - Phase 2 APPLY (--apply): batches of 50, ~350ms between mutations, >=90s gap
 *    between batches (store-wide bulk-push rule; sibling agents write
 *    concurrently), per-batch re-verify isPublished=false.
 *
 * Usage:
 *   node scripts/gmc-exclude-koroseal.js            # DRY-RUN: verify + rollback map only
 *   node scripts/gmc-exclude-koroseal.js --apply    # approved live unpublish
 *
 * Cost: $0 (Shopify Admin API only).
 */
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

const ROOT = path.resolve(__dirname, '..');
const STORE = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
  const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
  return (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].replace(/["']/g, '').trim();
})();
const URL = `https://${STORE}/admin/api/${API}/graphql.json`;
const GOOGLE_PUB = 'gid://shopify/Publication/29646651457'; // Google & YouTube

const APPLY = process.argv.includes('--apply');
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function gql(query, variables) {
  for (let attempt = 0; attempt < 6; attempt++) {
    const res = await fetch(URL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
    const j = await res.json();
    if (j.errors) { if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (attempt + 1)); continue; } throw new Error(JSON.stringify(j.errors).slice(0, 300)); }
    return j.data;
  }
  throw new Error('throttled after retries');
}

function targetIds() {
  // The approved 212 set: local mirror, vendor=Koroseal, ACTIVE, price=4.25
  const out = execSync(
    `psql "host=/tmp dbname=dw_unified" -Atc "SELECT shopify_id FROM shopify_products WHERE vendor='Koroseal' AND status='ACTIVE' AND price=4.25 ORDER BY shopify_id"`,
    { encoding: 'utf8' });
  return out.split('\n').filter(Boolean);
}

async function verifyOne(gid) {
  const d = await gql(`query($id:ID!){ product(id:$id){ id handle title status vendor
    variants(first:10){ nodes { price title sku } }
    resourcePublicationsV2(first:25){ nodes { publication { id name } isPublished } } } }`, { id: gid });
  const p = d.product;
  if (!p) return { id: gid, eligible: false, reason: 'not found live' };
  const prices = p.variants.nodes.map(v => Number(v.price));
  const minP = Math.min(...prices), maxP = Math.max(...prices);
  const onGoogle = p.resourcePublicationsV2.nodes.some(n => n.publication.id === GOOGLE_PUB && n.isPublished);
  const eligible = p.status === 'ACTIVE' && p.vendor === 'Koroseal' && maxP <= 4.25 && onGoogle;
  return { id: gid, handle: p.handle, status: p.status, vendor: p.vendor, minPrice: minP, maxPrice: maxP, onGoogle, eligible,
    reason: eligible ? 'ok' : (!onGoogle ? 'not on Google (already excluded)' : p.status !== 'ACTIVE' ? 'not active' : p.vendor !== 'Koroseal' ? 'vendor mismatch' : 'has real-priced variant — SKIP (do not hide a sellable product)') };
}

(async () => {
  console.log(`gmc-exclude-koroseal ${APPLY ? 'APPLY (LIVE)' : 'DRY-RUN'} → ${STORE}`);
  const ids = targetIds();
  console.log(`mirror targets: ${ids.length}`);

  // ---- Phase 1: VERIFY every target live ----
  const verified = [];
  for (let i = 0; i < ids.length; i++) {
    verified.push(await verifyOne(ids[i]));
    if (i % 25 === 24) { process.stdout.write(`  verified ${i + 1}/${ids.length}\n`); await sleep(800); }
    else await sleep(150);
  }
  const eligible = verified.filter(v => v.eligible);
  const skipped = verified.filter(v => !v.eligible);
  fs.writeFileSync(path.join(ROOT, 'data/verify-report.json'), JSON.stringify({ at: new Date().toISOString(), total: ids.length, eligible: eligible.length, skipped }, null, 2));

  // ---- Rollback map BEFORE any write ----
  const rollback = {
    at: new Date().toISOString(), ticket: 'TK-11106', channel: 'Google & YouTube', publicationId: GOOGLE_PUB,
    undo: 'for each id: mutation { publishablePublish(id:$id, input:[{publicationId:"' + GOOGLE_PUB + '"}]) } — see scripts/rollback-gmc-exclude.js',
    ids: eligible.map(e => ({ id: e.id, handle: e.handle, wasPublishedGoogle: true })),
  };
  fs.writeFileSync(path.join(ROOT, 'data/rollback-map.json'), JSON.stringify(rollback, null, 2));
  console.log(`eligible=${eligible.length} skipped=${skipped.length} → data/verify-report.json + data/rollback-map.json`);

  if (!APPLY) { console.log('DRY-RUN complete. No writes.'); return; }

  // ---- Phase 2: APPLY in batches of 50, >=90s between batches ----
  const BATCH = 50;
  let done = 0, failed = 0; const runs = [];
  for (let b = 0; b * BATCH < eligible.length; b++) {
    const batch = eligible.slice(b * BATCH, (b + 1) * BATCH);
    console.log(`batch ${b + 1}: ${batch.length} products`);
    for (const w of batch) {
      try {
        const d = await gql(`mutation($id:ID!,$pid:ID!){ publishableUnpublish(id:$id, input:[{publicationId:$pid}]){ userErrors{ field message } } }`, { id: w.id, pid: GOOGLE_PUB });
        const ue = d.publishableUnpublish.userErrors;
        if (ue && ue.length) { failed++; runs.push({ id: w.id, ok: false, err: ue }); }
        else { done++; runs.push({ id: w.id, ok: true }); }
      } catch (e) { failed++; runs.push({ id: w.id, ok: false, err: e.message.slice(0, 200) }); }
      await sleep(350);
    }
    // per-batch verify
    let stillOn = 0;
    for (const w of batch) {
      const d = await gql(`query($id:ID!){ product(id:$id){ resourcePublicationsV2(first:25){ nodes { publication { id } isPublished } } } }`, { id: w.id });
      const on = d.product && d.product.resourcePublicationsV2.nodes.some(n => n.publication.id === GOOGLE_PUB && n.isPublished);
      if (on) stillOn++;
      await sleep(150);
    }
    console.log(`  batch ${b + 1} verify: still-on-Google=${stillOn} (expect 0)`);
    if ((b + 1) * BATCH < eligible.length) { console.log('  90s inter-batch gap…'); await sleep(90000); }
  }
  fs.mkdirSync(path.join(ROOT, 'data/runs'), { recursive: true });
  fs.writeFileSync(path.join(ROOT, 'data/runs', `gmc-${new Date().toISOString().replace(/[:.]/g, '-')}.json`), JSON.stringify({ done, failed, runs }, null, 2));
  console.log(`DONE. unpublished=${done} failed=${failed}`);
})();