← back to Koroseal Quote Only

scripts/tag-koroseal-quotes.js

85 lines

#!/usr/bin/env node
/**
 * tag-koroseal-quotes.js — TK-11106 (Koroseal Option A, Steve-approved 2026-09-02)
 *
 * Quote-flow wiring, tag side: 2,362 of 2,449 ACTIVE Koroseal products already
 * carry the `quotes` trigger tag (the dw-quote-only-cta.liquid trigger + the
 * dw-five-field-canary exemption tag). This appends `quotes` + `Quote Only` to
 * the remaining untagged ACTIVE products so the whole line is wired uniformly.
 *
 * Append-only (never removes an existing tag). Idempotent (re-fetches live tags
 * before writing). Records before/after per product to data/tag-runs/<ts>.json —
 * undo = scripts/rollback-tags.js (removes ONLY the tags this run added).
 *
 * Usage:
 *   node scripts/tag-koroseal-quotes.js            # DRY-RUN
 *   node scripts/tag-koroseal-quotes.js --apply    # approved live write
 */
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 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/2024-10/graphql.json`;
const TRIGGER_TAGS = ['quotes', 'Quote Only'];
const APPLY = process.argv.includes('--apply');
const sleep = ms => new Promise(r => setTimeout(r, ms));

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

function targetIds() {
  const out = execSync(
    `psql "host=/tmp dbname=dw_unified" -Atc "SELECT shopify_id FROM shopify_products WHERE vendor='Koroseal' AND status='ACTIVE' AND (tags IS NULL OR tags NOT LIKE '%\\"quotes\\"%') ORDER BY shopify_id"`,
    { encoding: 'utf8' });
  return out.split('\n').filter(Boolean);
}

(async () => {
  console.log(`tag-koroseal-quotes ${APPLY ? 'APPLY (LIVE)' : 'DRY-RUN'} → ${STORE}`);
  const ids = targetIds();
  console.log(`mirror targets missing 'quotes': ${ids.length}`);
  const plan = [];
  for (let i = 0; i < ids.length; i++) {
    const d = await gql(`query($id:ID!){ product(id:$id){ id handle status vendor tags } }`, { id: ids[i] });
    const p = d.product;
    if (!p) { plan.push({ id: ids[i], eligible: false, reason: 'not found live' }); continue; }
    const missing = TRIGGER_TAGS.filter(t => !p.tags.includes(t));
    plan.push({ id: p.id, handle: p.handle, eligible: p.status === 'ACTIVE' && p.vendor === 'Koroseal' && missing.length > 0,
      beforeTags: p.tags, addTags: missing,
      reason: p.status !== 'ACTIVE' ? 'not active' : p.vendor !== 'Koroseal' ? 'vendor mismatch' : missing.length === 0 ? 'already tagged' : 'ok' });
    await sleep(120);
  }
  const eligible = plan.filter(x => x.eligible);
  fs.mkdirSync(path.join(ROOT, 'data/tag-runs'), { recursive: true });
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
  fs.writeFileSync(path.join(ROOT, 'data/tag-runs', `plan-${ts}.json`), JSON.stringify({ at: ts, eligible: eligible.length, plan }, null, 2));
  console.log(`eligible=${eligible.length} (plan → data/tag-runs/plan-${ts}.json)`);
  if (!APPLY) { console.log('DRY-RUN complete. No writes.'); return; }

  let done = 0, failed = 0; const runs = [];
  for (const w of eligible) {
    try {
      const d = await gql(`mutation($id:ID!,$tags:[String!]!){ tagsAdd(id:$id, tags:$tags){ userErrors{ field message } } }`, { id: w.id, tags: w.addTags });
      const ue = d.tagsAdd.userErrors;
      if (ue && ue.length) { failed++; runs.push({ id: w.id, ok: false, err: ue }); }
      else { done++; runs.push({ id: w.id, ok: true, added: w.addTags, beforeTags: w.beforeTags }); }
    } catch (e) { failed++; runs.push({ id: w.id, ok: false, err: e.message.slice(0, 200) }); }
    await sleep(350);
  }
  fs.writeFileSync(path.join(ROOT, 'data/tag-runs', `run-${ts}.json`), JSON.stringify({ done, failed, runs }, null, 2));
  console.log(`DONE. tagged=${done} failed=${failed} (run record = undo source for rollback-tags.js)`);
})();