← back to Sister Parish Onboarding

scripts/private_label.js

150 lines

#!/usr/bin/env node
/**
 * Private-label the Sister Parish line on the DW sandbox.
 *  1. De-dupe: group live SP products by title, keep the richest row
 *     (most images, tie-break lowest id), DELETE the rest.
 *  2. Renumber: sorted by title, assign flat DWPH-10001..DWPH-100NN.
 *     roll variant  -> DWPH-100NN
 *     sample variant-> DWPH-100NN-S   (detected via /samp/i in old SKU)
 *  3. Tag each kept product: add product tag `display_variant`; add metafields
 *     dwc.series="Saybrook House", dwc.series_code="DWPH", and
 *     dwc.mfr_number="SISTERPARISH-<vendor sku>" (internal reorder reference,
 *     NOT front-facing). Warn on any product missing a sample variant.
 *  4. Write output/sku_map.json (old vendor SKU -> new DW SKU, recoverable).
 *
 * --plan  : print the dedupe + renumber plan, change nothing.
 */
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const fs = require('fs');
const path = require('path');

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 SERIES = 'Saybrook House';
const SERIES_CODE = 'DWPH';
const START = 10001;
const PLAN = process.argv.includes('--plan');
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function shopify(method, url, body) {
  const res = await fetch(url.startsWith('http') ? url : `${BASE}${url}`, {
    method, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  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)}`); e.status = res.status; throw e; }
  return { json, link: res.headers.get('link') || '' };
}
const nextPage = link => {
  const m = link.split(',').find(s => s.includes('rel="next"'));
  return m ? m.match(/<([^>]+)>/)[1] : null;
};
const isSample = v => /samp/i.test(v.sku || '') || /sample/i.test(v.option3 || '') || /sample/i.test(v.title || '');

(async () => {
  // ---- fetch all live SP products ----
  const products = [];
  let url = `${BASE}/products.json?vendor=${encodeURIComponent('Sister Parish')}&limit=250`;
  while (url) {
    const { json, link } = await shopify('GET', url);
    products.push(...json.products);
    url = nextPage(link);
    if (url) await sleep(RATE_DELAY_MS);
  }
  console.log(`Live SP products: ${products.length}`);

  // ---- dedupe: group by title ----
  const byTitle = new Map();
  for (const p of products) {
    const t = p.title;
    if (!byTitle.has(t)) byTitle.set(t, []);
    byTitle.get(t).push(p);
  }
  const keepers = [], deletes = [];
  for (const [, group] of byTitle) {
    group.sort((a, b) => (b.images?.length || 0) - (a.images?.length || 0) || a.id - b.id);
    keepers.push(group[0]);
    deletes.push(...group.slice(1));
  }
  keepers.sort((a, b) => a.title.localeCompare(b.title));
  console.log(`Unique titles: ${keepers.length}   Duplicates to delete: ${deletes.length}`);

  // ---- build renumber plan ----
  const VENDOR_TOKEN = 'SISTERPARISH';
  const recoverMfr = sku => (sku || '').replace(/^DWSP-/i, '').replace(/^SAMP[ -]+/i, '').trim();
  const map = [];
  keepers.forEach((p, i) => {
    const base = `${SERIES_CODE}-${START + i}`;
    const samples = p.variants.filter(isSample);
    const rolls = p.variants.filter(v => !isSample(v));
    const vmap = [];
    rolls.forEach((v, j) => vmap.push({ id: v.id, old: v.sku, new: j === 0 ? base : `${base}-R${j+1}` }));
    samples.forEach((v, j) => vmap.push({ id: v.id, old: v.sku, new: j === 0 ? `${base}-S` : `${base}-S${j+1}` }));
    const vendorSku = recoverMfr((rolls[0] || p.variants[0] || {}).sku);
    const tags = [...new Set([...(p.tags || '').split(',').map(s => s.trim()).filter(Boolean), 'display_variant'])].join(', ');
    map.push({
      product_id: p.id, title: p.title, dw_base: base, variants: vmap, tags,
      mfr_number: `${VENDOR_TOKEN}-${vendorSku}`, has_sample: samples.length > 0,
    });
  });
  const missingSample = map.filter(m => !m.has_sample);
  if (missingSample.length) {
    console.log(`\n⚠ ${missingSample.length} product(s) have NO sample variant — fix manually:`);
    missingSample.forEach(m => console.log(`   ${m.title}`));
  }

  if (PLAN) {
    console.log('\n--- DELETE ---');
    deletes.forEach(p => console.log(`  ✗ #${p.id}  ${p.title}`));
    console.log('\n--- RENUMBER ---');
    map.forEach(m => {
      console.log(`  ${m.dw_base}  ${m.title}   [mfr: ${m.mfr_number}]`);
      m.variants.forEach(v => console.log(`     ${v.old}  ->  ${v.new}`));
    });
    return;
  }

  // ---- 1. delete dupes ----
  let del = 0;
  for (const p of deletes) {
    try { await shopify('DELETE', `/products/${p.id}.json`); del++; console.log(`✗ deleted dupe #${p.id} ${p.title}`); }
    catch (e) { console.log(`! delete failed #${p.id}: ${e.message.slice(0,120)}`); }
    await sleep(RATE_DELAY_MS);
  }
  console.log(`Deleted ${del}/${deletes.length} duplicates.\n`);

  // ---- 2. renumber + 3. series metafield ----
  let ok = 0, fail = 0;
  for (let i = 0; i < map.length; i++) {
    const m = map[i];
    const stamp = `[${String(i+1).padStart(3,'0')}/${map.length}]`;
    try {
      await shopify('PUT', `/products/${m.product_id}.json`, {
        product: {
          id: m.product_id,
          tags: m.tags,
          variants: m.variants.map(v => ({ id: v.id, sku: v.new })),
          metafields: [
            { namespace: 'dwc', key: 'series', type: 'single_line_text_field', value: SERIES },
            { namespace: 'dwc', key: 'series_code', type: 'single_line_text_field', value: SERIES_CODE },
            { namespace: 'dwc', key: 'mfr_number', type: 'single_line_text_field', value: m.mfr_number },
          ],
        },
      });
      ok++; console.log(`${stamp} ✓ ${m.dw_base}  ${m.title}`);
    } catch (e) {
      fail++; console.log(`${stamp} ✗ ${m.title}: ${e.message.slice(0,150)}`);
    }
    await sleep(RATE_DELAY_MS);
  }

  fs.writeFileSync(path.join(__dirname, '..', 'output', 'sku_map.json'),
    JSON.stringify({ generated: new Date().toISOString(), series: SERIES, series_code: SERIES_CODE, deleted: deletes.map(p => ({ id: p.id, title: p.title })), products: map }, null, 2));
  console.log(`\nRenumbered: ${ok}   Failed: ${fail}   ·  map -> output/sku_map.json`);
})().catch(e => { console.error('FATAL', e); process.exit(1); });