← back to Atomic50 Onboard

cadence/atomic50-cadence.mjs

210 lines

#!/usr/bin/env node
/**
 * atomic50-cadence.mjs — MONTHLY Atomic 50 Ceilings line maintenance.
 *   PROPOSE-ONLY. NEVER writes Shopify, never publishes. Modeled on york-cadence.mjs.
 *
 * Each run (15th @ 08:00 PT):
 *   a. Best-effort re-scrape / refresh atomic50_catalog (try/catch — a headless failure
 *      must NOT crash the cadence). The atomic50-scraper-manager skill ships no runnable
 *      script entrypoint today, so this pass currently SKIPS the re-scrape and notes it;
 *      when an entrypoint lands, point RESCRAPE_CMD at it and it will run best-effort.
 *   b. Diff catalog vs live Shopify (vendor "Atomic 50 Ceilings", keyed by sec/dwc mfr_sku):
 *        - onboard-ready : real catalog SKU (not LBI-BOYD) not yet on Shopify
 *        - disco         : catalog row flagged discontinued but still live ACTIVE
 *        - image-drift    : live product carrying an Atomic 50 mfr_sku with NO image
 *      Since all 58 real SKUs are currently mapped, the go-forward signal is NEW or REMOVED.
 *   c. If anything is actionable -> ONE dated pending-approval memo + ONE CNCP parking-lot
 *      card. If nothing changed -> write nothing (silent, like the canaries).
 *   d. NEVER auto-write Shopify. Propose only.
 *   e. Bump vendor_registry next_scheduled_crawl +1 month, last_product_update=now().
 *   f. Append a run-log line to cadence/data/cadence.log.
 *
 * Cost: $0 — local PG (host=/tmp socket) + read-only Shopify Admin GraphQL (rate-limited).
 */
import fs from 'node:fs';
import { execSync } from 'node:child_process';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { Client } = require('pg');

const ROOT = process.env.HOME + '/Projects/atomic50-onboard';
const DATA = ROOT + '/cadence/data';
const SHOP = 'designer-laboratory-sandbox.myshopify.com', VER = '2024-10';
const URL = `https://${SHOP}/admin/api/${VER}/graphql.json`;
const VENDOR = 'Atomic 50 Ceilings';
// Runnable feed-first scraper entrypoint (sitemap + ?format=json, $0 plain-fetch).
// Wired 2026-07-14. Best-effort: a failure here does not crash the cadence.
const RESCRAPE_CMD = `/opt/homebrew/bin/node ${process.env.HOME}/Projects/atomic50-onboard/scraper/scrape-atomic50.mjs`;

// ---- token (parse .env directly; do NOT shell-source — unquoted-value bug) ----
function readToken() {
  const env = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
  for (const line of env.split('\n')) {
    const m = line.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/);
    if (m) return m[1].trim().replace(/^["']|["']$/g, '');
  }
  throw new Error('SHOPIFY_ADMIN_TOKEN not found');
}
const TOKEN = readToken();

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const norm = (s) => String(s || '').toUpperCase().trim();
const stamp = () => new Date().toISOString();

const gql = async (query, v) => {
  for (let a = 0; a < 8; a++) {
    let j;
    try {
      const r = await fetch(URL, {
        method: 'POST',
        headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
        body: JSON.stringify({ query, variables: v }),
      });
      j = await r.json();
    } catch (e) { await sleep(1500 * (a + 1)); continue; }
    if (j.errors) {
      if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; }
      throw new Error(JSON.stringify(j.errors));
    }
    const t = j.extensions?.cost?.throttleStatus;
    if (t && t.currentlyAvailable < 300) await sleep(1500);
    return j.data;
  }
  throw new Error('gql retries exhausted');
};

// ---- a. best-effort re-scrape ------------------------------------------------
function rescrape() {
  if (!RESCRAPE_CMD) return { ran: false, note: 'no scraper entrypoint wired — re-scrape skipped this pass' };
  try {
    execSync(RESCRAPE_CMD, { stdio: 'ignore', timeout: 20 * 60 * 1000 });
    return { ran: true, note: 'atomic50 re-scrape completed' };
  } catch (e) {
    return { ran: false, note: `re-scrape failed (continuing on existing catalog): ${String(e.message || e).slice(0, 200)}` };
  }
}

