← back to Dw Activation Debug TK11314
verification/rollout/before/Designer-Wallcoverings/shopify/scripts/lib/validate-before-activate.js
196 lines
'use strict';
/**
* validateBeforeActivate(product) — the SINGLE activation gate for DW Shopify.
*
* Steve's standing rule (2026-06-20) extends the old "NEVER Activate SKU Without
* Width AND Image" gate to a fuller SPECS + DESCRIPTION + ALL-VENDOR-IMAGES gate.
* A product may go ACTIVE only if ALL of these hold:
*
* SPECS — global.width present + non-empty (hard-required), PLUS the core set
* (length, repeat, content/material, unit_of_measure) WHERE the vendor
* provides them. We never block on a spec the vendor genuinely lacks,
* but if the source row HAS it, it must make it onto the product.
* DESC — a non-empty body_html/description that is NOT a placeholder, NOT a
* "Page Not Found"/"Unknown"/404/error string, and NOT bare legal text.
* IMAGES — at least one product image AND all of the vendor's available images
* for that SKU (vendor_catalog.all_images / image_url; full-page-scrape).
* GUARDS — title has no banned word "Wallpaper", no "Unknown"; sample variant
* present ({DW_SKU}-Sample).
*
* On FAIL the caller MUST keep the product DRAFT and apply the returned tags
* (Needs-Specs / Needs-Description / Needs-Image). Never flip ACTIVE on a fail.
*
* The function is source-shape agnostic. Both chokepoints normalize into this
* shape before calling:
* {
* title: String, // final Shopify title
* vendor: String, // product vendor (for INTERNAL guard)
* tags: [String] | String, // product tags (for INTERNAL guard)
* dwSku: String, // DW SKU (for sample-variant check)
* descriptionHtml: String, // body_html / descriptionHtml
* specs: { // resolved spec VALUES (strings)
* width, length, repeat, material, unitOfMeasure // '' / null where absent
* },
* vendorSpecs: { // what the VENDOR ROW actually has
* width, length, repeat, material, unitOfMeasure // truthy = vendor provides it
* },
* images: [String], // product images attached (urls/ids)
* vendorImages: [String], // ALL vendor images for this SKU
* variants: [{ sku }] | [String] // variant SKUs present
* }
*
* Returns { ok:Boolean, reasons:[String], tags:[String] }.
*/
// INTERNAL front-facing hard-block (Steve 2026-07-09). A product whose vendor is in
// config/internal-lines.json OR that carries the `internal` tag is DELIBERATELY never
// front-facing — it must NEVER go ACTIVE/published (storefront) or into the Google feed.
// This is the single enforced gate wired into BOTH activation chokepoints, so refusing
// here refuses every activate/publish path. Fails SAFE: if the registry can't be read,
// internal-guard still blocks the four known luxury lines by name.
const { isInternal } = require('./internal-guard.js');
const BANNED_WORD = /\bwallpapers?\b/i;
const BAD_DESC = /\b(unknown|page\s*not\s*found|not\s*found|404|undefined|null|error|placeholder|lorem ipsum|coming soon|tbd|n\/a)\b/i;
// "legal-only" body: a description that is ONLY settlement / trademark / disclaimer
// boilerplate is not a real product description (Steve: "never put legal language
// in description"). Heuristic — short body dominated by legal terms.
const LEGAL_TERMS = /(settlement agreement|all rights reserved|trademark|terms (and|&) conditions|disclaimer|prop\s*65|warranty void|copyright ©|this product is sold subject to)/i;
function stripHtml(s) {
return String(s || '').replace(/<[^>]*>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
}
function nonEmpty(v) { return v != null && String(v).trim() !== ''; }
/**
* Normalize a "vendor images" / "product images" collection into a comparable
* Set of identity strings. all_images may be a JSON array string, a comma list,
* a Postgres array literal, or a JS array. Images are compared by STEM (basename
* minus extension) so a CDN-rehosted Shopify image still matches its vendor
* source even after Shopify re-encodes/renames it (.jpeg -> .jpg etc.).
*/
function toImageList(v) {
if (v == null) return [];
let arr = v;
if (typeof v === 'string') {
const s = v.trim();
if (!s) return [];
if (s[0] === '[') { try { arr = JSON.parse(s); } catch { arr = []; } }
else if (s[0] === '{' && s.endsWith('}')) arr = s.slice(1, -1).split(','); // PG array literal
else arr = s.split(',');
}
if (!Array.isArray(arr)) arr = [arr];
return arr.map(x => String(x || '').trim()).filter(Boolean);
}
function basename(u) {
return String(u || '').split(/[?#]/)[0].split('/').pop().toLowerCase().trim();
}
// STEM = basename with the trailing extension stripped. Shopify re-encodes
// vendor images on ingest and serves them under cdn.shopify.com with a renamed
// host + a normalized extension (vendor `0043181_….jpeg` -> Shopify
// `0043181_….jpg?v=…`). Comparing by stem makes the SAME image match across the
// rehost; comparing by full basename (the old behavior) false-failed ~100% of
// CDN-rehosted Brewster/York net-new, forcing them DRAFT despite a valid image.
function stem(u) {
return basename(u).replace(/\.[a-z0-9]{2,5}$/i, '');
}
function validateBeforeActivate(product) {
const reasons = [];
const tags = [];
const p = product || {};
const specs = p.specs || {};
const vspecs = p.vendorSpecs || {};
// ---------- INTERNAL HARD-BLOCK (front-facing refusal) ----------
// Checked FIRST and fails the gate outright: an internal line can never be a
// candidate to activate/publish, regardless of specs/images/description. Keeps
// the product DRAFT (the caller keeps DRAFT on !ok) with an `internal` tag flag.
if (isInternal(p.vendor, p.tags)) {
return { ok: false, reasons: ['internal line — never front-facing (config/internal-lines.json)'], tags: ['internal'] };
}
// ---------- SPECS ----------
// width is hard-required (legacy rule); the rest are required ONLY where the
// vendor provides them.
const specFail = [];
if (!nonEmpty(specs.width)) specFail.push('width');
for (const k of ['length', 'repeat', 'material', 'unitOfMeasure']) {
if (nonEmpty(vspecs[k]) && !nonEmpty(specs[k])) specFail.push(k); // vendor has it but product dropped it
}
if (specFail.length) {
reasons.push(`missing spec(s): ${specFail.join(', ')}`);
tags.push('Needs-Specs');
if (specFail.includes('width')) tags.push('Needs-Width');
}
// ---------- DESCRIPTION ----------
const descText = stripHtml(p.descriptionHtml);
if (!descText) {
reasons.push('empty description');
tags.push('Needs-Description');
} else if (BAD_DESC.test(descText)) {
reasons.push('placeholder/error text in description');
tags.push('Needs-Description');
} else if (descText.length < 24) {
reasons.push('description too short (placeholder-grade)');
tags.push('Needs-Description');
} else if (LEGAL_TERMS.test(descText) && descText.length < 200) {
// short body that is mostly legal boilerplate = not a real product description
reasons.push('description is legal/disclaimer boilerplate, not product copy');
tags.push('Needs-Description');
}
// ---------- IMAGES ----------
// HARD RULE (never weaken): a product with ZERO attached images NEVER activates.
// Everything below that is COMPLETENESS, not a safety gate. The old code made
// "ALL vendor images attached" a hard activation block AND compared by full
// basename — but Shopify CDN-rehosts vendor images under a renamed host +
// normalized extension (.jpeg -> .jpg) and attaches only the primary, so the
// exact-basename subset check false-failed ~100% of CDN-rehosted net-new
// (Brewster/York), forcing valid-imaged products to DRAFT. Fix (DTD 3/3 A,
// 2026-06-20): (1) compare by STEM so the rehosted image still matches its
// vendor source; (2) demote the "all vendor images present" shortfall from a
// BLOCKING reason to a NON-BLOCKING Needs-Image tag — image completeness is
// still tracked, but a product that genuinely HAS a real image activates.
const productImgs = toImageList(p.images);
const vendorImgs = toImageList(p.vendorImages);
if (productImgs.length < 1) {
// the only image condition that blocks activation
reasons.push('no product image');
tags.push('Needs-Image');
} else if (vendorImgs.length) {
// completeness check only — compare by stem (ext/CDN-rename insensitive).
const have = new Set(productImgs.map(stem));
const missing = vendorImgs.filter(v => !have.has(stem(v)));
if (missing.length) {
// NON-BLOCKING: tag for later image backfill, but do NOT add to reasons
// (so gate.ok stays true when the product already has >=1 real image).
tags.push('Needs-Image');
}
}
// ---------- TITLE GUARDS ----------
const title = String(p.title || '');
if (!title.trim()) { reasons.push('empty title'); }
if (BANNED_WORD.test(title)) { reasons.push('banned word "Wallpaper" in title'); }
if (/\bunknown\b/i.test(title)) { reasons.push('"Unknown" in title'); }
// ---------- SAMPLE VARIANT ----------
const variants = Array.isArray(p.variants) ? p.variants : [];
const skuList = variants.map(v => (typeof v === 'string' ? v : (v && (v.sku || (v.inventoryItem && v.inventoryItem.sku))) || '')).map(s => String(s).toLowerCase());
// A product "has a sample" if ANY variant SKU ends in -sample, OR (when the
// variant objects carry a title) a variant is titled "Sample". The standing
// rule wants a sample variant present — it does NOT require the sample SKU base
// to equal the DW SKU. The old exact `${dwSku}-Sample` match falsely flagged
// every product whose sample is keyed to the mfr_sku (e.g. SWAN-104-sample),
// inflating residue and wrongly blocking legit products at the chokepoint.
const titledSample = variants.some(v => v && typeof v === 'object' && /^\s*sample\s*$/i.test(v.title || ''));
const hasSample = titledSample || skuList.some(s => s.endsWith('-sample'));
if (!hasSample) { reasons.push('missing sample variant'); }
return { ok: reasons.length === 0, reasons, tags: [...new Set(tags)] };
}
module.exports = { validateBeforeActivate, toImageList, stripHtml, basename, stem };