← back to Discontinued Agent
lib/shopify.js
175 lines
// Shopify Admin API writes for the discontinued-agent.
//
// When a vendor reply is a HIGH-CONFIDENCE explicit discontinuation and its
// mfr# maps cleanly to Shopify product(s), we:
// WRITE 1 — set product status -> ARCHIVED (pulls it from the live store)
// WRITE 2 — stamp metafield custom.date_discontinued (date) = today
//
// SAFETY: every write is dry-run unless SHOPIFY_AUTOCOMMIT=1. The mfr#->product
// bridge is the local dw_unified mirror (shopify_products.mfr_sku, indexed) with
// a secondary match on the manufacturer_sku metafield (mirror.mfr_sku is blank
// on ~22% of rows even when the real mfr# lives in a metafield). Live status is
// re-read before any archive so a stale mirror never drives a bad write.
//
// Creds are read from the secrets-manager master .env (never hardcoded). This
// is the LIVE production store (designer-laboratory-sandbox is a legacy name).
//
// Cost: Shopify Admin API + local Postgres mirror = $0 per call.
import { readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
const API_VERSION = process.env.SHOPIFY_API_VERSION || '2024-10';
const MF_NAMESPACE = 'custom';
const MF_KEY = 'date_discontinued';
const PSQL = process.env.PSQL_BIN || '/opt/homebrew/opt/postgresql@14/bin/psql';
const MIRROR_DSN = process.env.DW_UNIFIED_DSN || 'host=/tmp dbname=dw_unified';
// Sanity cap: a single mfr# resolving to more than this many ACTIVE products is
// suspicious (broad/garbage match) -> queue instead of auto-archiving.
export const MATCH_CAP = Number(process.env.SHOPIFY_MATCH_CAP || 25);
const SECRETS_ENV = process.env.SECRETS_ENV || '/Users/macstudio3/Projects/secrets-manager/.env';
function fromSecrets(key) {
if (process.env[key]) return process.env[key];
try {
const raw = readFileSync(SECRETS_ENV, 'utf8');
for (const line of raw.split('\n')) {
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (m && m[1] === key) {
let v = m[2].trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
return v;
}
}
} catch { /* ignore */ }
return null;
}
function creds() {
const domain =
fromSecrets('SHOPIFY_STORE_DOMAIN') || fromSecrets('SHOPIFY_STORE') || 'designer-laboratory-sandbox.myshopify.com';
const token = fromSecrets('SHOPIFY_ADMIN_TOKEN');
if (!token) throw new Error('SHOPIFY_ADMIN_TOKEN not found in secrets-manager .env');
return { domain, token };
}
export function today() {
// Shopify `date` metafields want ISO YYYY-MM-DD.
return new Date().toISOString().slice(0, 10);
}
async function gql(query, variables = {}) {
const { domain, token } = creds();
const res = await fetch(`https://${domain}/admin/api/${API_VERSION}/graphql.json`, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
const data = await res.json();
if (data.errors) throw new Error('Shopify GraphQL: ' + JSON.stringify(data.errors));
return data.data;
}
// Idempotently ensure the custom.date_discontinued metafield DEFINITION exists
// (so the stamped value renders as a typed Date in admin). Safe to call every run.
export async function ensureDefinition() {
const d = await gql(
`mutation($def: MetafieldDefinitionInput!){
metafieldDefinitionCreate(definition:$def){
createdDefinition{ id } userErrors{ code message }
}
}`,
{
def: {
name: 'Date Discontinued',
namespace: MF_NAMESPACE,
key: MF_KEY,
type: 'date',
ownerType: 'PRODUCT',
description: 'Date the vendor confirmed this product discontinued (set by discontinued-agent).',
},
}
);
const errs = d.metafieldDefinitionCreate.userErrors || [];
const taken = errs.some((e) => e.code === 'TAKEN');
return { created: !!d.metafieldDefinitionCreate.createdDefinition, alreadyExisted: taken, errors: errs.filter((e) => e.code !== 'TAKEN') };
}
// Mirror lookup: mfr# -> ACTIVE Shopify products. Uses spawnSync (no shell) with
// psql :'mfr' safe-quoting; matches the mfr_sku column OR the manufacturer_sku
// metafield. Returns [{ gid, dwSku, viaColumn }].
export function findActiveProductsByMfr(mfr) {
const sql =
"SELECT shopify_id, coalesce(dw_sku,''), (upper(mfr_sku)=upper(:'mfr'))::int " +
'FROM shopify_products ' +
"WHERE status='ACTIVE' AND (" +
"upper(mfr_sku)=upper(:'mfr') OR " +
"upper(coalesce(metafields->'manufacturer_sku'->>'value','')) = upper(:'mfr'))";
const r = spawnSync(PSQL, [MIRROR_DSN, '-v', `mfr=${mfr}`, '-Atc', sql, '-F', '\t'], { encoding: 'utf8' });
if (r.status !== 0) throw new Error('mirror lookup failed: ' + (r.stderr || '').trim());
return (r.stdout || '')
.split('\n')
.filter(Boolean)
.map((line) => {
const [gid, dwSku, viaCol] = line.split('\t');
return { gid, dwSku, viaColumn: viaCol === '1' };
});
}
// Live-read a product's status + current discontinued-date metafield.
export async function getProduct(gid) {
const d = await gql(
`query($id:ID!){ product(id:$id){ id status title
metafield(namespace:"${MF_NAMESPACE}", key:"${MF_KEY}"){ value } } }`,
{ id: gid }
);
return d.product;
}
// Archive + stamp ONE product. Dry-run unless commit=true. Returns a diff record.
export async function archiveAndStamp(gid, { commit = false } = {}) {
const before = await getProduct(gid);
if (!before) return { gid, error: 'product not found live (stale mirror)' };
const stamp = today();
const needArchive = before.status !== 'ARCHIVED';
const needStamp = !before.metafield || !before.metafield.value;
const plan = {
gid,
title: before.title,
statusBefore: before.status,
statusAfter: needArchive ? 'ARCHIVED' : before.status,
dateBefore: before.metafield?.value || null,
dateAfter: needStamp ? stamp : before.metafield?.value || stamp,
willArchive: needArchive,
willStamp: needStamp,
committed: false,
};
if (!commit || (!needArchive && !needStamp)) return plan;
if (needArchive) {
const d = await gql(
`mutation($input:ProductInput!){ productUpdate(input:$input){ product{ id status } userErrors{ field message } } }`,
{ input: { id: gid, status: 'ARCHIVED' } }
);
const e = d.productUpdate.userErrors;
if (e && e.length) return { ...plan, error: 'archive: ' + JSON.stringify(e) };
}
if (needStamp) {
const d = await gql(
`mutation($m:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$m){ metafields{ id value } userErrors{ field message } } }`,
{ m: [{ ownerId: gid, namespace: MF_NAMESPACE, key: MF_KEY, type: 'date', value: stamp }] }
);
const e = d.metafieldsSet.userErrors;
if (e && e.length) return { ...plan, error: 'stamp: ' + JSON.stringify(e) };
}
return { ...plan, committed: true };
}
// Health/ping — resolves creds + a cheap shop query.
export async function ping() {
const d = await gql(`{ shop { name myshopifyDomain } }`);
return { ok: true, shop: d.shop.name, domain: d.shop.myshopifyDomain };
}