async function main() {
  fs.mkdirSync(DATA, { recursive: true });
  const ts = stamp();
  const rescr = rescrape();

  const c = new Client({ host: '/tmp', database: 'dw_unified' });
  await c.connect();

  // ---- catalog snapshot (exclude the LBI-BOYD resource row) ----
  const { rows: cat } = await c.query(`
    SELECT mfr_sku, dw_sku, product_type, shopify_product_id, on_shopify,
           discontinued, image_url
    FROM atomic50_catalog
    WHERE mfr_sku <> 'LBI-BOYD'`);
  const catByMfr = new Map();
  for (const r of cat) catByMfr.set(norm(r.mfr_sku), r);

  // ---- live Shopify: vendor "Atomic 50 Ceilings", keyed by sec/dwc mfr_sku ----
  const liveMfr = new Set();
  const liveNoImage = [];
  const liveActiveByMfr = new Map();
  let cur = null;
  for (let pg = 0; pg < 60; pg++) {
    const d = (await gql(
      `query($c:String){products(first:60,after:$c,query:"vendor:'Atomic 50 Ceilings'"){pageInfo{hasNextPage endCursor} nodes{id status title featuredImage{url} sec:metafield(namespace:"sec",key:"mfr_sku"){value} dwc:metafield(namespace:"dwc",key:"manufacturer_sku"){value} cust:metafield(namespace:"custom",key:"manufacturer_sku"){value}}}}`,
      { c: cur }
    ))?.products;
    if (!d) break;
    for (const p of d.nodes) {
      const mfr = norm(p.sec?.value || p.dwc?.value || p.cust?.value);
      if (!mfr) continue;
      liveMfr.add(mfr);
      if (p.status === 'ACTIVE') liveActiveByMfr.set(mfr, p);
      if (!p.featuredImage?.url) liveNoImage.push({ mfr, title: p.title, status: p.status });
    }
    if (!d.pageInfo.hasNextPage) break;
    cur = d.pageInfo.endCursor;
  }

  // ---- b. diff ----
  // onboard-ready: real catalog SKU not present on live Shopify (NEW on the site since onboard)
  const onboard = cat
    .filter((r) => !liveMfr.has(norm(r.mfr_sku)))
    .map((r) => ({ mfr: r.mfr_sku, dw_sku: r.dw_sku, type: r.product_type }));

  // disco: catalog row flagged discontinued but still live ACTIVE (REMOVED from the site)
  const disco = [];
  for (const [mfr, p] of liveActiveByMfr) {
    const row = catByMfr.get(mfr);
    if (row && row.discontinued) disco.push({ mfr, title: p.title });
  }

  // image-drift: live Atomic 50 product with no image (kept as draft per DW rule, worth flagging)
  const imageDrift = liveNoImage;

  const actionable = onboard.length + disco.length + imageDrift.length;

  const out = {
    ts,
    rescrape: rescr,
    catalog_real: cat.length,
    live_shopify: liveMfr.size,
    onboard_ready: onboard.length,
    disco: disco.length,
    image_drift: imageDrift.length,
    onboard_sample: onboard.slice(0, 12),
    disco_sample: disco.slice(0, 10),
    image_drift_sample: imageDrift.slice(0, 10),
  };
  fs.writeFileSync(DATA + '/latest.json', JSON.stringify(out, null, 2));

  // ---- c. propose only on change (silent otherwise) ----
  if (actionable > 0) {
    const day = ts.slice(0, 10);
    const memo =
`# Atomic 50 cadence — ${actionable} actionable (${day})

Monthly propose-only diff of atomic50_catalog vs live Shopify (vendor "${VENDOR}").
All customer-facing writes stay GATED — this is a surface, not an auto-apply.

- **Onboard-ready**: ${onboard.length} real catalog SKU(s) not yet on Shopify → run push-shopify.js (creates DRAFT, quote-only/sample-only). Sample: ${onboard.slice(0, 8).map((o) => o.mfr).join(', ') || '—'}
- **Disco**: ${disco.length} live ACTIVE now flagged discontinued in catalog → ARCHIVE (never delete). Sample: ${disco.slice(0, 8).map((d) => d.mfr).join(', ') || '—'}
- **Image-drift**: ${imageDrift.length} live Atomic 50 product(s) with NO image → keep DRAFT + tag Needs-Image until backfilled. Sample: ${imageDrift.slice(0, 8).map((i) => i.mfr).join(', ') || '—'}

Re-scrape this pass: ${rescr.ran ? 'ran' : 'skipped'} — ${rescr.note}
Data: cadence/data/latest.json

## Decision
- [ ] **APPROVE** — which action to run (onboard DRAFT / archive disco / backfill images)
- [ ] **REVISE** — adjust scope
- [ ] **BLOCK** — do nothing this cycle
`;
    fs.writeFileSync(process.env.HOME + '/.claude/yolo-queue/pending-approval/atomic50-cadence-actions.md', memo);
    try {
      execSync(
        `curl -s -X POST http://127.0.0.1:3333/api/parking-lot -H 'Content-Type: application/json' -d ${JSON.stringify(
          JSON.stringify({
            project: 'atomic50-onboard',
            title: `Atomic 50 cadence: ${actionable} actionable`,
            note: `onboard ${onboard.length} / disco ${disco.length} / image-drift ${imageDrift.length}`,
          })
        )}`,
        { stdio: 'ignore' }
      );
    } catch (e) { /* CNCP down — memo still written */ }
  }

  // ---- e. bump the schedule (+1 month) and stamp last_product_update ----
  await c.query(`
    UPDATE vendor_registry
    SET next_scheduled_crawl = next_scheduled_crawl + interval '1 month',
        last_product_update  = now()
    WHERE vendor_name = $1`, [VENDOR]);

  await c.end();

  // ---- f. run-log line ----
  const logLine = `${ts} | catalog ${cat.length} | live ${liveMfr.size} | onboard-ready ${onboard.length} | disco ${disco.length} | image-drift ${imageDrift.length} | actionable ${actionable} | rescrape=${rescr.ran ? 'ran' : 'skipped'}\n`;
  fs.appendFileSync(DATA + '/cadence.log', logLine);
  console.log('atomic50-cadence ' + logLine.trim());
}

main().catch((e) => { console.error('atomic50-cadence FAILED:', e); process.exit(1); });