← back to Dw Activation Calendar

lib/rotation-order.js

160 lines

'use strict';
/*
 * rotation-order.js — THE canonical ordering for DW draft activation.
 *
 * ONE source of truth for the "textures-first, then vendor round-robin (~one per
 * vendor per increment)" order across ALL DRAFT products (not just the ~5k
 * field-fix worklist). Both consumers import ROTATION_ORDER_SQL from here so the
 * unified effective order is identical:
 *   - dw-rotation-activator/rotate-activate.js  (the executor that flips DRAFT→ACTIVE)
 *   - dw-activation-calendar/scripts/generate-schedule.js  (the projection/UI)
 *
 * Proven design (read-only projection validated by the main session):
 *   mat_tier = 0 (textures/naturals) leads, then mat_tier = 1 (rest).
 *   Within each tier, breadth-first vendor round-robin:
 *     rr = (position of this SKU within its (tier,vendor) group) * 100000
 *          + (dense rank of the vendor within the tier)
 *   Ordering by (mat_tier, rr) therefore takes ONE sku from every vendor before a
 *   2nd from any — so each activation increment spreads across vendors (never a
 *   single-vendor contiguous block).
 *
 * WIDENED texture detection (vs the original material-metafield-only heuristic,
 * which under-counted because the material metafield is blank on many drafts):
 * we build a match string from title + product_type + tags + BOTH material
 * metafields, and match a broad naturals/texture lexicon. This lands more true
 * grasscloth / silk / naturals / weaves / metallics in tier 0.
 *
 * NOISE FILTER: instructional/help-doc rows (e.g. a "How to Hang Grasscloth" help
 * page that falsely matched the grasscloth keyword) are EXCLUDED entirely so they
 * never activate. The filter is deliberately NARROW — it matches genuine help-doc
 * title patterns only, NOT "memo"/"sample" (which are legit product-name tokens on
 * Phillipe Romano etc.).
 *
 * Schumacher is excluded (internal-only, HARD RULE — never on the storefront).
 */

// Broad naturals/texture lexicon → tier 0 (textures-first).
const TEXTURE_REGEX =
  "silk|grasscloth|sisal|raffia|abaca|jute|linen|hemp|cork|paperweave|paper weave|" +
  "grass|natural fiber|natural fibre|seagrass|arrowroot|wool|mohair|leather|suede|" +
  "bamboo|rattan|cane|weave|woven|textile|\\mfiber\\M|\\mfibre\\M|reed|coir|hessian|" +
  "burlap|felt|velvet|flock|metallic|\\mmica\\M|gilded|gold leaf|silver leaf|foil";

// NARROW instructional/help-doc noise filter. Matches real help-doc titles only.
// Deliberately does NOT include "memo"/"sample book" (legit product-name tokens).
const NOISE_REGEX =
  "how to|how-to|instructional|installation guide|hanging guide|care guide|" +
  "user guide|tutorial";

// NEVER-ACTIVATE vendors (2026-08-10, TK-10446). These are bespoke / made-to-order /
// quote-only / internal-only lines that must never auto-publish to the storefront:
//   Gracie   — HARD RULE, Steve "do not show GRACIE EVER" (bespoke chinoiserie, quote-only)
//   Fromental, Zuber, De Gournay — bespoke made-to-order, quote-only, settlement-heavy
//   Astek    — internal line-viewer only, not on the DW Shopify store
// (Schumacher is already excluded below as internal-only.)
// Added 2026-08-11 (Steve "no old dwjs, phillip jeffries", TK-10474) — DIFFERENT rationale:
// these two are NOT bespoke; they already have full live catalogs (Jeffrey Stevens ~2.5k ACTIVE,
// Phillip Jeffries ~2.5k ACTIVE). Their remaining DRAFTs are OLD leftovers, so exclude both from
// auto-activation so no old pattern is re-surfaced as new. NB: DWJS- = Jeffrey Stevens (NOT York,
// NOT Phillip Jeffries); Phillip Jeffries = DWJP-/DWPJ-. Matched by vendor NAME, not SKU prefix.
const NEVER_ACTIVATE_VENDORS = "gracie|fromental|zuber|de gournay|astek|jeffrey stevens|phillip jeffries";

// NEVER-ACTIVATE brand matched by TITLE, not vendor (2026-08-13, Et Cie rights revoked).
// Et Cie panels carry vendor "Designer Wallcoverings" — the brand lives ONLY in the TITLE, so the
// vendor filter above can NOT catch them. Steve: "we do not have rights to show any more" (all Et
// Cie were ARCHIVED 2026-08-13; this stops a NEW import from auto-activating as "new").
// Et Cie ships in TWO title forms — "Et Cie <name>" AND "<name> by Et Cie Wall Panels" — so the
// match is the whole-word phrase "et cie" ANYWHERE in the title (Postgres \y word boundaries):
// catches both forms yet EXCLUDES "Anet Ciel | Pierre Frey" (the et/cie there sit inside anet/ciel,
// no word boundary). Do NOT guard on the PANEL- SKU prefix — it is SHARED with Paul Montgomery
// Studios (434+ live) and others, so a SKU-prefix block would wrongly freeze them.
const NEVER_ACTIVATE_TITLE = "\\yet cie\\y";

// SAMPLE-SKU filter (2026-08-10, TK-10446). ~90% of the DRAFT pool are memo/sample
// SKUs (…-Sample, …-S). Activating one publishes a standalone $4.25 memo listing
// (Google-Merchant $4.25-leak / disapproval risk). Samples must NEVER activate as
// their own live product — only real roll/sell SKUs do. Matched on the SKU suffix,
// NOT on title (Phillipe Romano etc. use "memo"/"sample" as legit name tokens).
const SAMPLE_SKU_REGEX = "-(sample|s)$";

