import { Printful, PrintfulClient, ProductSyncer, Webflow, WebflowClient } from "@blade-and-brawn/commerce"; import { env, log } from "../util"; import { db } from "../database/db"; import { defineEventType, EventQueue } from "./event-queue"; import { Type as t } from "@sinclair/typebox"; import { Value } from "@sinclair/typebox/value"; import { sql } from "kysely"; import zipcodesUs from "zipcodes-us"; import { secToMs } from "@blade-and-brawn/domain"; import { EventsService } from "./events"; export class CommerceService { readonly Printful = new PrintfulClient({ token: env.PRINTFUL_AUTH, storeId: env.PRINTFUL_STORE_ID, }); readonly Webflow = new WebflowClient({ siteId: env.WEBFLOW_SITE_ID, collectionsId: env.WEBFLOW_COLLECTION_ID, token: env.WEBFLOW_AUTH, webhookSecret: env.WEBFLOW_WEBHOOK_SECRET, }); readonly Apparel = new ApparelService(this.Printful, this.Webflow); } class ApparelService { Syncs: ApparelSyncService; Orders: ApparelOrdersService; constructor(private readonly Printful: PrintfulClient, private readonly Webflow: WebflowClient) { this.Syncs = new ApparelSyncService(this.Printful, this.Webflow); this.Orders = new ApparelOrdersService(this.Printful, this.Webflow); } } class ApparelOrdersService { readonly Queue; constructor(private readonly Printful: PrintfulClient, private readonly Webflow: WebflowClient) { this.Queue = new EventQueue({ group: "apparel_order", cfg: { error: { limit: 3, initialRetryDelayMs: secToMs(1) }, concurrency: { limit: 1 } }, types: { "apparel_order_create": defineEventType({ schemas: { payload: t.Object({ wOrder: t.Object({}) }), state: t.Null() }, dedupeKey: (p) => (p.wOrder as Webflow.Orders.Order).orderId, processor: async (payload) => { const wOrder = payload.wOrder as Webflow.Orders.Order; // creates a DRAFT order await this.Printful.Orders.create({ external_id: wOrder.orderId, // TODO: derive from webflow shipping: "STANDARD", recipient: { name: wOrder.shippingAddress.addressee, address1: wOrder.shippingAddress.line1, address2: wOrder.shippingAddress.line2, city: wOrder.shippingAddress.city, state_code: zipcodesUs.find( wOrder.shippingAddress.postalCode.split("-")[0] ?? "", ).stateCode, country_code: wOrder.shippingAddress.country, zip: wOrder.shippingAddress.postalCode, }, items: wOrder.purchasedItems.map((wOrderSku) => ({ external_variant_id: wOrderSku.variantId, quantity: wOrderSku.count, })), }); // TODO: confirm the above created order // right now we manually review and confirm it in the printful portal } }), "apparel_order_fulfill": defineEventType({ schemas: { payload: t.Object({ wOrderId: t.String(), shipment: t.Object({}) }), state: t.Null() }, dedupeKey: (p) => `${p.wOrderId}:${(p.shipment as Printful.Webhook.PackageShipped["data"]["shipment"]).tracking_number}`, processor: async (payload) => { const wOrderId = payload.wOrderId; const shipment = payload.shipment as Printful.Webhook.PackageShipped["data"]["shipment"]; log.info({ wOrderId, carrier: shipment.carrier, tracking: shipment.tracking_number }, "printful webhook: package shipped"); await this.Webflow.Orders.update(wOrderId, { shippingTrackingURL: shipment.tracking_url, shippingTracking: shipment.tracking_number, shippingProvider: shipment.carrier, }); await this.Webflow.Orders.fulfill(wOrderId, { sendOrderFulfilledEmail: true, }); } }) } }); } } class ApparelSyncService { readonly Queue; private readonly ProductSyncer: ProductSyncer; // TODO: make each service have their own logger, so we can enforce .child({component: }) constructor(private readonly Printful: PrintfulClient, private readonly Webflow: WebflowClient) { this.ProductSyncer = new ProductSyncer(this.Printful, this.Webflow, log); this.Queue = new EventQueue({ group: "apparel_sync", cfg: { error: { limit: 3, initialRetryDelayMs: secToMs(1) }, concurrency: { limit: 1 } }, types: { "apparel_sync_update": defineEventType({ schemas: { payload: t.Object({ session: t.Object({ name: t.String(), id: t.String() }), filter: t.Optional( t.Object({ pProductIds: t.Optional(t.Array(t.Number())) }) ) }), state: t.Union([ t.Object({ pProductIds: t.Optional(t.Array(t.Number())) }), t.Null() ]) }, dedupeKey: (p) => (p.filter?.pProductIds ?? []).join(","), processor: async (payload, setState) => { await this.ProductSyncer.syncApparel({ filter: payload.filter, beforeStep: async (pProductIds: number[]) => { await setState({ pProductIds }); } }); } }), "apparel_sync_delete": defineEventType({ schemas: { payload: t.Object({ wProductId: t.String() }), state: t.Union([t.Object({}), t.Null()]) }, dedupeKey: (p) => p.wProductId, processor: async (payload) => { await this.Webflow.Products.remove(payload.wProductId); } }) } }); } async getLatestSyncState(sessionId: string) { const latestSync = await db.selectFrom("events") .select(["started_at", "available_at", "ended_at", "has_failed", "state"]) .where("group", "=", this.Queue.group) .where("type", "=", "apparel_sync_update") .where(sql`payload->'session'->>'id'`, "=", sessionId) .orderBy(sql`(started_at is not null and ended_at is null)`, "desc") .orderBy("created_at", "desc") .limit(1) .executeTakeFirst(); if (!latestSync) return; Value.Assert(this.Queue.types["apparel_sync_update"].schemas.state, latestSync.state); return { startDate: latestSync.started_at, status: EventsService.status(latestSync), syncingPProductIds: latestSync.state?.pProductIds ?? [] }; } }