← back to CelebritySignatures
scripts/submit-pod-draft.mjs
140 lines
#!/usr/bin/env node
// Steve-gated PRINTFUL sender for POD (print-on-demand) order drafts.
//
// The /wear checkout appends a DRAFT_UNSENT record per PAID order to
// data/pod-order-drafts.jsonl. This script is the ONLY place a draft ever leaves
// the building — and it refuses unless ALL THREE are true (defense in depth):
// 1. --apply is passed,
// 2. PRINTFUL_API_KEY is present (env or .env),
// 3. WEAR_SALES_LIVE=1 (real sales are actually turned on).
// Without all three it is a DRY RUN: it prints the exact Printful /orders payload
// it WOULD POST, so the left-chest order can be reviewed before anything is sent.
//
// Even when applied, orders are created with confirm=false — i.e. a DRAFT order in
// the Printful dashboard, NOT auto-fulfilled and NOT auto-charged. A human confirms
// each order in Printful. Provider: PRINTFUL (Steve's call 2026-08-10, TK-10286).
import { readFile, appendFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { readFileSync } from 'node:fs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const DATA = join(ROOT, 'data');
const APPLY = process.argv.includes('--apply');
const SHOW_PII = process.argv.includes('--show-pii');
const PRINTFUL_ORDERS_URL = 'https://api.printful.com/orders';
function envVal(name) {
if (process.env[name]) return process.env[name];
for (const p of [join(ROOT, '.env'), join(ROOT, '..', 'secrets-manager', '.env')]) {
try { const m = readFileSync(p, 'utf8').match(new RegExp('^' + name + '=(.+)$', 'm')); if (m) return m[1].trim(); } catch {}
}
return null;
}
// Dry-run preview only — never sent anywhere, never written to a file. Masks
// recipient PII (name/address/email) so a dry-run (the DEFAULT mode) never
// puts a real customer's home address into stdout/console logs. Region-level
// fields (city/state/country) stay visible since that's what a human
// reviewer needs to sanity-check routing; --show-pii opts into the raw
// payload for deliberate debugging.
function redactForLog(payload) {
if (SHOW_PII) return payload;
const r = payload.recipient || {};
const maskName = s => s ? s.trim().split(/\s+/).map(w => w[0] + '.').join(' ') : s;
const maskEmail = s => { const m = /^(.)(.*)(@.+)$/.exec(s || ''); return m ? `${m[1]}***${m[3]}` : s; };
return { ...payload, recipient: { ...r, name: maskName(r.name), email: maskEmail(r.email),
address1: r.address1 ? '[redacted]' : r.address1, address2: r.address2 ? '[redacted]' : r.address2, zip: r.zip ? r.zip.slice(0, 3) + '**' : r.zip } };
}
async function templates() {
return JSON.parse(await readFile(join(DATA, 'wear-templates.json'), 'utf8'));
}
// Resolve our (garment,color,size) -> Printful catalog variant_id. FAILS LOUD when
// unmapped so an unmapped garment can never be submitted (the map is filled by
// scripts/fetch-printful-catalog.mjs once PRINTFUL_API_KEY lands).
function resolveVariant(tpl, d) {
const g = (tpl.garments || []).find(x => x.id === d.garment);
if (!g) throw new Error(`order #${d.orderId}: unknown garment "${d.garment}"`);
const pf = g.printful || {};
const key = `${d.color}/${d.size}`;
const vid = (pf.variants || {})[key];
if (!vid) throw new Error(`order #${d.orderId}: garment "${d.garment}" ${key} is not mapped to a Printful variant_id — run scripts/fetch-printful-catalog.mjs after PRINTFUL_API_KEY is set, then fill data/wear-templates.json.`);
return { variantId: vid, placementType: pf.placementType || 'front' };
}
function buildPayload(tpl, d) {
const { variantId, placementType } = resolveVariant(tpl, d);
const r = d.recipient || {};
// Printful requires a real shipping address for a physical order.
const missing = ['name', 'address1', 'city', 'state_code', 'country_code', 'zip'].filter(k => !r[k]);
if (missing.length) throw new Error(`order #${d.orderId}: missing shipping fields ${missing.join(',')} — the Stripe checkout must collect a shipping address (shipping_address_collection) and /wear-success must carry it into the draft before this can submit.`);
if (!d.design_image_url) throw new Error(`order #${d.orderId}: no design_image_url`);
return {
external_id: `celebsig-wear-${d.orderId}`,
recipient: {
name: r.name, email: d.recipient_email,
address1: r.address1, address2: r.address2 || '',
city: r.city, state_code: r.state_code, country_code: r.country_code, zip: r.zip,
},
items: [{
variant_id: variantId, quantity: 1,
name: `${d.signature_name} signature — left-chest ${d.garment}`,
files: [{ type: placementType, url: d.design_image_url }],
}],
};
}
async function main() {
let lines = [];
try { lines = (await readFile(join(DATA, 'pod-order-drafts.jsonl'), 'utf8')).trim().split('\n').filter(Boolean); } catch {}
const all = lines.map(l => JSON.parse(l));
// Orders already sent to Printful get a SUBMITTED marker line appended below.
// Exclude them so a rerun (network glitch, human re-run) NEVER double-submits.
const submitted = new Set(all.filter(d => d.status === 'SUBMITTED' && (!d.provider || d.provider === 'printful')).map(d => d.orderId));
// Dedupe by orderId (defense-in-depth): if the append-only drafts log ever
// ends up with two DRAFT_UNSENT lines for the same order (a crash/retry on
// the writer side), only the FIRST is ever considered — never submit the
// same paid order twice to Printful.
const seenOrderIds = new Set();
const drafts = all.filter(d => {
if (d.status !== 'DRAFT_UNSENT' || (d.provider && d.provider !== 'printful') || submitted.has(d.orderId)) return false;
if (seenOrderIds.has(d.orderId)) return false;
seenOrderIds.add(d.orderId); return true;
});
const tpl = await templates();
console.log(`POD drafts pending: ${drafts.length} (provider: Printful)`);
const key = envVal('PRINTFUL_API_KEY');
const salesLive = envVal('WEAR_SALES_LIVE') === '1';
const willApply = APPLY && !!key && salesLive;
for (const d of drafts) {
console.log(`\n— order #${d.orderId} (${d.signature_name}) —`);
let payload;
try { payload = buildPayload(tpl, d); }
catch (e) { console.log(` SKIP: ${e.message}`); continue; }
console.log(JSON.stringify(redactForLog(payload), null, 2));
if (!willApply) continue;
// confirm=false => Printful DRAFT order (not auto-fulfilled / not auto-charged).
const resp = await fetch(PRINTFUL_ORDERS_URL + '?confirm=false', {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const j = await resp.json().catch(() => ({}));
if (!resp.ok) { console.error(` PRINTFUL ERROR ${resp.status}: ${j.error?.message || j.result || 'unknown'}`); continue; }
// Mark this order SUBMITTED so a rerun skips it (append-only, matches the draft log).
await appendFile(join(DATA, 'pod-order-drafts.jsonl'), JSON.stringify({ orderId: d.orderId, status: 'SUBMITTED', provider: 'printful', printfulId: j.result?.id, submittedAt: new Date().toISOString() }) + '\n');
console.log(` ✓ Printful DRAFT order created: id=${j.result?.id} status=${j.result?.status} (confirm it in the Printful dashboard to fulfill).`);
}
if (!willApply) {
console.log(`\nDRY RUN — nothing submitted to Printful.`);
console.log(` --apply: ${APPLY} | PRINTFUL_API_KEY present: ${!!key} | WEAR_SALES_LIVE=1: ${salesLive}`);
console.log(` All three must be true to submit. Even then, orders are created as Printful DRAFTS (confirm=false) and a human confirms each one.`);
}
}
main().catch(e => { console.error(e.message); process.exit(1); });