rspace-online/modules/rswag/pod/printful.ts

300 lines
7.8 KiB
TypeScript

/**
* Printful v2 API client.
*
* Handles catalog lookup, mockup generation, and order submission.
* API v2 docs: https://developers.printful.com/docs/v2-beta/
* Rate limit: 120 req/60s (leaky bucket), lower for mockups.
*/
import type { PodOrder, PodRecipient, PodMockup, PodVariant } from "./types";
const BASE_URL = "https://api.printful.com/v2";
/** In-memory cache for catalog variants: product_id -> { variants, ts } */
const variantCache = new Map<number, { variants: PodVariant[]; ts: number }>();
const VARIANT_CACHE_TTL = 86_400_000; // 24 hours in ms
export class PrintfulClient {
private apiToken: string;
private storeId: string;
private sandbox: boolean;
readonly enabled: boolean;
constructor() {
this.apiToken = process.env.PRINTFUL_API_TOKEN || "";
this.storeId = process.env.PRINTFUL_STORE_ID || "";
this.sandbox = process.env.POD_SANDBOX_MODE !== "false";
this.enabled = !!this.apiToken;
}
private get headers(): Record<string, string> {
const h: Record<string, string> = {
Authorization: `Bearer ${this.apiToken}`,
"Content-Type": "application/json",
};
if (this.storeId) h["X-PF-Store-Id"] = this.storeId;
return h;
}
// ── Catalog ──
async getCatalogVariants(productId: number): Promise<PodVariant[]> {
const cached = variantCache.get(productId);
if (cached && Date.now() - cached.ts < VARIANT_CACHE_TTL) {
return cached.variants;
}
const resp = await fetch(
`${BASE_URL}/catalog-products/${productId}/catalog-variants`,
{ headers: this.headers },
);
if (!resp.ok) throw new Error(`Printful catalog error: ${resp.status}`);
const data = (await resp.json()) as { data?: any[] };
const raw = data.data || [];
const variants: PodVariant[] = raw.map((v: any) => ({
id: v.id,
size: v.size || "",
color: v.color || "",
colorCode: v.color_code || "",
}));
variantCache.set(productId, { variants, ts: Date.now() });
return variants;
}
/**
* Resolve (product_id, size, color) -> Printful catalog_variant_id.
* Our metadata uses SKU "71" + variants ["S","M","L",...].
* Printful orders require numeric catalog_variant_id.
*/
async resolveVariantId(
productId: number,
size: string,
color = "Black",
): Promise<number | null> {
const variants = await this.getCatalogVariants(productId);
// Exact match on size + color
for (const v of variants) {
if (
v.size.toUpperCase() === size.toUpperCase() &&
v.color.toLowerCase().includes(color.toLowerCase())
) {
return v.id;
}
}
// Fallback: match size only
for (const v of variants) {
if (v.size.toUpperCase() === size.toUpperCase()) {
return v.id;
}
}
return null;
}
// ── Mockup Generation ──
/**
* Start async mockup generation task (v2 format).
* Returns task_id to poll with getMockupTask().
*/
async createMockupTask(
productId: number,
variantIds: number[],
imageUrl: string,
placement = "front",
technique = "dtg",
): Promise<string> {
const payload = {
products: [
{
source: "catalog",
catalog_product_id: productId,
catalog_variant_ids: variantIds,
placements: [
{
placement,
technique,
layers: [{ type: "file", url: imageUrl }],
},
],
},
],
format: "png",
};
const resp = await fetch(`${BASE_URL}/mockup-tasks`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(`Printful mockup task error: ${resp.status}`);
const result = (await resp.json()) as { data?: any };
const rawData = result.data;
const data = Array.isArray(rawData) && rawData.length ? rawData[0] : rawData;
const taskId = data?.id || data?.task_key || data?.task_id;
return String(taskId);
}
/** Poll mockup task status. */
async getMockupTask(taskId: string): Promise<{
status: string;
mockups?: PodMockup[];
failureReasons?: string[];
}> {
const resp = await fetch(
`${BASE_URL}/mockup-tasks?id=${encodeURIComponent(taskId)}`,
{ headers: this.headers },
);
if (!resp.ok) throw new Error(`Printful mockup poll error: ${resp.status}`);
const result = (await resp.json()) as { data?: any };
const raw = result.data;
const data = Array.isArray(raw) && raw.length ? raw[0] : (typeof raw === "object" ? raw : {});
const mockups: PodMockup[] = (
data.mockups || data.catalog_variant_mockups || []
).map((m: any) => ({
variantId: m.catalog_variant_id || m.variant_id,
mockupUrl: m.mockup_url || m.url || "",
placement: m.placement || "front",
}));
return {
status: data.status || "",
mockups,
failureReasons: data.failure_reasons,
};
}
/**
* Create mockup task and poll until complete.
* Returns mockup list or null on failure/timeout.
*/
async generateMockupAndWait(
productId: number,
variantIds: number[],
imageUrl: string,
placement = "front",
technique = "dtg",
maxPolls = 20,
pollIntervalMs = 3000,
): Promise<PodMockup[] | null> {
const taskId = await this.createMockupTask(
productId, variantIds, imageUrl, placement, technique,
);
for (let i = 0; i < maxPolls; i++) {
await new Promise((r) => setTimeout(r, pollIntervalMs));
const result = await this.getMockupTask(taskId);
if (result.status === "completed") {
return result.mockups || [];
}
if (result.status === "failed") {
console.error(`Printful mockup task ${taskId} failed:`, result.failureReasons);
return null;
}
}
console.warn(`Printful mockup task ${taskId} timed out after ${maxPolls} polls`);
return null;
}
// ── Orders ──
async createOrder(
items: {
catalogVariantId: number;
quantity: number;
imageUrl: string;
placement?: string;
}[],
recipient: PodRecipient,
): Promise<PodOrder> {
if (!this.enabled) throw new Error("Printful API token not configured");
const orderItems = items.map((item) => ({
source: "catalog",
catalog_variant_id: item.catalogVariantId,
quantity: item.quantity,
placements: [
{
placement: item.placement || "front",
technique: "dtg",
layers: [{ type: "file", url: item.imageUrl }],
},
],
}));
const payload: any = {
recipient: {
name: recipient.name,
address1: recipient.address1,
...(recipient.address2 ? { address2: recipient.address2 } : {}),
city: recipient.city,
...(recipient.stateCode ? { state_code: recipient.stateCode } : {}),
country_code: recipient.countryCode,
zip: recipient.zip,
...(recipient.email ? { email: recipient.email } : {}),
},
items: orderItems,
};
if (this.sandbox) payload.draft = true;
const resp = await fetch(`${BASE_URL}/orders`, {
method: "POST",
headers: this.headers,
body: JSON.stringify(payload),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`Printful order error ${resp.status}: ${text.slice(0, 500)}`);
}
const result = (await resp.json()) as { data?: any };
const data = result.data || {};
return {
id: String(data.id),
provider: "printful",
status: data.status || "draft",
items: items.map((i) => ({
sku: String(i.catalogVariantId),
quantity: i.quantity,
imageUrl: i.imageUrl,
placement: i.placement || "front",
})),
recipient,
createdAt: new Date().toISOString(),
raw: data,
};
}
async getOrder(orderId: string): Promise<PodOrder> {
const resp = await fetch(`${BASE_URL}/orders/${orderId}`, {
headers: this.headers,
});
if (!resp.ok) throw new Error(`Printful get order error: ${resp.status}`);
const result = (await resp.json()) as { data?: any };
const data = result.data || {};
return {
id: String(data.id),
provider: "printful",
status: data.status || "unknown",
items: [],
recipient: { name: "", address1: "", city: "", countryCode: "", zip: "" },
createdAt: data.created_at || new Date().toISOString(),
raw: data,
};
}
}