← back to CelebritySignatures

scripts/submit-pod-draft-printify.mjs

128 lines

#!/usr/bin/env node
// Steve-gated Printify order sender for TK-10286. Printify order creation can
// enter production depending on account settings, so this refuses to POST unless
// all FIVE gates are explicit: --apply, token, shop id, live sales, and a manual
// approval acknowledgement. A provider-specific marker prevents duplicate sends.
import { readFile, appendFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';

const ROOT = fileURLToPath(new URL('..', import.meta.url));
const DATA = join(ROOT, 'data');
const API = 'https://api.printify.com/v1';
const SITE_ORIGIN = 'https://celebsignatures.com';
const APPLY = process.argv.includes('--apply');
const SHOW_PII = process.argv.includes('--show-pii');

// Don't let a real customer's name/address/email land in a terminal
// scrollback or redirected log file by default; --show-pii opts back in.
function redactPayload(payload) {
  if (SHOW_PII) return payload;
  const a = payload.address_to || {};
  return { ...payload, address_to: { ...a, first_name: a.first_name ? '[redacted]' : a.first_name, last_name: a.last_name ? '[redacted]' : a.last_name,
    email: a.email ? '[redacted]' : a.email, address1: a.address1 ? '[redacted]' : a.address1, address2: a.address2 ? '[redacted]' : a.address2, zip: a.zip ? '[redacted]' : a.zip } };
}
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;
}
function resolveProduct(tpl, d) {
  const garment = (tpl.garments || []).find(g => g.id === d.garment);
  if (!garment) throw new Error(`order #${d.orderId}: unknown garment "${d.garment}"`);
  const map = garment.printify || {};
  const key = `${d.color}/${d.size}`;
  if (!map.blueprintId || !map.printProviderId || !map.variants?.[key]) throw new Error(`order #${d.orderId}: ${d.garment} ${key} is not mapped to a Printify blueprint/provider/variant`);
  return { blueprintId: map.blueprintId, printProviderId: map.printProviderId, variantId: map.variants[key],
    positions: map.printPositions || [map.printPosition || { placeholder: 'front', scale: 0.25, x: 0.32, y: 0.3 }],
    // Extra STATIC print areas (e.g. the 'Celebrity Signatures' collar tag) —
    // same image on every order for this garment, not the per-order design.
    extraPrintAreas: map.extraPrintAreas || [] };
}
function splitName(name) {
  const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
  return { first_name: parts.shift() || '', last_name: parts.join(' ') || '-' };
}
export function buildPayload(tpl, d) {
  const { blueprintId, printProviderId, variantId, positions, extraPrintAreas } = resolveProduct(tpl, d);
  const r = d.recipient || {};
  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(',')}`);
  if (!d.design_image_url) throw new Error(`order #${d.orderId}: no design_image_url`);
  const name = splitName(r.name);
  if (!positions.length) throw new Error(`order #${d.orderId}: no print positions configured`);
  const print_areas = {};
  for (const position of positions) {
    print_areas[position.placeholder || 'front'] = [{ src: d.design_image_url, scale: position.scale,
      x: position.x, y: position.y, angle: position.angle || 0 }];
  }
  for (const area of extraPrintAreas) {
    if (!area.placeholder || !area.imageUrl) continue;
    print_areas[area.placeholder] = [{ src: `${SITE_ORIGIN}${area.imageUrl}`, scale: area.scale, x: area.x, y: area.y, angle: 0 }];
  }
  return {
    external_id: `celebsig-wear-${d.orderId}`,
    label: `${d.signature_name} signature — ${d.garment}`,
    line_items: [{ print_provider_id: printProviderId, blueprint_id: blueprintId, variant_id: variantId, quantity: 1,
      external_id: `celebsig-wear-${d.orderId}-1`,
      print_areas }],
    is_printify_express: false,
    is_economy_shipping: false,
    shipping_method: 1,
    send_shipping_notification: false,
    address_to: { ...name, email: d.recipient_email, phone: '', country: r.country_code, region: r.state_code,
      address1: r.address1, address2: r.address2 || '', city: r.city, zip: r.zip },
  };
}
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(JSON.parse);
  const submitted = new Set(all.filter(x => x.status === 'SUBMITTED' && x.provider === 'printify').map(x => x.orderId));
  // Dedupe by orderId (defense-in-depth): two DRAFT_UNSENT lines for the same
  // order (a crash/retry on the writer side) must never both get submitted —
  // only the first is ever considered.
  const seenOrderIds = new Set();
  const drafts = all.filter(x => {
    if (x.status !== 'DRAFT_UNSENT' || x.provider !== 'printify' || submitted.has(x.orderId)) return false;
    if (seenOrderIds.has(x.orderId)) return false;
    seenOrderIds.add(x.orderId); return true;
  });
  const tpl = JSON.parse(await readFile(join(DATA, 'wear-templates.json'), 'utf8'));
  const token = envVal('PRINTIFY_API_TOKEN');
  const shopId = envVal('PRINTIFY_SHOP_ID');
  const salesLive = envVal('WEAR_SALES_LIVE') === '1';
  const manual = envVal('PRINTIFY_MANUAL_APPROVAL_CONFIRMED') === '1';
  const willApply = APPLY && !!token && !!shopId && salesLive && manual;
  console.log(`POD drafts pending: ${drafts.length} (provider: Printify)`);
  for (const d of drafts) {
    let payload;
    try { payload = buildPayload(tpl, d); } catch (e) { console.log(`  SKIP: ${e.message}`); continue; }
    console.log(JSON.stringify(redactPayload(payload), null, 2));
    if (!willApply) continue;
    if (tpl.garments.find(g => g.id === d.garment)?.saleEnabled === false) {
      console.log(`  HELD: ${d.garment} is not approved for sale or fulfillment.`);
      continue;
    }
    const resp = await fetch(`${API}/shops/${shopId}/orders.json`, { method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'CelebritySignatures/1.0', 'Content-Type': 'application/json' },
      body: JSON.stringify(payload) });
    const body = await resp.json().catch(() => ({}));
    if (!resp.ok) { console.error(`PRINTIFY ERROR ${resp.status}: ${body.message || body.error || 'unknown'}`); continue; }
    await appendFile(join(DATA, 'pod-order-drafts.jsonl'), JSON.stringify({ orderId: d.orderId, status: 'SUBMITTED', provider: 'printify', printifyId: body.id, submittedAt: new Date().toISOString() }) + '\n');
    console.log(`  Printify order created: ${body.id}; production remains controlled by the account's manual approval setting.`);
  }
  if (!willApply) {
    console.log('\nDRY RUN — nothing submitted to Printify.');
    console.log(`  --apply: ${APPLY} | token: ${!!token} | shop: ${!!shopId} | sales live: ${salesLive} | manual approval confirmed: ${manual}`);
    console.log('  All five gates must be true. Verify the Printify store Order approval setting is Manual before setting the acknowledgement.');
  }
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  main().catch(e => { console.error(e.message); process.exit(1); });
}