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