← back to Majilite Quote Cta

scripts/tag-majilite-quotes.js

134 lines

#!/usr/bin/env node
/**
 * tag-majilite-quotes.js — add the quote-only trigger tags to all ~160 live
 * Majilite products. DRY-RUN BY DEFAULT. The live write is Steve-gated.
 *
 * Trigger tag (machine): `quotes`      — canary-native (dw-five-field-canary
 *                                          L51 exempts %,quotes,% from the
 *                                          sellable-variant + zero-price checks;
 *                                          adding it auto-resolves the FAIL this
 *                                          line will otherwise throw).
 * Companion tag (human):  `Quote Only`  — admin/theme readability only.
 *
 * DW doctrine: PostgreSQL (dw_unified mirror) staged FIRST, then Shopify, then
 * the mirror re-syncs from Shopify (Shopify is authoritative). This script:
 *   - default (no flag): DRY-RUN — prints the before/after tag diff for each of
 *     the 160 products, writes a plan to data/tag-plan.json, touches nothing.
 *   - --apply: adds the tags on the LIVE store via productUpdate (append-only,
 *     never removes an existing tag), records before/after to data/runs/<ts>.json.
 *
 * Usage:
 *   node scripts/tag-majilite-quotes.js            # DRY-RUN (default)
 *   node scripts/tag-majilite-quotes.js --apply    # GATED live write
 *   node scripts/tag-majilite-quotes.js --limit 3  # only first N (canary)
 *
 * Read-only Shopify GraphQL is used to (re)fetch the current live tag set so the
 * append is idempotent even if run twice.
 */
const fs = require('fs');
const path = require('path');

const ROOT = path.resolve(__dirname, '..');
const API = '2024-10';
const STORE = process.env.SHOPIFY_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');
  const m = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m); return m ? m[1].replace(/["']/g, '').trim() : '';
})();
const GQL = `https://${STORE}/admin/api/${API}/graphql.json`;

const TRIGGER_TAGS = ['quotes', 'Quote Only'];

const args = process.argv.slice(2);
const APPLY = args.includes('--apply');
const LIMIT = (() => { const i = args.indexOf('--limit'); return i >= 0 ? Number(args[i + 1]) : Infinity; })();
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function gql(query, variables) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(GQL, {
      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(1500 * (attempt + 1)); continue; }
      throw new Error('GQL errors: ' + JSON.stringify(j.errors).slice(0, 300));
    }
    return j.data;
  }
  throw new Error('GQL throttled after retries');
}

async function fetchAllMajilite() {
  let cursor = null; const out = [];
  for (;;) {
    const after = cursor ? `, after: "${cursor}"` : '';
    const d = await gql(`{ products(first: 100, query: "vendor:Majilite"${after}) {
      pageInfo { hasNextPage endCursor }
      edges { node { id handle title status tags } } } }`);
    for (const e of d.products.edges) out.push(e.node);
    if (!d.products.pageInfo.hasNextPage) break;
    cursor = d.products.pageInfo.endCursor;
    await sleep(500);
  }
  return out;
}

(async () => {
  console.log(`Majilite quote-tag ${APPLY ? 'APPLY (LIVE WRITE)' : 'DRY-RUN'} → ${STORE}`);
  const prods = (await fetchAllMajilite()).slice(0, LIMIT);
  console.log(`Fetched ${prods.length} Majilite products.\n`);

  const plan = [];
  for (const p of prods) {
    const cur = new Set(p.tags);
    const toAdd = TRIGGER_TAGS.filter(t => !cur.has(t));
    const after = Array.from(new Set([...p.tags, ...TRIGGER_TAGS]));
    plan.push({ id: p.id, handle: p.handle, status: p.status, before: p.tags, add: toAdd, after });
  }

  const needChange = plan.filter(x => x.add.length);
  console.log(`${needChange.length} of ${plan.length} need the trigger tag(s) added.`);
  console.log(`Trigger tags: ${TRIGGER_TAGS.join(', ')}`);
  for (const x of plan.slice(0, 5)) {
    console.log(`  ${x.handle} [${x.status}]  add=${JSON.stringify(x.add)}`);
  }
  if (plan.length > 5) console.log(`  … and ${plan.length - 5} more`);

  fs.mkdirSync(path.join(ROOT, 'data'), { recursive: true });
  fs.writeFileSync(path.join(ROOT, 'data/tag-plan.json'), JSON.stringify(plan, null, 2));
  console.log(`\nPlan written → data/tag-plan.json`);

  if (!APPLY) {
    console.log('\nDRY-RUN complete. No writes. Re-run with --apply (Steve-gated) to add the tags on the live store.');
    return;
  }

  // ---- GATED LIVE WRITE ----
  fs.mkdirSync(path.join(ROOT, 'data/runs'), { recursive: true });
  const runFile = path.join(ROOT, 'data/runs', `${new Date().toISOString().replace(/[:.]/g, '-')}.json`);
  const results = [];
  let changed = 0, skipped = 0, failed = 0;
  for (const x of needChange) {
    try {
      const d = await gql(
        `mutation($input: ProductInput!){ productUpdate(input:$input){ product{ id tags } userErrors{ field message } } }`,
        { input: { id: x.id, tags: x.after } }
      );
      const ue = d.productUpdate.userErrors;
      if (ue && ue.length) { failed++; results.push({ ...x, ok: false, err: ue }); }
      else { changed++; results.push({ ...x, ok: true, applied: d.productUpdate.product.tags }); }
    } catch (e) {
      failed++; results.push({ ...x, ok: false, err: e.message.slice(0, 200) });
    }
    if ((changed + failed) % 20 === 0) await sleep(1000); // gentle pacing
  }
  skipped = plan.length - needChange.length;
  fs.writeFileSync(runFile, JSON.stringify({ at: new Date().toISOString(), triggerTags: TRIGGER_TAGS, changed, skipped, failed, results }, null, 2));
  console.log(`\nAPPLIED. changed=${changed} skipped=${skipped} failed=${failed}`);
  console.log(`Run record (reversible before/after) → ${runFile}`);
  console.log('Next: the dw_unified mirror re-syncs tags from Shopify on its normal cadence.');
})();