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