← 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.');
})();