← 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); });