← back to Secrets Manager

coordonne-collab-audit/create-coco-mural.js

143 lines

#!/usr/bin/env node
// PG-first-then-Shopify creation of the 16 Coco Dávez $113.62 mural products (DTD Option C).
// Per DTD verdict C: murals held. Per DW rules:
//  - PostgreSQL (coordonne_catalog staging) updated FIRST with real_sku + description,
//    then Shopify create (authoritative), then shopify_product_id written back.
//  - Title = "<pattern_name> | Coordonné" (pattern_name already = Pattern + real colorway).
//  - Sample variant {DW_SKU}-Sample @ $4.25, inventory NOT tracked.
//  - Sellable roll variant {DW_SKU} @ $169.
//  - Featured image, description, vendor Coordonné, real mfr SKU (C-code) on the roll variant.
//  - Tags >= 2 incl EXACT smart-collection rule tag "Coco D Vez Colortherapills" (accent-normalized).
//  - Word "Wallpaper" BANNED. product_type = Wallcovering.
//  - NEVER ACTIVE without image AND width metafield -> created ACTIVE only when both present,
//    else DRAFT + Needs-Image / Needs-Width tag.
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 COLLECTION_TAG = 'Coco D Vez Colortherapills'; // EXACT accent-normalized smart-collection rule
const INFILE = process.env.INFILE || 'coco-create-mural.json';
const rows = JSON.parse(fs.readFileSync(path.join(__dirname, INFILE), 'utf8'));
const LIMIT = parseInt(process.env.LIMIT || '0', 10) || rows.length;
const DRY = process.env.DRY === '1';

function pg(sql) {
  return execSync(`psql "host=/tmp dbname=dw_unified" -tA -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 } }
      media(first:1){ nodes{ ... on MediaImage { id } } }
    }
    userErrors { field message }
  }
}`;
const M_VARIANTS_SET = `mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
  productVariantsBulkUpdate(productId: $productId, variants: $variants) {
    productVariants { id sku price }
    userErrors { field message }
  }
}`;
const M_VARIANTS_CREATE = `mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
  productVariantsBulkCreate(productId: $productId, variants: $variants) {
    productVariants { id sku price selectedOptions{name value} }
    userErrors { field message }
  }
}`;

function esc(s) { return String(s || '').replace(/'/g, "''"); }

(async () => {
  const results = [];
  let created = 0, err = 0;
  for (const row of rows.slice(0, LIMIT)) {
    const dwSku = row.dw_sku;
    const title = `${row.pattern_name} | Coordonné`;
    const realSku = row.real_sku;           // C-code
    const desc = row.description;
    const material = row.material || row.material;
    const img = row.image_url;
    const width = row.width && row.width.trim() ? row.width.trim() : null;
    const hasImg = !!img;
    const hasWidth = !!width;
    const activateOK = hasImg && hasWidth;
    const status = activateOK ? 'ACTIVE' : 'DRAFT';
    const tags = ['Coordonné', COLLECTION_TAG, 'Wallcovering', row.pattern_name];
    if (!hasImg) tags.push('Needs-Image');
    if (!hasWidth) tags.push('Needs-Width');

    if (DRY) { results.push({ dwSku, title, realSku, status, tags }); process.stderr.write(`[DRY] ${dwSku} ${title} status=${status}\n`); continue; }

    // 1) PG-FIRST: stamp staging with cleaned description ONLY.
    //    Do NOT overwrite mfr_sku with the real C-code: the Coco Dávez row keeps its
    //    internal COORD- code, and the real C-code already lives on a parallel
    //    generic-collection row (mfr_sku is UNIQUE, overwriting would collide).
    //    The real C-code is carried customer-facing on the Shopify manufacturer_sku metafield.
    pg(`UPDATE coordonne_catalog SET about_vendor='${esc(desc)}', updated_at=now() WHERE dw_sku='${esc(dwSku)}'`);

    // 2) Shopify create — product shell + featured image + description + metafields
    const input = {
      title,
      descriptionHtml: `<p>${desc}</p>`,
      vendor: 'Coordonné',
      productType: 'Wallcovering',
      status,
      tags,
      metafields: [
        { namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field', value: realSku },
        { 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é' },
        { namespace: 'custom', key: 'material', type: 'multi_line_text_field', value: material },
      ].concat(width ? [{ namespace: 'custom', key: 'width', type: 'single_line_text_field', value: width }] : []),
      productOptions: [{ name: 'Size', values: [{ name: 'Sample' }] }],
    };
    const media = hasImg ? [{ 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) productCreate made ONE default variant (Size=Sample). Update it to the
    //     $4.25 sample memo variant, sku {DW_SKU}-Sample, inventory NOT tracked.
    const sampleV = prod.variants.nodes[0];
    const vr1 = await gql(M_VARIANTS_SET, { 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) Create the sellable Roll variant @ $169, sku {DW_SKU}, inventory NOT tracked.
    const vr2 = await gql(M_VARIANTS_CREATE, { productId: pid, variants: [
      { optionValues: [{ optionName: 'Size', name: 'Roll' }], price: '113.62', inventoryItem: { sku: dwSku, tracked: false } },
    ] });
    const vue2 = vr2?.data?.productVariantsBulkCreate?.userErrors || [];
    if (vue2.length) process.stderr.write(`VARERR-roll ${dwSku}: ${JSON.stringify(vue2)}\n`);
    const vue = [...vue1, ...vue2];

    // 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, realSku, variantErrors: vue || [] });
    process.stderr.write(`OK ${dwSku} -> ${prod.handle} [${prod.status}]\n`);
    await new Promise(r => setTimeout(r, 700)); // ~1.4/s, under limits
  }
  fs.writeFileSync(path.join(__dirname, process.env.OUTFILE || 'coco-create-result.json'), JSON.stringify({ created, err, total: Math.min(LIMIT, rows.length), collection_tag: COLLECTION_TAG, results }, null, 2));
  console.log(JSON.stringify({ created, err, total: Math.min(LIMIT, rows.length) }, null, 2));
})();