// DEDICATED-POSTER vendors (2026-08-20, TK-10762). DIFFERENT rationale from
// NEVER_ACTIVATE_VENDORS: these vendors DO get published — but via their OWN
// dedicated daily poster, NOT this shared round-robin rotation. They must be
// excluded from the shared rotation because it activates ONE colorway at a time,
// which violates Steve's HARD Fabricut rule ("ALWAYS complete patterns — every
// colorway together, never cut off after 1 color"; fabricut-daily-post.js posts
// whole patterns). Historically Fabricut was excluded only INCIDENTALLY by the
// SAMPLE_SKU_REGEX above (a mirror sync bug left every Fabricut product SKU as the
// "…-Sample" variant). Once that mirror sku is corrected to the sellable roll SKU
// (root fix shipped in sync-shopify-products.js), the incidental exclusion vanishes
// and Fabricut would silently flood the shared rotation. This makes the exclusion
// EXPLICIT + durable so a future full re-sync can't reintroduce the hazard.
const DEDICATED_POSTER_VENDORS = "fabricut";

/*
 * The canonical ordered-DRAFT query. Emits every DRAFT (minus Schumacher + noise)
 * in the exact textures-first + vendor-round-robin order, with the columns both
 * consumers need. shopify_id is the durable per-item identity + image/title join.
 *
 * NOTE: `tags` is a text column (comma-joined), `metafields` is jsonb. coalesce
 * guards every field so a null never breaks the match string.
 */
const ROTATION_ORDER_SQL = `
WITH pool AS (
  SELECT
    shopify_id,
    vendor,
    coalesce(sku, dw_sku)                         AS dw_sku,
    dw_sku                                          AS raw_dw_sku,
    title,
    product_type,
    lower(
      coalesce(metafields->'specs'->>'material','') || ' ' ||
      coalesce(metafields->'custom'->>'material','') || ' ' ||
      coalesce(title,'')        || ' ' ||
      coalesce(tags,'')         || ' ' ||
      coalesce(product_type,'')
    )                                               AS matstr
  FROM shopify_products sp
  WHERE status = 'DRAFT'
    AND vendor NOT ILIKE '%schumacher%'                     -- internal-only, never storefront
    AND lower(coalesce(vendor,'')) !~ '${NEVER_ACTIVATE_VENDORS}' -- Gracie/Fromental/Zuber/De Gournay/Astek: bespoke/quote-only/internal, never auto-publish
    AND lower(coalesce(title,'')) !~ '${NEVER_ACTIVATE_TITLE}'         -- Et Cie (rights revoked 2026-08-13): brand in TITLE, both "Et Cie …" and "… by Et Cie" forms; vendor is "Designer Wallcoverings"
    AND lower(coalesce(title,'')) !~ '${NOISE_REGEX}'       -- drop help-doc noise rows
    AND lower(coalesce(sku, dw_sku, '')) !~ '${SAMPLE_SKU_REGEX}' -- exclude memo/sample SKUs (…-Sample / …-S): never a standalone live listing
    AND lower(coalesce(vendor,'')) !~ '${DEDICATED_POSTER_VENDORS}' -- Fabricut (TK-10762): published via its OWN full-pattern daily poster, never the shared 1-colorway rotation
    AND NOT EXISTS (                                        -- re-introduction guard (TITLE): skip a DRAFT whose title was EVER on the store
      SELECT 1 FROM shopify_products a                      -- (ACTIVE now, or intentionally ARCHIVED/DELETED before) — no resurrecting retired patterns
       WHERE a.status IN ('ACTIVE','ARCHIVED','DELETED_FROM_SHOPIFY')
         AND a.title = sp.title AND a.shopify_id <> sp.shopify_id
    )
    AND NOT EXISTS (                                        -- re-introduction guard (MFR_SKU): the title guard misses colorway rows that
      SELECT 1 FROM shopify_products a                      -- share a manufacturer SKU under a slightly different title. mfr_sku is the
       WHERE a.status IN ('ACTIVE','ARCHIVED','DELETED_FROM_SHOPIFY') -- STABLE pattern identity — an exact mfr_sku already on the store means the
         AND coalesce(sp.mfr_sku,'') <> ''                 -- same physical pattern we already sell / already retired (Steve 2026-08-12,
         AND upper(trim(a.mfr_sku)) = upper(trim(sp.mfr_sku)) -- TK-10480: caught 285 dupes-of-ACTIVE + 41 dupes-of-retired slipping through).
         AND a.shopify_id <> sp.shopify_id
    )
    AND coalesce(sp.created_at_shopify, '1970-01-01'::timestamp) >= '2025-01-01' -- AGE GATE (Steve 2026-08-12, TK-10480): a DRAFT whose Shopify record predates
                                                                                 -- 2025 is an OLD product, not a "new arrival" (665/1237 staged were pre-2025,
                                                                                 -- 45 from 2018). NULL created date = old/unknown → excluded. Strict per Steve's pick.
),
tiered AS (
  SELECT *,
    CASE WHEN matstr ~ '${TEXTURE_REGEX}' THEN 0 ELSE 1 END AS mat_tier
  FROM pool
),
ranked AS (
  SELECT *,
    (row_number() OVER (PARTITION BY mat_tier, vendor
                        ORDER BY coalesce(dw_sku, raw_dw_sku, shopify_id))) * 100000
    + dense_rank() OVER (PARTITION BY mat_tier ORDER BY vendor)             AS rr
  FROM tiered
)
SELECT shopify_id, vendor, dw_sku, title, product_type, mat_tier, rr
FROM ranked
ORDER BY mat_tier, rr
`;

module.exports = { ROTATION_ORDER_SQL, TEXTURE_REGEX, NOISE_REGEX };