181 lines
7.9 KiB
TypeScript
181 lines
7.9 KiB
TypeScript
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: <ServiceName>})
|
|
|
|
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<string>`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 ?? []
|
|
};
|
|
}
|
|
}
|