← back to Dw Add Sellable Variant Tk10902

activate-cohort.mjs

102 lines

// TK-10456 — TARGETED activator for the 794 cohort ONLY (never vendor-wide, to
// avoid activating the ~7.7k other quotes-tagged PR/PF drafts). For each cohort
// product that now has a real sellable price (just built), it runs the 5-field
// gate then DRAFT→ACTIVE and publishes to all sales channels EXCEPT Google&YouTube
// (the GMC exclusion), mirroring activate-gated.js. Reversible + ledgered.
//
// Usage: node activate-cohort.mjs <candidates.json> [--limit N] [--live]
//   default = DRY-RUN (no writes). --live activates + publishes.
import fs from 'fs';
import { execSync } from 'child_process';

const TOKEN = execSync(`grep -E '^SHOPIFY_ADMIN_TOKEN=' ${process.env.HOME}/Projects/secrets-manager/.env | cut -d= -f2-`).toString().trim();
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
const LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;
const RUNLOG = `${process.cwd()}/activate-run-log.jsonl`;
const GOOGLE_PUBLICATION_ID = 'gid://shopify/Publication/29646651457'; // GMC — excluded

const args = process.argv.slice(2);
const file = args.find(a => !a.startsWith('--'));
const LIVE = args.includes('--live');
const limArg = args.find(a => a.startsWith('--limit'));
const LIMIT = limArg ? parseInt(args[args.indexOf(limArg) + 1], 10) : Infinity;

async function rest(path, method = 'GET', body = null) {
  for (let i = 0; i < 4; i++) {
    try {
      const res = await fetch(`https://${DOMAIN}/admin/api/2024-10${path}`, {
        method, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
        body: body ? JSON.stringify(body) : undefined });
      return { status: res.status, json: await res.json().catch(() => ({})) };
    } catch (e) { await new Promise(r => setTimeout(r, 2500)); }
  }
  return { status: 0, json: {} };
}
async function gql(query, variables) {
  for (let i = 0; i < 4; i++) {
    try {
      const res = await fetch(`https://${DOMAIN}/admin/api/2024-10/graphql.json`, {
        method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
        body: JSON.stringify({ query, variables }) });
      return await res.json().catch(() => ({}));
    } catch (e) { await new Promise(r => setTimeout(r, 2500)); }
  }
  return {}; // network kept failing — treat as publish-unconfirmed, don't crash the run
}
const appendJsonl = (p, o) => fs.appendFileSync(p, JSON.stringify(o) + '\n');

// 5-field gate (mirrors validateBeforeActivate essentials): sample + sellable priced>4.25 + body + >=2 tags + image
function fiveField(p) {
  const vs = p.variants || [];
  const hasSample = vs.some(v => /-sample$/i.test((v.sku || '')));
  const sellablePriced = vs.some(v => !/-sample$/i.test((v.sku || '')) && parseFloat(v.price) > 4.25);
  const body = (p.body_html || '').trim().length > 0;
  const tags = (p.tags || '').split(',').map(t => t.trim()).filter(Boolean);
  const img = (p.images || []).length > 0 || !!p.image;
  const reasons = [];
  if (!hasSample) reasons.push('no-sample');
  if (!sellablePriced) reasons.push('no-real-sellable-price');
  if (!body) reasons.push('no-description');
  if (tags.length < 2) reasons.push('fewer-than-2-tags');
  if (!img) reasons.push('no-image');
  return { ok: reasons.length === 0, reasons };
}

let PUBS = null;
async function loadPubs() {
  const r = await gql(`{publications(first:50){edges{node{id name}}}}`, {});
  PUBS = (r.data?.publications?.edges || []).map(e => e.node).filter(p => p.id !== GOOGLE_PUBLICATION_ID);
}
const M_PUBLISH = `mutation($id:ID!,$input:[PublicationInput!]!){publishablePublish(id:$id,input:$input){userErrors{message}}}`;

const cohort = JSON.parse(fs.readFileSync(file, 'utf8'));
const batch = cohort.slice(0, LIMIT);
if (LIVE) await loadPubs();
let activated = 0, skipped = 0, published = 0;
const skips = [];
console.log(`\n=== ${LIVE ? 'LIVE' : 'DRY-RUN'} activate — ${batch.length} cohort products (channels except GMC) ===\n`);

for (const c of batch) {
  const pid = c.product_id;
  const { status, json } = await rest(`/products/${pid}.json`);
  if (status !== 200 || !json.product) { skipped++; skips.push({ pid, reason: `fetch_${status}` }); continue; }
  const p = json.product;
  if (p.status === 'active') { skipped++; skips.push({ pid, reason: 'already_active', title: p.title }); continue; }
  const g = fiveField(p);
  if (!g.ok) { skipped++; skips.push({ pid, reason: g.reasons.join('+'), title: p.title }); console.log(`SKIP ${pid} (${p.vendor}) ${g.reasons.join('+')}`); continue; }
  if (!LIVE) { activated++; appendJsonl(RUNLOG, { ts: new Date().toISOString(), mode: 'dry', action: 'would_activate', pid, vendor: p.vendor, title: p.title }); console.log(`WOULD ACTIVATE ${pid} (${p.vendor}) ${p.title}`); continue; }
  const r = await rest(`/products/${pid}.json`, 'PUT', { product: { id: Number(pid), status: 'active' } });
  if (r.status !== 200) { skipped++; skips.push({ pid, reason: `activate_failed_${r.status}`, title: p.title }); continue; }
  const pr = await gql(M_PUBLISH, { id: `gid://shopify/Product/${pid}`, input: PUBS.map(x => ({ publicationId: x.id })) });
  const pubOk = !(pr.data?.publishablePublish?.userErrors || []).length;
  if (pubOk) published++;
  activated++;
  appendJsonl(RUNLOG, { ts: new Date().toISOString(), mode: 'live', action: 'activated', pid, vendor: p.vendor, title: p.title, published: pubOk });
  appendJsonl(LEDGER, { ts: new Date().toISOString(), agent: 'claude-run-10456', ticket: 'TK-10456',
    action: `activate ${p.vendor} ${p.title} DRAFT->ACTIVE + publish (except GMC), real price (TK-10456 basis A)`,
    blast_radius: 1, undo_cmd: `PUT products/${pid} status=draft`, verify: `GET product ${pid} -> active` });
  console.log(`ACTIVATED ${pid} (${p.vendor}) ${p.title}${pubOk ? ' +published' : ' (publish err)'}`);
}
fs.writeFileSync(`${process.cwd()}/activate-skips.json`, JSON.stringify(skips, null, 1));
console.log(`\n=== ${LIVE ? 'ACTIVATED' : 'WOULD ACTIVATE'}: ${activated}  PUBLISHED: ${published}  SKIPPED: ${skipped} ===`);