← back to Secrets Manager

coordonne-collab-audit/cotswolds-create-drafts.js

133 lines

#!/usr/bin/env node
// Cotswolds (Ybarra & Serret for Coordonné) — create the 37 settlement-OK SKUs as DRAFT.
// Steve-approved 2026-07-22: DRAFT only (admin-only, storefront-safe). Activation is separate.
// Pattern lifted from create-coco-mural.js. Deltas:
//  - status FORCED to DRAFT for all (not image-conditional).
//  - Per-row price from coordonne_catalog.price_retail ($169 wallcovering / $113.62 mural).
//  - product_type = Wallcovering | Mural (word "Wallpaper" BANNED); mural sellable option = "Panel", else "Roll".
//  - Feed-first ($0) backfill of description + real mfr SKU + material from the Woo Store API.
//  - Only the 37 settlement-OK dw_skus (read from cotswolds-settlement-result.json).
//  - Idempotency: skip any row that already has a shopify_product_id in PG.
//  - PG-first (stamp description) -> Shopify create (authoritative) -> write back shopify_product_id.
const fs = require('fs'), path = require('path');
const { execSync } = require('child_process');
const ENV = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
const TOKEN = (ENV.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1];
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com', VER = '2024-10';
if (!TOKEN) { console.error('no token'); process.exit(1); }
const DRY = process.env.DRY === '1';
const LIMIT = parseInt(process.env.LIMIT || '0', 10);

