Overhaul logging

This commit is contained in:
Dominic Ferrando
2026-06-23 22:30:56 -04:00
parent 449b08971f
commit d415685a81
6 changed files with 170 additions and 115 deletions
+1
View File
@@ -15,6 +15,7 @@
"@elysiajs/cors": "^1.4.2", "@elysiajs/cors": "^1.4.2",
"elysia": "^1.4.29", "elysia": "^1.4.29",
"ml-levenberg-marquardt": "^5.0.1", "ml-levenberg-marquardt": "^5.0.1",
"pino": "^10.3.1",
"zipcodes-us": "^1.1.3", "zipcodes-us": "^1.1.3",
"zod": "^4.4.3" "zod": "^4.4.3"
} }
+8 -1
View File
@@ -4,11 +4,18 @@ function requireEnv(key: string): string {
return val; return val;
} }
function optionEnv(key: string, fallback: string): string {
const val = Bun.env[key];
if (!val) return fallback;
return val;
}
export const env = { export const env = {
PRINTFUL_AUTH: requireEnv("PRINTFUL_AUTH"), PRINTFUL_AUTH: requireEnv("PRINTFUL_AUTH"),
PRINTFUL_STORE_ID: requireEnv("PRINTFUL_STORE_ID"), PRINTFUL_STORE_ID: requireEnv("PRINTFUL_STORE_ID"),
WEBFLOW_SITE_ID: requireEnv("WEBFLOW_SITE_ID"), WEBFLOW_SITE_ID: requireEnv("WEBFLOW_SITE_ID"),
WEBFLOW_COLLECTION_ID: requireEnv("WEBFLOW_COLLECTION_ID"), WEBFLOW_COLLECTION_ID: requireEnv("WEBFLOW_COLLECTION_ID"),
WEBFLOW_AUTH: requireEnv("WEBFLOW_AUTH"), WEBFLOW_AUTH: requireEnv("WEBFLOW_AUTH"),
WEBFLOW_WEBHOOK_SECRET: requireEnv("WEBFLOW_WEBHOOK_SECRET") WEBFLOW_WEBHOOK_SECRET: requireEnv("WEBFLOW_WEBHOOK_SECRET"),
LOG_LEVEL: optionEnv("LOG_LEVEL", "info")
}; };
+41 -34
View File
@@ -21,8 +21,11 @@ import {
} from "@blade-and-brawn/commerce"; } from "@blade-and-brawn/commerce";
import zipcodesUs from "zipcodes-us"; import zipcodesUs from "zipcodes-us";
import z from "zod"; import z from "zod";
import pino from "pino";
import { env } from "./env"; import { env } from "./env";
const log = pino({ level: env.LOG_LEVEL });
// SERVICES // SERVICES
// ----------- // -----------
const levelCalculator = new LevelCalculator( const levelCalculator = new LevelCalculator(
@@ -41,11 +44,15 @@ const webflow = new WebflowClient({
webhookSecret: env.WEBFLOW_WEBHOOK_SECRET, webhookSecret: env.WEBFLOW_WEBHOOK_SECRET,
}); });
const productSyncer = new ProductSyncer(printful, webflow); const productSyncer = new ProductSyncer(printful, webflow, log);
// ELYSIA // ELYSIA
// ----------- // -----------
export const app = new Elysia() export const app = new Elysia()
.derive(() => ({
startTime: Date.now(),
requestId: crypto.randomUUID(),
}))
.use( .use(
cors({ cors({
origin: [ origin: [
@@ -71,27 +78,29 @@ export const app = new Elysia()
WebflowError, WebflowError,
}) })
.onError(({ code, error }) => { .onError(({ code, error, startTime, requestId }) => {
const duration = Date.now() - (startTime ?? Date.now());
switch (code) { switch (code) {
case "PrintfulError": case "PrintfulError":
case "WebflowError": case "WebflowError":
console.error(error.message, error.status, error.payload); log.error(
{ requestId, duration, upstreamStatus: error.status, payload: error.payload },
error.message,
);
return status(502, { error: error.message }); return status(502, { error: error.message });
default: default:
console.error(error); log.error({ requestId, duration, err: error }, "unhandled error");
} }
}) })
.onAfterHandle(({ request, set }) => { .onAfterHandle(({ request, set, startTime, requestId }) => {
console.log( log.info({
JSON.stringify({ requestId,
lvl: "info", method: request.method,
msg: "req", path: new URL(request.url).pathname,
method: request.method, status: set.status ?? 200,
path: new URL(request.url).pathname, duration: Date.now() - startTime,
status: set.status ?? 200, }, "request");
}),
);
}) })
.get("/", () => ({ status: "ok" })) .get("/", () => ({ status: "ok" }))
@@ -148,18 +157,14 @@ export const app = new Elysia()
await productSyncer.sync(params.printfulProductId); await productSyncer.sync(params.printfulProductId);
}, { params: z.object({ printfulProductId: z.coerce.number() }) }), }, { params: z.object({ printfulProductId: z.coerce.number() }) }),
) )
// TODO: add schema validation
.get("/:printfulProductId", async ({ params }) => { .get("/:printfulProductId", async ({ params }) => {
const printfulProduct = const printfulProduct = await printful.Products.get(params.printfulProductId);
await printful.Products.get(params.printfulProductId);
if (!printfulProduct) throw new NotFoundError("Missing printful product"); if (!printfulProduct) throw new NotFoundError("Missing printful product");
const webflowProductId = const webflowProductId = printfulProduct.sync_product.external_id.split("-")[0];
printfulProduct.sync_product.external_id.split("-")[0];
if (!webflowProductId) throw new NotFoundError("Missing webflow product ID"); if (!webflowProductId) throw new NotFoundError("Missing webflow product ID");
const webflowProduct = const webflowProduct = await webflow.Products.get(webflowProductId);
await webflow.Products.get(webflowProductId);
return { printfulProduct, webflowProduct }; return { printfulProduct, webflowProduct };
}, { }, {
@@ -174,18 +179,19 @@ export const app = new Elysia()
switch (payload.type) { switch (payload.type) {
case Printful.Webhook.Event.ProductUpdated: { case Printful.Webhook.Event.ProductUpdated: {
const printfulProduct = payload.data.sync_product; const printfulProduct = payload.data.sync_product;
log.info({ productId: printfulProduct.id }, "printful webhook: product updated");
await productSyncer.sync(printfulProduct.id); await productSyncer.sync(printfulProduct.id);
break; break;
} }
case Printful.Webhook.Event.ProductDeleted: { case Printful.Webhook.Event.ProductDeleted: {
await webflow.Products.remove( log.info({ externalId: payload.data.sync_product.external_id }, "printful webhook: product deleted");
payload.data.sync_product.external_id, await webflow.Products.remove(payload.data.sync_product.external_id);
);
break; break;
} }
case Printful.Webhook.Event.PackageShipped: { case Printful.Webhook.Event.PackageShipped: {
const webflowOrderId = payload.data.order.external_id; const webflowOrderId = payload.data.order.external_id;
const shipInfo = payload.data.shipment; const shipInfo = payload.data.shipment;
log.info({ webflowOrderId, carrier: shipInfo.carrier, tracking: shipInfo.tracking_number }, "printful webhook: package shipped");
await webflow.Orders.update(webflowOrderId, { await webflow.Orders.update(webflowOrderId, {
shippingTrackingURL: shipInfo.tracking_url, shippingTrackingURL: shipInfo.tracking_url,
@@ -197,6 +203,8 @@ export const app = new Elysia()
}); });
break; break;
} }
default:
log.warn({ type: (payload as any).type }, "printful webhook: unhandled event type");
} }
}) })
.post("/webhook/webflow", async ({ request, body }) => { .post("/webhook/webflow", async ({ request, body }) => {
@@ -208,6 +216,7 @@ export const app = new Elysia()
switch (payload.triggerType) { switch (payload.triggerType) {
case Webflow.Webhook.Event.OrderCreated: { case Webflow.Webhook.Event.OrderCreated: {
const webflowOrder = payload.payload; const webflowOrder = payload.payload;
log.info({ orderId: webflowOrder.orderId }, "webflow webhook: order created");
await printful.Orders.create({ await printful.Orders.create({
external_id: webflowOrder.orderId, external_id: webflowOrder.orderId,
@@ -219,24 +228,22 @@ export const app = new Elysia()
address2: webflowOrder.shippingAddress.line2, address2: webflowOrder.shippingAddress.line2,
city: webflowOrder.shippingAddress.city, city: webflowOrder.shippingAddress.city,
state_code: zipcodesUs.find( state_code: zipcodesUs.find(
webflowOrder.shippingAddress.postalCode.split( webflowOrder.shippingAddress.postalCode.split("-")[0] ?? "",
"-",
)[0] ?? "",
).stateCode, ).stateCode,
country_code: webflowOrder.shippingAddress.country, country_code: webflowOrder.shippingAddress.country,
zip: webflowOrder.shippingAddress.postalCode, zip: webflowOrder.shippingAddress.postalCode,
}, },
items: webflowOrder.purchasedItems.map( items: webflowOrder.purchasedItems.map((webflowOrderSku) => ({
(webflowOrderSku) => ({ external_variant_id: webflowOrderSku.variantId,
external_variant_id: webflowOrderSku.variantId, quantity: webflowOrderSku.count,
quantity: webflowOrderSku.count, })),
}),
),
}); });
break; break;
} }
default:
log.warn({ triggerType: (payload as any).triggerType }, "webflow webhook: unhandled event type");
} }
}); });
app.listen(3000); app.listen(3000, () => log.info({ port: 3000 }, "server started"));
+32
View File
@@ -20,6 +20,7 @@
"@elysiajs/cors": "^1.4.2", "@elysiajs/cors": "^1.4.2",
"elysia": "^1.4.29", "elysia": "^1.4.29",
"ml-levenberg-marquardt": "^5.0.1", "ml-levenberg-marquardt": "^5.0.1",
"pino": "^10.3.1",
"zipcodes-us": "^1.1.3", "zipcodes-us": "^1.1.3",
"zod": "^4.4.3", "zod": "^4.4.3",
}, },
@@ -56,6 +57,9 @@
"packages/commerce": { "packages/commerce": {
"name": "@blade-and-brawn/commerce", "name": "@blade-and-brawn/commerce",
"version": "0.0.0", "version": "0.0.0",
"dependencies": {
"pino": "^10.3.1",
},
}, },
"packages/domain": { "packages/domain": {
"name": "@blade-and-brawn/domain", "name": "@blade-and-brawn/domain",
@@ -143,6 +147,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
@@ -257,6 +263,8 @@
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
@@ -363,26 +371,46 @@
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="],
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="], "set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
"svelte": ["svelte@5.56.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.11", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA=="], "svelte": ["svelte@5.56.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.11", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA=="],
@@ -395,6 +423,8 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="],
"tiny-glob": ["tiny-glob@0.2.9", "", { "dependencies": { "globalyzer": "0.1.0", "globrex": "^0.1.2" } }, "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg=="], "tiny-glob": ["tiny-glob@0.2.9", "", { "dependencies": { "globalyzer": "0.1.0", "globrex": "^0.1.2" } }, "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
@@ -432,5 +462,7 @@
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
} }
} }
+3
View File
@@ -5,5 +5,8 @@
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
},
"dependencies": {
"pino": "^10.3.1"
} }
} }
+85 -80
View File
@@ -3,12 +3,10 @@ import { formatSlug, type DeepPartial } from "./util/misc";
import { WebflowClient } from "./webflow"; import { WebflowClient } from "./webflow";
import { PrintfulClient } from "./printful"; import { PrintfulClient } from "./printful";
import { redis } from "bun"; import { redis } from "bun";
import pino from "pino";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const R2_WORKER_URL = "https://r2-worker.xominus.workers.dev";
export const WEBSITE_MEDIA_URL = "https://website-media.bladeandbrawn.com";
type MainProduct = { type MainProduct = {
name: string; name: string;
externalId: string; externalId: string;
@@ -25,144 +23,149 @@ type SyncState = {
}; };
export class ProductSyncer { export class ProductSyncer {
private log: pino.Logger;
constructor( constructor(
private printful: PrintfulClient, private printful: PrintfulClient,
private webflow: WebflowClient, private webflow: WebflowClient,
) { } log?: pino.Logger,
) {
this.log = (log ?? pino({ level: "silent" })).child({ component: "ProductSyncer" });
}
async sync(printfulProductId?: number) { async sync(printfulProductId?: number) {
const syncStart = Date.now();
try { try {
if (printfulProductId) this.log.info({ printfulProductId }, "sync started");
else this.log.info("sync started");
await this.setState({ isSyncing: true, syncingIds: [] }); await this.setState({ isSyncing: true, syncingIds: [] });
// Populate products this.log.debug("populating webflow products");
const mainPrintfulProducts: MainProduct[] = []; const webflowProducts: Webflow.Products.ProductAndSkus[] = await this.webflow.Products.getAll();
const webflowProducts: Webflow.Products.ProductAndSkus[] = [];
this.log.debug("populating printful products");
let printfulProducts = await this.printful.Products.getAll(); let printfulProducts = await this.printful.Products.getAll();
if (printfulProductId) { if (printfulProductId) {
const printfulProduct = printfulProducts.find( const printfulProduct = printfulProducts.find((p) => p.id === printfulProductId);
(p) => p.id === printfulProductId,
);
if (printfulProduct) { if (printfulProduct) {
const mainProductName = this.getMainProductName( const metaProductName = this.getMainProductName(
printfulProduct.name, printfulProduct.name,
); );
printfulProducts = printfulProducts.filter((p) => printfulProducts = printfulProducts.filter((p) =>
p.name.includes(mainProductName), p.name.includes(metaProductName),
); );
} }
} }
webflowProducts.push(...(await this.webflow.Products.getAll()));
// Generate main printful products this.log.info({ count: printfulProducts.length }, "generating meta printful products");
console.log("Generating main products..."); const metaPrintfulProducts: MainProduct[] = [];
for (const printfulProduct of printfulProducts) { for (const printfulProduct of printfulProducts) {
const mainProductName = this.getMainProductName( const mainProductName = this.getMainProductName(
printfulProduct.name, printfulProduct.name,
); );
let mainPrintfulProduct = mainPrintfulProducts.find((mp) => let metaPrintfulProduct = metaPrintfulProducts.find((mp) =>
mp.name.includes(mainProductName), mp.name.includes(mainProductName),
); );
if (!mainPrintfulProduct) { if (!metaPrintfulProduct) {
mainPrintfulProduct = { metaPrintfulProduct = {
name: mainProductName, name: mainProductName,
variants: [], variants: [],
externalId: printfulProduct.external_id, externalId: printfulProduct.external_id,
colors: new Set(), colors: new Set(),
}; };
mainPrintfulProducts.push(mainPrintfulProduct); metaPrintfulProducts.push(metaPrintfulProduct);
} }
const productColor = this.findColorInProductName( const productColor = this.findColorInProductName(
printfulProduct.name, printfulProduct.name,
); );
mainPrintfulProduct.variants.push({ metaPrintfulProduct.variants.push({
color: productColor, color: productColor,
product: (await this.printful.Products.get(printfulProduct.id))!, product: (await this.printful.Products.get(printfulProduct.id))!,
}); });
mainPrintfulProduct.colors.add(productColor); metaPrintfulProduct.colors.add(productColor);
} }
// TODO: Validate main printful products // TODO: Validate main printful products
// Sync the main printful products for (const metaPrintfulProduct of metaPrintfulProducts) {
console.log("Syncing main products..."); this.log.info({ name: metaPrintfulProduct.name }, "syncing meta printful product");
for (const mainPrintfulProduct of mainPrintfulProducts) {
await this.setState({ await this.setState({
isSyncing: true, isSyncing: true,
syncingIds: mainPrintfulProduct.variants.map( syncingIds: metaPrintfulProduct.variants.map(
(v) => v.product.sync_product.id, (v) => v.product.sync_product.id,
), ),
}); });
const foundColors = mainPrintfulProduct.colors; const foundColors = metaPrintfulProduct.colors;
const foundSizes: Set<string> = new Set(); const foundSizes: Set<string> = new Set();
const webflowSkus: DeepPartial<Webflow.Products.Skus.Sku>[] = []; const webflowSkus: DeepPartial<Webflow.Products.Skus.Sku>[] = [];
for (const specialVariant of mainPrintfulProduct.variants) { for (const specialVariant of metaPrintfulProduct.variants) {
for (const printfulVariant of specialVariant.product.sync_variants) { for (const printfulVariant of specialVariant.product.sync_variants) {
// check if this variant size is in all other special variants this.log.debug(
{ size: printfulVariant.size, color: printfulVariant.color },
"generating webflow SKU from printful variant"
);
// check if this variant size is in all other special variants, if its not then skip it
let sizeIsInAllVariants = true; let sizeIsInAllVariants = true;
for (const specialVariant of mainPrintfulProduct.variants) { for (const metaVariant of metaPrintfulProduct.variants) {
const sizes = specialVariant.product.sync_variants.map( const sizes = metaVariant.product.sync_variants.map((v) => v.size);
(v) => v.size,
);
if (!sizes.includes(printfulVariant.size)) if (!sizes.includes(printfulVariant.size))
sizeIsInAllVariants = false; sizeIsInAllVariants = false;
} }
if (!sizeIsInAllVariants) continue;
if (sizeIsInAllVariants) { foundSizes.add(printfulVariant.size);
foundSizes.add(printfulVariant.size);
webflowSkus.push({ webflowSkus.push({
id: printfulVariant.external_id, id: printfulVariant.external_id,
fieldData: { fieldData: {
name: printfulVariant.name, name: printfulVariant.name,
slug: formatSlug(printfulVariant.name), slug: formatSlug(printfulVariant.name),
"sku-values": { "sku-values": {
color: specialVariant.color, color: specialVariant.color,
size: printfulVariant.size, size: printfulVariant.size,
},
price: {
value: Math.floor(
+printfulVariant.retail_price * 100,
),
unit: printfulVariant.currency,
currency: printfulVariant.currency,
},
"main-image": PrintfulClient.Util.getVariantMainImage(
printfulVariant,
),
}, },
}); price: {
} value: Math.floor(
+printfulVariant.retail_price * 100,
),
unit: printfulVariant.currency,
currency: printfulVariant.currency,
},
"main-image": PrintfulClient.Util.getVariantMainImage(
printfulVariant,
),
},
});
} }
} }
// SYNC PRODUCT DATA // SYNC PRODUCT DATA
let existingWebflowProduct = webflowProducts.find( let existingWebflowProduct = webflowProducts.find(
(webflowProduct) => (webflowProduct) => webflowProduct.product.id === metaPrintfulProduct.externalId.split("-")[0],
webflowProduct.product.id ===
mainPrintfulProduct.externalId.split("-")[0],
); );
if (existingWebflowProduct) { if (existingWebflowProduct) {
// SYNC PRODUCT UPDATE // SYNC PRODUCT UPDATE
this.log.info("existing webflow product found, updating it");
// do not update images // do not update images
for (const webflowSku of webflowSkus) { for (const webflowSku of webflowSkus)
delete webflowSku.fieldData?.["main-image"]; delete webflowSku.fieldData?.["main-image"];
}
const webflowProductId = const webflowProductId = metaPrintfulProduct.externalId.split("-")[0];
mainPrintfulProduct.externalId.split("-")[0]; if (!webflowProductId) throw new Error("Malformed printful product ID");
if (!webflowProductId)
throw new Error("Malformed printful product ID");
await this.webflow.Products.update(webflowProductId, { await this.webflow.Products.update(webflowProductId, {
product: { product: {
fieldData: { fieldData: {
name: mainPrintfulProduct.name, name: metaPrintfulProduct.name,
slug: formatSlug(mainPrintfulProduct.name), slug: formatSlug(metaPrintfulProduct.name),
shippable: true, shippable: true,
"tax-category": "standard-taxable", "tax-category": "standard-taxable",
"sku-properties": [ "sku-properties": [
@@ -209,11 +212,13 @@ export class ProductSyncer {
} }
} else { } else {
// SYNC PRODUCT CREATE // SYNC PRODUCT CREATE
this.log.info("existing webflow product not found, creating it");
const webflowProductId = await this.webflow.Products.create({ const webflowProductId = await this.webflow.Products.create({
product: { product: {
fieldData: { fieldData: {
name: mainPrintfulProduct.name, name: metaPrintfulProduct.name,
slug: formatSlug(mainPrintfulProduct.name), slug: formatSlug(metaPrintfulProduct.name),
shippable: true, shippable: true,
"tax-category": "standard-taxable", "tax-category": "standard-taxable",
"sku-properties": [ "sku-properties": [
@@ -249,12 +254,11 @@ export class ProductSyncer {
existingWebflowProduct = await this.webflow.Products.get(webflowProductId); existingWebflowProduct = await this.webflow.Products.get(webflowProductId);
if (!existingWebflowProduct) if (!existingWebflowProduct)
throw new Error("Webflow product missing after create"); throw new Error("webflow product missing after create");
for (const specialVariant of mainPrintfulProduct.variants) { for (const metaVariant of metaPrintfulProduct.variants) {
const newPrintfulVariants: DeepPartial<Printful.Products.SyncVariant>[] = const newPrintfulVariants: DeepPartial<Printful.Products.SyncVariant>[] = [];
[]; for (const printfulVariant of metaVariant.product.sync_variants) {
for (const printfulVariant of specialVariant.product.sync_variants) {
const associatedWebflowSku = existingWebflowProduct.skus.find( const associatedWebflowSku = existingWebflowProduct.skus.find(
(sku) => (sku) =>
sku.fieldData["sku-values"]?.["color"] === printfulVariant.color && sku.fieldData["sku-values"]?.["color"] === printfulVariant.color &&
@@ -269,12 +273,13 @@ export class ProductSyncer {
} }
await sleep(10000); await sleep(10000);
this.log.info({ printfulProduct: metaVariant.product.sync_product.id }, "updating printful product");
await this.printful.Products.update( await this.printful.Products.update(
specialVariant.product.sync_product.id, metaVariant.product.sync_product.id,
{ {
sync_product: { sync_product: {
id: specialVariant.product.sync_product.id, id: metaVariant.product.sync_product.id,
external_id: `${webflowProductId}-${specialVariant.color}`, external_id: `${webflowProductId}-${metaVariant.color}`,
}, },
sync_variants: newPrintfulVariants, sync_variants: newPrintfulVariants,
}, },
@@ -282,13 +287,13 @@ export class ProductSyncer {
} }
} }
} }
console.log("Done");
} this.log.info({ durationMs: Date.now() - syncStart }, "sync complete");
finally { } finally {
try { try {
await this.setState({ isSyncing: false, syncingIds: [] }); await this.setState({ isSyncing: false, syncingIds: [] });
} catch { } catch {
console.error("Failed to reset sync state"); this.log.error("failed to reset sync state");
} }
} }
} }