← back to CelebritySignatures
wear/POD: wire Printful sender + go-live path (gated OFF) — TK-10286
0fa8dbf50ca483f1984a524336a084315e619656 · 2026-08-10 15:14:47 -0700 · Steve Abrams
- submit-pod-draft.mjs: Printful /orders payload, triple-gated (--apply + PRINTFUL_API_KEY + WEAR_SALES_LIVE=1), confirm=false drafts, fail-loud on unmapped variant / missing shipping addr; dry-run default
- fetch-printful-catalog.mjs (new): read-only catalog->variant_id mapper
- wear-templates.json: per-garment printful{} block (empty by design)
- server.js: Stripe shipping_address_collection on /wear-checkout + carry address into order+POD draft (still behind WEAR_SALES_LIVE coming-soon gate; no visitor reaches checkout)
- verified node --check clean, dry-run submits nothing, unmapped garment skipped
- go-live memo -> pending-approval (real flip stays Steve-gated: key+live Stripe+WEAR_SALES_LIVE)
Files touched
M data/wear-templates.jsonA scripts/fetch-printful-catalog.mjsM scripts/submit-pod-draft.mjsM server.js
Diff
commit 0fa8dbf50ca483f1984a524336a084315e619656
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 10 15:14:47 2026 -0700
wear/POD: wire Printful sender + go-live path (gated OFF) — TK-10286
- submit-pod-draft.mjs: Printful /orders payload, triple-gated (--apply + PRINTFUL_API_KEY + WEAR_SALES_LIVE=1), confirm=false drafts, fail-loud on unmapped variant / missing shipping addr; dry-run default
- fetch-printful-catalog.mjs (new): read-only catalog->variant_id mapper
- wear-templates.json: per-garment printful{} block (empty by design)
- server.js: Stripe shipping_address_collection on /wear-checkout + carry address into order+POD draft (still behind WEAR_SALES_LIVE coming-soon gate; no visitor reaches checkout)
- verified node --check clean, dry-run submits nothing, unmapped garment skipped
- go-live memo -> pending-approval (real flip stays Steve-gated: key+live Stripe+WEAR_SALES_LIVE)
---
data/wear-templates.json | 18 +++++-
scripts/fetch-printful-catalog.mjs | 57 +++++++++++++++++++
scripts/submit-pod-draft.mjs | 113 +++++++++++++++++++++++++++----------
server.js | 17 ++++--
4 files changed, 168 insertions(+), 37 deletions(-)
diff --git a/data/wear-templates.json b/data/wear-templates.json
index 55ae349..ae1053d 100644
--- a/data/wear-templates.json
+++ b/data/wear-templates.json
@@ -17,7 +17,14 @@
{ "id": "heather", "label": "Heather Gray", "hex": "#d8d5cd", "ink": "#1a1a1a" },
{ "id": "sand", "label": "Sand", "hex": "#e7ddca", "ink": "#1a1a1a" },
{ "id": "sky", "label": "Sky", "hex": "#dbe6ef", "ink": "#16324a" }
- ]
+ ],
+ "printful": {
+ "_note": "Printful mapping (fill after PRINTFUL_API_KEY lands — run scripts/fetch-printful-catalog.mjs). productId = Printful catalog product; variants maps our 'color/size' key -> Printful catalog variant_id (each color+size is a distinct Printful variant). placementType is the Printful print-file placement. Left EMPTY on purpose so scripts/submit-pod-draft.mjs FAILS LOUD (never submits an unmapped garment).",
+ "suggestedProduct": "Bella+Canvas 3001 Unisex Jersey Short-Sleeve Tee",
+ "productId": null,
+ "placementType": "front",
+ "variants": {}
+ }
},
{
"id": "polo",
@@ -36,7 +43,14 @@
{ "id": "heather", "label": "Heather Gray", "hex": "#d8d5cd", "ink": "#1a1a1a" },
{ "id": "sand", "label": "Sand", "hex": "#e7ddca", "ink": "#1a1a1a" },
{ "id": "sky", "label": "Sky", "hex": "#dbe6ef", "ink": "#16324a" }
- ]
+ ],
+ "printful": {
+ "_note": "Printful mapping (fill after PRINTFUL_API_KEY lands — run scripts/fetch-printful-catalog.mjs). productId = Printful catalog product; variants maps our 'color/size' key -> Printful catalog variant_id. Left EMPTY on purpose so scripts/submit-pod-draft.mjs FAILS LOUD (never submits an unmapped garment).",
+ "suggestedProduct": "Bella+Canvas 3001 Unisex Jersey Short-Sleeve Tee (or a polo equiv, e.g. Cotton Heritage / Adidas)",
+ "productId": null,
+ "placementType": "front",
+ "variants": {}
+ }
}
]
}
diff --git a/scripts/fetch-printful-catalog.mjs b/scripts/fetch-printful-catalog.mjs
new file mode 100644
index 0000000..04b487a
--- /dev/null
+++ b/scripts/fetch-printful-catalog.mjs
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+// READ-ONLY Printful catalog helper for /wear go-live wiring (TK-10286).
+//
+// Once PRINTFUL_API_KEY is set, this fetches a Printful catalog product's variants
+// and prints a suggested `variants` map ({ "color/size": variant_id }) to paste into
+// data/wear-templates.json for each garment. It ONLY issues GET requests to the
+// Printful catalog — it never creates, confirms, or touches an order. Safe to run
+// any time the key is present; a no-op (prints guidance) when it is not.
+//
+// Usage:
+// node scripts/fetch-printful-catalog.mjs # list candidate products
+// node scripts/fetch-printful-catalog.mjs <productId> # dump variants for a product
+import { fileURLToPath } from 'node:url';
+import { join } from 'node:path';
+import { readFileSync } from 'node:fs';
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url));
+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;
+}
+
+async function pf(key, path) {
+ const r = await fetch('https://api.printful.com' + path, { headers: { Authorization: `Bearer ${key}` } });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok) throw new Error(`Printful ${r.status}: ${j.error?.message || 'error'} on ${path}`);
+ return j.result;
+}
+
+async function main() {
+ const key = envVal('PRINTFUL_API_KEY');
+ if (!key) {
+ console.log('PRINTFUL_API_KEY not set — paste it via the secrets skill first.');
+ console.log('This script is read-only (GET /products only) and will list candidate garments once the key is present.');
+ return;
+ }
+ const productId = process.argv[2];
+ if (!productId) {
+ const products = await pf(key, '/products');
+ const tees = products.filter(p => /t-?shirt|tee|polo/i.test(p.type_name || p.title || ''));
+ console.log(`Catalog products matching tee/polo (${tees.length}):`);
+ for (const p of tees.slice(0, 40)) console.log(` id=${p.id} ${p.title} (${p.type_name})`);
+ console.log(`\nNext: node scripts/fetch-printful-catalog.mjs <id> to dump variants for the chosen product.`);
+ return;
+ }
+ const prod = await pf(key, `/products/${productId}`);
+ console.log(`Product ${productId}: ${prod.product?.title}`);
+ console.log(`Map our color/size -> Printful variant_id. Suggested variants block (edit color labels to match our ids):`);
+ const map = {};
+ for (const v of prod.variants || []) map[`${v.color}/${v.size}`] = v.id;
+ console.log(JSON.stringify({ productId: Number(productId), placementType: 'front', variants: map }, null, 2));
+ console.log(`\nOur color ids are white/heather/sand/sky and sizes S/M/L/XL/2XL — align the keys, then paste into the garment's "printful" block in data/wear-templates.json.`);
+}
+main().catch(e => { console.error(e.message); process.exit(1); });
diff --git a/scripts/submit-pod-draft.mjs b/scripts/submit-pod-draft.mjs
index 0c63c88..d685277 100644
--- a/scripts/submit-pod-draft.mjs
+++ b/scripts/submit-pod-draft.mjs
@@ -1,13 +1,18 @@
#!/usr/bin/env node
-// Steve-gated sender for POD (print-on-demand) order drafts.
+// Steve-gated PRINTFUL sender for POD (print-on-demand) order drafts.
//
-// The /wear checkout appends a DRAFT_UNSENT record per paid TEST order to
-// data/pod-order-drafts.jsonl. This script is the ONLY place a draft would ever
-// leave the building — and it refuses to unless BOTH:
-// --apply is passed, AND a POD API key is present (POD_API_KEY env / .env).
-// Without those it is a dry run: it prints exactly what WOULD be sent, so the
-// left-chest payload can be reviewed. No live POD integration is wired yet
-// (Steve's call, 2026-08-06: "template + mockup only, no live wiring").
+// 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 } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
@@ -16,40 +21,88 @@ 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 PRINTFUL_ORDERS_URL = 'https://api.printful.com/orders';
function envVal(name) {
if (process.env[name]) return process.env[name];
try { const m = readFileSync(join(ROOT, '.env'), 'utf8').match(new RegExp('^' + name + '=(.+)$', 'm')); if (m) return m[1].trim(); } catch {}
+ // also read the master secrets .env if present
+ try { const p = join(ROOT, '..', 'secrets-manager', '.env'); const m = readFileSync(p, 'utf8').match(new RegExp('^' + name + '=(.+)$', 'm')); if (m) return m[1].trim(); } catch {}
return null;
}
+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 drafts = lines.map(l => JSON.parse(l)).filter(d => d.status === 'DRAFT_UNSENT');
- console.log(`POD drafts pending: ${drafts.length}`);
- for (const d of drafts){
- // The shape a Printful/Printify order will take (documented target payload).
- const podPayload = {
- recipient: { email: d.recipient_email },
- items: [{ garment: d.garment, color: d.color, size: d.size,
- placement: d.placement, // 'left_chest'
- design_url: d.design_image_url }],
- note: `Celebrity signature: ${d.signature_name}`,
- };
+ 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}) —`);
- console.log(JSON.stringify(podPayload, null, 2));
+ let payload;
+ try { payload = buildPayload(tpl, d); }
+ catch (e) { console.log(` SKIP: ${e.message}`); continue; }
+ console.log(JSON.stringify(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; }
+ console.log(` ✓ Printful DRAFT order created: id=${j.result?.id} status=${j.result?.status} (confirm it in the Printful dashboard to fulfill).`);
}
- const key = envVal('POD_API_KEY');
- if (!APPLY || !key){
- console.log(`\nDRY RUN — nothing submitted.`);
- console.log(` --apply passed: ${APPLY} | POD_API_KEY present: ${!!key}`);
- console.log(` To actually submit you need Steve's go, a POD account + POD_API_KEY, and the`);
- console.log(` provider call wired below. This is intentionally a stub.`);
- return;
+
+ 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.`);
}
- // GATED: real submission is deliberately not implemented. Reaching here needs a
- // wired provider client; fail loud rather than silently no-op.
- throw new Error('Live POD submission is not wired yet — add the provider client here (Steve-gated).');
}
-main().catch(e=>{ console.error(e.message); process.exit(1); });
+main().catch(e => { console.error(e.message); process.exit(1); });
diff --git a/server.js b/server.js
index cb17d18..fefb588 100644
--- a/server.js
+++ b/server.js
@@ -814,6 +814,8 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
list.push(order); await store('wear-orders.json', list);
const params = new URLSearchParams();
params.set('mode', 'payment');
+ // Physical POD product => collect a shipping address (Printful requires one).
+ params.set('shipping_address_collection[allowed_countries][0]', 'US');
params.set('success_url', 'https://celebsignatures.com/wear-success?sid={CHECKOUT_SESSION_ID}');
params.set('cancel_url', 'https://celebsignatures.com/wear');
params.set('customer_email', email);
@@ -848,16 +850,21 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
order = list.find(o => String(o.id) === String(s.metadata?.order_id || s.client_reference_id));
if (order && order.status !== 'paid') {
order.status = 'paid'; order.paidAt = new Date().toISOString();
+ // Capture the shipping address Stripe collected (Printful needs it).
+ const sd = s.shipping_details || s.customer_details || {};
+ const ad = sd.address || {};
+ order.recipient = { name: sd.name || null, address1: ad.line1 || null, address2: ad.line2 || null,
+ city: ad.city || null, state_code: ad.state || null, country_code: ad.country || null, zip: ad.postal_code || null };
// DRAFT the POD order — do NOT submit. This is the exact payload a
- // future Printful/Printify call will send; scripts/submit-pod-draft.mjs
- // is the Steve-gated sender.
+ // future Printful call will send; scripts/submit-pod-draft.mjs
+ // is the Steve-gated sender (Printful, confirm=false drafts).
if (!order.pod_submitted) {
const sig = (await mergedSignatures()).find(x => qidOf(x) === order.qid);
- const draft = { draftedAt: new Date().toISOString(), orderId: order.id, status: 'DRAFT_UNSENT',
- recipient_email: order.email, garment: order.garment, color: order.color, size: order.size,
+ const draft = { draftedAt: new Date().toISOString(), orderId: order.id, status: 'DRAFT_UNSENT', provider: 'printful',
+ recipient_email: order.email, recipient: order.recipient, garment: order.garment, color: order.color, size: order.size,
placement: order.placement, signature_name: order.signature_name,
design_image_url: sig ? sig.signature_image_url : null,
- note: 'NOT SENT — awaiting Steve approval + POD API key (scripts/submit-pod-draft.mjs --apply)' };
+ note: 'NOT SENT — awaiting Steve approval + PRINTFUL_API_KEY + WEAR_SALES_LIVE=1 (scripts/submit-pod-draft.mjs --apply)' };
await appendFile(join(DATA, 'pod-order-drafts.jsonl'), JSON.stringify(draft) + '\n');
order.pod_submitted = true;
}
← 5ed1ce7 wear v1 soft-launch: browsable catalog live, sales gated beh
·
back to CelebritySignatures
·
chore: lint, refactor, session-close fixes, v1.0.2 → v1.0.3 0770f00 →