const ent = { '’': '’', '‘': '‘', '“': '“', '”': '”', '–': '–', '—': '—', '…': '…', '&': '&', ' ': ' ', '"': '"', ''': "'", ''': "'" };
const strip = h => (h || '').replace(/<[^>]+>/g, ' ').replace(/&#?\w+;/g, m => ent[m] || m).replace(/\s+/g, ' ').trim();
function esc(s) { return String(s == null ? '' : s).replace(/'/g, "''"); }
function pg(sql) { return execSync(`psql "host=/tmp dbname=dw_unified" -tA -F'|' -c ${JSON.stringify(sql)}`, { encoding: 'utf8' }).trim(); }
async function gql(query, variables) {
  const r = await fetch(`https://${DOMAIN}/admin/api/${VER}/graphql.json`, {
    method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });
  return r.json();
}
const M_CREATE = `mutation($input: ProductInput!, $media: [CreateMediaInput!]) {
  productCreate(input: $input, media: $media) {
    product { id handle status title variants(first:5){ nodes{ id title sku } } }
    userErrors { field message } } }`;
const M_VSET = `mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
  productVariantsBulkUpdate(productId: $productId, variants: $variants) { userErrors { field message } } }`;
const M_VCREATE = `mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
  productVariantsBulkCreate(productId: $productId, variants: $variants) { userErrors { field message } } }`;

(async () => {
  // 37 settlement-OK dw_skus
  const gate = JSON.parse(fs.readFileSync(path.join(__dirname, 'cotswolds-settlement-result.json'), 'utf8'));
  const okSkus = gate.results.filter(o => o.verdict === 'OK').map(o => o.dw_sku);
  const inList = okSkus.map(s => `'${esc(s)}'`).join(',');
  // Pull authoritative staged rows from PG (source of truth for price/width/image/type)
  const raw = pg(`SELECT dw_sku, mfr_sku, pattern_name, color_name, product_type, width, price_retail, material, image_url, product_url, shopify_product_id FROM coordonne_catalog WHERE dw_sku IN (${inList}) ORDER BY dw_sku`);
  let rows = raw.split('\n').filter(Boolean).map(l => {
    const [dw_sku, mfr_sku, pattern_name, color_name, product_type, width, price_retail, material, image_url, product_url, shopify_product_id] = l.split('|');
    return { dw_sku, mfr_sku, pattern_name, color_name, product_type, width, price_retail, material, image_url, product_url, shopify_product_id };
  });
  const already = rows.filter(r => r.shopify_product_id && r.shopify_product_id !== '');
  rows = rows.filter(r => !r.shopify_product_id || r.shopify_product_id === '');   // idempotency
  if (LIMIT) rows = rows.slice(0, LIMIT);

  const results = []; let created = 0, err = 0;
  for (const row of rows) {
    const dwSku = row.dw_sku;
    const isMural = /mural/i.test(row.product_type);
    const productType = isMural ? 'Mural' : 'Wallcovering';
    const sellOption = isMural ? 'Panel' : 'Roll';
    const price = (parseFloat(row.price_retail) || (isMural ? 113.62 : 169)).toFixed(2);
    const width = (row.width || '').trim() || null;
    const img = (row.image_url || '').trim() || null;
    const title = `${row.pattern_name} | Coordonné`;

    // feed-first ($0) backfill: description + real mfr SKU + material from Woo Store API
    let desc = '', realSku = null, material = row.material || null;
    const wpid = (row.mfr_sku || '').replace(/^COORD-/, '');
    try {
      const wr = await fetch(`https://coordonne.com/wp-json/wc/store/v1/products/${wpid}`);
      const wj = await wr.json();
      desc = strip(wj.description) || strip(wj.short_description) || '';
      realSku = wj.sku || null;
      const qa = (wj.attributes || []).find(a => /quality|material/i.test(a.name));
      if (qa && qa.terms) material = qa.terms.map(t => t.name).join(' / ') || material;
    } catch (e) { /* leave desc empty; draft still created */ }
    if (!desc) desc = `${row.pattern_name} — from the Cotswolds collection by Ybarra & Serret for Coordonné.`;

    const tags = ['Coordonné', 'Cotswolds', 'Ybarra & Serret', productType, row.pattern_name, 'settlement-ok'];
    if (!img) tags.push('Needs-Image');
    if (!width) tags.push('Needs-Width');

    if (DRY) { results.push({ dwSku, title, productType, price, realSku, status: 'DRAFT', tags, hasImg: !!img }); process.stderr.write(`[DRY] ${dwSku} ${title} ${productType} $${price} img=${!!img}\n`); continue; }

    // 1) PG-FIRST: stamp cleaned description (keep mfr_sku = internal COORD- code)
    pg(`UPDATE coordonne_catalog SET about_vendor='${esc(desc)}', material=COALESCE(NULLIF('${esc(material)}',''),material), updated_at=now() WHERE dw_sku='${esc(dwSku)}'`);

    // 2) Shopify create — DRAFT
    const input = {
      title, descriptionHtml: `<p>${esc(desc)}</p>`, vendor: 'Coordonné', productType, status: 'DRAFT', tags,
      metafields: [
        realSku ? { namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field', value: realSku } : null,
        { namespace: 'custom', key: 'pattern_name', type: 'single_line_text_field', value: row.pattern_name },
        { namespace: 'custom', key: 'brand', type: 'single_line_text_field', value: 'Coordonné' },
        material ? { namespace: 'custom', key: 'material', type: 'multi_line_text_field', value: material } : null,
        width ? { namespace: 'custom', key: 'width', type: 'single_line_text_field', value: width } : null,
      ].filter(Boolean),
      productOptions: [{ name: 'Size', values: [{ name: 'Sample' }] }],
    };
    const media = img ? [{ originalSource: img, mediaContentType: 'IMAGE', alt: title }] : null;
    const cr = await gql(M_CREATE, { input, media });
    const ue = cr?.data?.productCreate?.userErrors;
    if (ue && ue.length) { err++; results.push({ dwSku, status: 'error', errors: ue }); process.stderr.write(`ERR ${dwSku}: ${JSON.stringify(ue)}\n`); continue; }
    const prod = cr?.data?.productCreate?.product;
    if (!prod) { err++; results.push({ dwSku, status: 'gql-error', errors: cr.errors }); process.stderr.write(`GQLERR ${dwSku}: ${JSON.stringify(cr.errors)}\n`); continue; }
    const pid = prod.id;

    // 3a) sample variant -> $4.25, sku {DW}-Sample, untracked
    const sampleV = prod.variants.nodes[0];
    const vr1 = await gql(M_VSET, { productId: pid, variants: [{ id: sampleV.id, price: '4.25', inventoryItem: { sku: `${dwSku}-Sample`, tracked: false } }] });
    const vue1 = vr1?.data?.productVariantsBulkUpdate?.userErrors || [];
    if (vue1.length) process.stderr.write(`VARERR-sample ${dwSku}: ${JSON.stringify(vue1)}\n`);

    // 3b) sellable variant @ price, sku {DW}, untracked
    const vr2 = await gql(M_VCREATE, { productId: pid, variants: [{ optionValues: [{ optionName: 'Size', name: sellOption }], price, inventoryItem: { sku: dwSku, tracked: false } }] });
    const vue2 = vr2?.data?.productVariantsBulkCreate?.userErrors || [];
    if (vue2.length) process.stderr.write(`VARERR-sell ${dwSku}: ${JSON.stringify(vue2)}\n`);

    // 4) PG write-back shopify_product_id
    const numId = pid.split('/').pop();
    pg(`UPDATE coordonne_catalog SET shopify_product_id='${numId}', updated_at=now() WHERE dw_sku='${esc(dwSku)}'`);

    created++;
    results.push({ dwSku, shopify_id: pid, handle: prod.handle, status: prod.status, title, productType, price, realSku, variantErrors: [...vue1, ...vue2] });
    process.stderr.write(`OK ${dwSku} -> ${prod.handle} [${prod.status}] ${productType} $${price}\n`);
    await new Promise(r => setTimeout(r, 700));
  }
  const summary = { created, err, attempted: rows.length, skipped_already_created: already.length, results };
  fs.writeFileSync(path.join(__dirname, process.env.OUTFILE || 'cotswolds-create-result.json'), JSON.stringify(summary, null, 2));
  console.log(JSON.stringify({ created, err, attempted: rows.length, skipped_already_created: already.length }, null, 2));
})();