← back to Fix Live Board
probes/shopify-recent.mjs
86 lines
#!/usr/bin/env node
// "Last N days" probe — every product created in the last N days (all statuses), across all
// vendors, checked against Steve's standing go-live rule: sample variant, sellable variant,
// complete price, description, >=2 tags, featured image. READ-ONLY live DW Shopify.
// Env: DAYS (default 5). Token: SHOPIFY_ADMIN_TOKEN -> secrets-manager/.env -> flock-fix-viewer/.token
import https from 'https';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const DAYS = parseInt(process.env.DAYS || '5', 10);
// Showroom lines are SAMPLE-ONLY by design — a sample with no sellable roll/price is CORRECT, not
// broken. Editable list; seeded with Phillip Jeffries.
let SHOWROOM = new Set(['phillip jeffries']);
try { SHOWROOM = new Set(JSON.parse(fs.readFileSync(path.join(ROOT, '..', 'config', 'showroom-vendors.json'), 'utf8')).map(s => String(s).trim().toLowerCase())); } catch (e) {}
function token() {
if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN.trim();
try { const env = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8'); const m = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m); if (m) return m[1].trim(); } catch (e) {}
return fs.readFileSync(path.join(os.homedir(), 'Projects/flock-fix-viewer/.token'), 'utf8').trim();
}
const TOKEN = token();
const since = new Date(Date.now() - DAYS * 864e5).toISOString().slice(0, 10);
const QUERY = 'created_at:>=' + since;
const PRODUCT_Q = `query($c:String){
products(first:100, query:${JSON.stringify(QUERY)}, sortKey:CREATED_AT, reverse:true, after:$c){
pageInfo{hasNextPage endCursor}
edges{node{ handle title status createdAt vendor descriptionHtml featuredImage{url} tags
variants(first:20){edges{node{sku title price}}}
}}
}
}`;
function gql(query, variables) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ query, variables: variables || {} });
const req = https.request({ host: SHOP, path: '/admin/api/2024-10/graphql.json', method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } },
res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } }); });
req.on('error', reject); req.write(body); req.end();
});
}
(async () => {
let cur = null, all = [];
do {
const r = await gql(PRODUCT_Q, { c: cur });
const p = r && r.data && r.data.products; if (!p) { if (r && r.errors) process.stderr.write(JSON.stringify(r.errors)); break; }
all = all.concat(p.edges);
cur = p.pageInfo.hasNextPage ? p.pageInfo.endCursor : null;
} while (cur && all.length < 3000);
const rows = all.map(e => {
const n = e.node;
const vs = (n.variants.edges || []).map(v => v.node);
const isSample = v => /sample/i.test(v.title || '') || /-sample$/i.test(v.sku || '');
const sampleV = vs.find(isSample);
const sellV = vs.find(v => v.sku && !isSample(v)); // sellable = a real (non-sample) variant
const sellPrice = sellV ? (parseFloat(sellV.price) || 0) : 0;
const tags = n.tags || [];
const showroom = SHOWROOM.has((n.vendor || '').trim().toLowerCase());
const f = {
sample: !!sampleV,
// showroom lines are sample-only by design → sellable + price are waived (shown satisfied)
sellable: showroom ? true : !!sellV,
price: showroom ? true : sellPrice > 0,
desc: !!(n.descriptionHtml && n.descriptionHtml.replace(/<[^>]*>/g, '').trim().length > 0),
tags: tags.length >= 2,
image: !!(n.featuredImage && n.featuredImage.url)
};
return {
id: (sellV && sellV.sku) || (sampleV && sampleV.sku) || n.handle,
sku: (sellV && sellV.sku) || (sampleV && sampleV.sku) || '—',
handle: n.handle,
title: '[' + (n.vendor || '?') + '] ' + n.title,
status: n.status, price: sellPrice,
img: (n.featuredImage && n.featuredImage.url) || null,
created: n.createdAt, note: showroom ? 'showroom' : undefined, fields: f,
// showroom lines are fixed when the sample + quality fields are present (no sellable/price needed)
fixed: showroom
? (f.sample && f.desc && f.tags && f.image)
: (f.sample && f.sellable && f.price && f.desc && f.tags && f.image)
};
});
process.stdout.write(JSON.stringify(rows));
})().catch(e => { process.stderr.write(String(e)); process.exit(1); });