← back to Majilite Quote Cta

scripts/importer-guard-proposal.md

85 lines

# Importer fix — `~/Projects/majilite-onboard/scripts/publish_shopify.js`

## What shipped the broken half-state
- **L34**: `const STATUS = (opt('status','active')==='draft') ? 'DRAFT' : 'ACTIVE';`
  → default `ACTIVE`, no validate-before-activate gate.
- **L118–119**: creates ONLY a `Type: Sample` option + a single `$4.25` Sample
  variant. No sellable variant.
- **L113**: `tags = [...p.tags, dw_sku, 'display_variant', 'Sample']` — no
  quote-only trigger tag.

Result: 160 products went straight to `ACTIVE` with a $4.25 sample as the only
purchasable variant and no quote mechanism → the $4.25 shows as the headline
price and the material can't be bought. This is precisely the
`has_product_variant = false` failure the **dw-five-field-canary** guards
(rule #2), and it advertises to GMC at $4.25.

## Proposed guard (quote-only lines ship WITH the trigger tag, and never
## active-without-a-sellable-variant)

Two changes to `publish_shopify.js`:

### 1. Tag quote-only products with the trigger tag at create time
Add a `QUOTE_ONLY` flag (config or per-product), and inject the tags:

```js
// near the top config
const QUOTE_ONLY = true; // Majilite Metallic Specialties I is quote-only (no yardage price loaded)
const QUOTE_TAGS = QUOTE_ONLY ? ['quotes', 'Quote Only'] : [];

// L113 — add QUOTE_TAGS to the tag set
const tags = Array.from(new Set([...p.tags, p.dw_sku, 'display_variant', 'Sample', ...QUOTE_TAGS])).join(', ');
```

`quotes` is the tag the dw-five-field-canary already exempts (auditor.mjs L51),
so a correctly-tagged quote-only line no longer trips the canary.

### 2. Refuse ACTIVE without a sellable variant UNLESS quote-only
The canary's rule is: an ACTIVE product must have a sellable variant OR carry the
`quotes` tag. Enforce the same invariant at the importer chokepoint so a future
line can't ship sample-only-and-untagged:

```js
function assertActivatable(input, quoteOnly) {
  const hasSample = input.variants.some(v => (v.sku || '').endsWith('-Sample'));
  const hasSellable = input.variants.some(v => !(v.sku || '').endsWith('-Sample'));
  const hasQuoteTag = input.tags.includes('quotes');
  if (input.status === 'ACTIVE') {
    if (!hasSample) throw new Error(`GATE: ${input.handle} ACTIVE without a Sample variant`);
    if (!hasSellable && !hasQuoteTag) {
      throw new Error(`GATE: ${input.handle} ACTIVE sample-only without the 'quotes' tag — ship as DRAFT or tag quote-only`);
    }
  }
}

// before the productSet mutation (after building `input`):
assertActivatable(input, QUOTE_ONLY);
```

With QUOTE_ONLY=true the guard passes because `quotes` is in the tags; a future
run that forgets the tag on a sample-only line HARD-FAILS instead of shipping
the broken state.

## Canonical enforcement (better than per-script)
The DW canon already has ONE enforced validator:
`~/Projects/Designer-Wallcoverings/shopify/scripts/lib/validate-before-activate.js`
→ `validateBeforeActivate(product)`, wired into `cadence-import.js` and
`activate-gated.js`. The Majilite onboarder BYPASSED it (it's a standalone
project, not a cadence import). The durable fix is to route one-off vendor
onboarders through that same validator — OR extend the validator to encode the
"ACTIVE sample-only ⇒ must carry `quotes`" rule so it's enforced everywhere, not
just in the two cadence chokepoints.

## Did dw-five-field-canary fire on this?
- The 06:15 run today returned **PASS, recent_fail=0** — but ONLY because the
  Majilite line synced to the dw_unified mirror at 13:22 PT, AFTER the 06:15
  run. Simulating the canary's `recent_fail` query against the CURRENT mirror
  returns **168/168 Majilite failing** (has_product_variant=false, no `quotes`
  tag, created <7d).
- So the canary is CORRECT and WILL fire FAIL at the next 06:15 run — unless the
  `quotes` tag lands first (which auto-resolves it) or sellable variants are
  added.
- Recommendation: apply the `quotes` tag (Deliverable 2) BEFORE the next 06:15
  canary run to avoid a self-inflicted FAIL alert, since the quote-only design
  is the intended end state.