← back to Sister Parish Onboarding

scripts/publish_sp_channels.js

78 lines

#!/usr/bin/env node
/**
 * Publish every Sister Parish product to ALL sales channels (publications).
 * Lists publications via GraphQL, then publishablePublish each product to all.
 */
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });

const SHOP = process.env.SHOPIFY_STORE;
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
if (!SHOP || !TOKEN) { console.error('Need SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN'); process.exit(1); }

const BASE = `https://${SHOP}/admin/api/2026-01`;
const RATE_DELAY_MS = 350;
const DRY = process.argv.includes('--dry-run');
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function rest(method, url) {
  const res = await fetch(url.startsWith('http') ? url : `${BASE}${url}`, {
    method, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
  });
  const text = await res.text();
  let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
  if (!res.ok) { const e = new Error(`${method} ${url} → ${res.status}: ${text.slice(0,300)}`); throw e; }
  return { json, link: res.headers.get('link') || '' };
}
async function gql(query, variables) {
  const res = await fetch(`${BASE}/graphql.json`, {
    method: 'POST',
    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });
  const json = await res.json();
  if (json.errors) throw new Error(JSON.stringify(json.errors).slice(0, 300));
  return json.data;
}
const nextPage = link => {
  const m = link.split(',').find(s => s.includes('rel="next"'));
  return m ? m.match(/<([^>]+)>/)[1] : null;
};

(async () => {
  const pubData = await gql(`{ publications(first:50){edges{node{id name}}} }`);
  const pubs = pubData.publications.edges.map(e => e.node);
  console.log(`${pubs.length} sales channels: ${pubs.map(p => p.name).join(', ')}`);

  const products = [];
  let url = `${BASE}/products.json?vendor=${encodeURIComponent('Sister Parish')}&limit=250&fields=id,title`;
  while (url) {
    const { json, link } = await rest('GET', url);
    products.push(...json.products);
    url = nextPage(link);
    if (url) await sleep(RATE_DELAY_MS);
  }
  console.log(`${DRY ? '[DRY-RUN] ' : ''}Publishing ${products.length} SP products to all ${pubs.length} channels…`);
  if (DRY) return;

  const MUT = `mutation pub($id: ID!, $input: [PublicationInput!]!) {
    publishablePublish(id: $id, input: $input) { userErrors { field message } }
  }`;
  const input = pubs.map(p => ({ publicationId: p.id }));

  let ok = 0, fail = 0;
  for (let i = 0; i < products.length; i++) {
    const p = products[i];
    const stamp = `[${String(i+1).padStart(3,'0')}/${products.length}]`;
    try {
      const d = await gql(MUT, { id: `gid://shopify/Product/${p.id}`, input });
      const errs = d.publishablePublish.userErrors;
      if (errs.length) { fail++; console.log(`${stamp} ⚠ ${p.title}: ${errs.map(e => e.message).join('; ').slice(0,160)}`); }
      else { ok++; console.log(`${stamp} ✓ ${p.title}`); }
    } catch (e) {
      fail++; console.log(`${stamp} ✗ ${p.title}: ${e.message.slice(0,160)}`);
    }
    await sleep(RATE_DELAY_MS);
  }
  console.log(`\nPublished clean: ${ok}   With errors: ${fail}`);
})().catch(e => { console.error('FATAL', e); process.exit(1); });