diff --git a/apps/api/package.json b/apps/api/package.json index a6e13dc..7164ca6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,7 @@ "@elysiajs/cors": "^1.4.2", "elysia": "^1.4.29", "ml-levenberg-marquardt": "^5.0.1", + "pino": "^10.3.1", "zipcodes-us": "^1.1.3", "zod": "^4.4.3" } diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 4e515f2..4a74596 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -4,11 +4,18 @@ function requireEnv(key: string): string { return val; } +function optionEnv(key: string, fallback: string): string { + const val = Bun.env[key]; + if (!val) return fallback; + return val; +} + export const env = { PRINTFUL_AUTH: requireEnv("PRINTFUL_AUTH"), PRINTFUL_STORE_ID: requireEnv("PRINTFUL_STORE_ID"), WEBFLOW_SITE_ID: requireEnv("WEBFLOW_SITE_ID"), WEBFLOW_COLLECTION_ID: requireEnv("WEBFLOW_COLLECTION_ID"), 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") }; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index d5673cb..87ba561 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -21,8 +21,11 @@ import { } from "@blade-and-brawn/commerce"; import zipcodesUs from "zipcodes-us"; import z from "zod"; +import pino from "pino"; import { env } from "./env"; +const log = pino({ level: env.LOG_LEVEL }); + // SERVICES // ----------- const levelCalculator = new LevelCalculator( @@ -41,11 +44,15 @@ const webflow = new WebflowClient({ webhookSecret: env.WEBFLOW_WEBHOOK_SECRET, }); -const productSyncer = new ProductSyncer(printful, webflow); +const productSyncer = new ProductSyncer(printful, webflow, log); // ELYSIA // ----------- export const app = new Elysia() + .derive(() => ({ + startTime: Date.now(), + requestId: crypto.randomUUID(), + })) .use( cors({ origin: [ @@ -71,27 +78,29 @@ export const app = new Elysia() WebflowError, }) - .onError(({ code, error }) => { + .onError(({ code, error, startTime, requestId }) => { + const duration = Date.now() - (startTime ?? Date.now()); switch (code) { case "PrintfulError": 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 }); default: - console.error(error); + log.error({ requestId, duration, err: error }, "unhandled error"); } }) - .onAfterHandle(({ request, set }) => { - console.log( - JSON.stringify({ - lvl: "info", - msg: "req", - method: request.method, - path: new URL(request.url).pathname, - status: set.status ?? 200, - }), - ); + .onAfterHandle(({ request, set, startTime, requestId }) => { + log.info({ + requestId, + method: request.method, + path: new URL(request.url).pathname, + status: set.status ?? 200, + duration: Date.now() - startTime, + }, "request"); }) .get("/", () => ({ status: "ok" })) @@ -148,18 +157,14 @@ export const app = new Elysia() await productSyncer.sync(params.printfulProductId); }, { params: z.object({ printfulProductId: z.coerce.number() }) }), ) - // TODO: add schema validation .get("/:printfulProductId", async ({ params }) => { - const printfulProduct = - await printful.Products.get(params.printfulProductId); + const printfulProduct = await printful.Products.get(params.printfulProductId); if (!printfulProduct) throw new NotFoundError("Missing printful product"); - const webflowProductId = - printfulProduct.sync_product.external_id.split("-")[0]; + const webflowProductId = printfulProduct.sync_product.external_id.split("-")[0]; if (!webflowProductId) throw new NotFoundError("Missing webflow product ID"); - const webflowProduct = - await webflow.Products.get(webflowProductId); + const webflowProduct = await webflow.Products.get(webflowProductId); return { printfulProduct, webflowProduct }; }, { @@ -174,18 +179,19 @@ export const app = new Elysia() switch (payload.type) { case Printful.Webhook.Event.ProductUpdated: { const printfulProduct = payload.data.sync_product; + log.info({ productId: printfulProduct.id }, "printful webhook: product updated"); await productSyncer.sync(printfulProduct.id); break; } case Printful.Webhook.Event.ProductDeleted: { - await webflow.Products.remove( - payload.data.sync_product.external_id, - ); + log.info({ externalId: payload.data.sync_product.external_id }, "printful webhook: product deleted"); + await webflow.Products.remove(payload.data.sync_product.external_id); break; } case Printful.Webhook.Event.PackageShipped: { const webflowOrderId = payload.data.order.external_id; const shipInfo = payload.data.shipment; + log.info({ webflowOrderId, carrier: shipInfo.carrier, tracking: shipInfo.tracking_number }, "printful webhook: package shipped"); await webflow.Orders.update(webflowOrderId, { shippingTrackingURL: shipInfo.tracking_url, @@ -197,6 +203,8 @@ export const app = new Elysia() }); break; } + default: + log.warn({ type: (payload as any).type }, "printful webhook: unhandled event type"); } }) .post("/webhook/webflow", async ({ request, body }) => { @@ -208,6 +216,7 @@ export const app = new Elysia() switch (payload.triggerType) { case Webflow.Webhook.Event.OrderCreated: { const webflowOrder = payload.payload; + log.info({ orderId: webflowOrder.orderId }, "webflow webhook: order created"); await printful.Orders.create({ external_id: webflowOrder.orderId, @@ -219,24 +228,22 @@ export const app = new Elysia() address2: webflowOrder.shippingAddress.line2, city: webflowOrder.shippingAddress.city, state_code: zipcodesUs.find( - webflowOrder.shippingAddress.postalCode.split( - "-", - )[0] ?? "", + webflowOrder.shippingAddress.postalCode.split("-")[0] ?? "", ).stateCode, country_code: webflowOrder.shippingAddress.country, zip: webflowOrder.shippingAddress.postalCode, }, - items: webflowOrder.purchasedItems.map( - (webflowOrderSku) => ({ - external_variant_id: webflowOrderSku.variantId, - quantity: webflowOrderSku.count, - }), - ), + items: webflowOrder.purchasedItems.map((webflowOrderSku) => ({ + external_variant_id: webflowOrderSku.variantId, + quantity: webflowOrderSku.count, + })), }); 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")); diff --git a/bun.lock b/bun.lock index ae84c73..6564805 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "@elysiajs/cors": "^1.4.2", "elysia": "^1.4.29", "ml-levenberg-marquardt": "^5.0.1", + "pino": "^10.3.1", "zipcodes-us": "^1.1.3", "zod": "^4.4.3", }, @@ -56,6 +57,9 @@ "packages/commerce": { "name": "@blade-and-brawn/commerce", "version": "0.0.0", + "dependencies": { + "pino": "^10.3.1", + }, }, "packages/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=="], + "@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=="], "@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=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "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=="], + "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=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "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=="], + "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=="], + "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=="], "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=="], "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=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "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=="], @@ -395,6 +423,8 @@ "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=="], "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/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=="], } } diff --git a/packages/commerce/package.json b/packages/commerce/package.json index 95f3c72..d2248d6 100644 --- a/packages/commerce/package.json +++ b/packages/commerce/package.json @@ -5,5 +5,8 @@ "type": "module", "exports": { ".": "./src/index.ts" + }, + "dependencies": { + "pino": "^10.3.1" } } diff --git a/packages/commerce/src/sync.ts b/packages/commerce/src/sync.ts index 7c6293b..1377ddc 100644 --- a/packages/commerce/src/sync.ts +++ b/packages/commerce/src/sync.ts @@ -3,12 +3,10 @@ import { formatSlug, type DeepPartial } from "./util/misc"; import { WebflowClient } from "./webflow"; import { PrintfulClient } from "./printful"; import { redis } from "bun"; +import pino from "pino"; 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 = { name: string; externalId: string; @@ -25,144 +23,149 @@ type SyncState = { }; export class ProductSyncer { + private log: pino.Logger; + constructor( private printful: PrintfulClient, private webflow: WebflowClient, - ) { } + log?: pino.Logger, + ) { + this.log = (log ?? pino({ level: "silent" })).child({ component: "ProductSyncer" }); + } async sync(printfulProductId?: number) { + const syncStart = Date.now(); try { + if (printfulProductId) this.log.info({ printfulProductId }, "sync started"); + else this.log.info("sync started"); + await this.setState({ isSyncing: true, syncingIds: [] }); - // Populate products - const mainPrintfulProducts: MainProduct[] = []; - const webflowProducts: Webflow.Products.ProductAndSkus[] = []; + this.log.debug("populating webflow products"); + const webflowProducts: Webflow.Products.ProductAndSkus[] = await this.webflow.Products.getAll(); + + this.log.debug("populating printful products"); let printfulProducts = await this.printful.Products.getAll(); if (printfulProductId) { - const printfulProduct = printfulProducts.find( - (p) => p.id === printfulProductId, - ); + const printfulProduct = printfulProducts.find((p) => p.id === printfulProductId); if (printfulProduct) { - const mainProductName = this.getMainProductName( + const metaProductName = this.getMainProductName( printfulProduct.name, ); printfulProducts = printfulProducts.filter((p) => - p.name.includes(mainProductName), + p.name.includes(metaProductName), ); } } - webflowProducts.push(...(await this.webflow.Products.getAll())); - // Generate main printful products - console.log("Generating main products..."); + this.log.info({ count: printfulProducts.length }, "generating meta printful products"); + const metaPrintfulProducts: MainProduct[] = []; for (const printfulProduct of printfulProducts) { const mainProductName = this.getMainProductName( printfulProduct.name, ); - let mainPrintfulProduct = mainPrintfulProducts.find((mp) => + let metaPrintfulProduct = metaPrintfulProducts.find((mp) => mp.name.includes(mainProductName), ); - if (!mainPrintfulProduct) { - mainPrintfulProduct = { + if (!metaPrintfulProduct) { + metaPrintfulProduct = { name: mainProductName, variants: [], externalId: printfulProduct.external_id, colors: new Set(), }; - mainPrintfulProducts.push(mainPrintfulProduct); + metaPrintfulProducts.push(metaPrintfulProduct); } const productColor = this.findColorInProductName( printfulProduct.name, ); - mainPrintfulProduct.variants.push({ + metaPrintfulProduct.variants.push({ color: productColor, product: (await this.printful.Products.get(printfulProduct.id))!, }); - mainPrintfulProduct.colors.add(productColor); + metaPrintfulProduct.colors.add(productColor); } // TODO: Validate main printful products - // Sync the main printful products - console.log("Syncing main products..."); - for (const mainPrintfulProduct of mainPrintfulProducts) { + for (const metaPrintfulProduct of metaPrintfulProducts) { + this.log.info({ name: metaPrintfulProduct.name }, "syncing meta printful product"); await this.setState({ isSyncing: true, - syncingIds: mainPrintfulProduct.variants.map( + syncingIds: metaPrintfulProduct.variants.map( (v) => v.product.sync_product.id, ), }); - const foundColors = mainPrintfulProduct.colors; + const foundColors = metaPrintfulProduct.colors; const foundSizes: Set = new Set(); const webflowSkus: DeepPartial[] = []; - for (const specialVariant of mainPrintfulProduct.variants) { + for (const specialVariant of metaPrintfulProduct.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; - for (const specialVariant of mainPrintfulProduct.variants) { - const sizes = specialVariant.product.sync_variants.map( - (v) => v.size, - ); + for (const metaVariant of metaPrintfulProduct.variants) { + const sizes = metaVariant.product.sync_variants.map((v) => v.size); if (!sizes.includes(printfulVariant.size)) sizeIsInAllVariants = false; } + if (!sizeIsInAllVariants) continue; - if (sizeIsInAllVariants) { - foundSizes.add(printfulVariant.size); + foundSizes.add(printfulVariant.size); - webflowSkus.push({ - id: printfulVariant.external_id, - fieldData: { - name: printfulVariant.name, - slug: formatSlug(printfulVariant.name), - "sku-values": { - color: specialVariant.color, - size: printfulVariant.size, - }, - price: { - value: Math.floor( - +printfulVariant.retail_price * 100, - ), - unit: printfulVariant.currency, - currency: printfulVariant.currency, - }, - "main-image": PrintfulClient.Util.getVariantMainImage( - printfulVariant, - ), + webflowSkus.push({ + id: printfulVariant.external_id, + fieldData: { + name: printfulVariant.name, + slug: formatSlug(printfulVariant.name), + "sku-values": { + color: specialVariant.color, + size: printfulVariant.size, }, - }); - } + price: { + value: Math.floor( + +printfulVariant.retail_price * 100, + ), + unit: printfulVariant.currency, + currency: printfulVariant.currency, + }, + "main-image": PrintfulClient.Util.getVariantMainImage( + printfulVariant, + ), + }, + }); } } // SYNC PRODUCT DATA let existingWebflowProduct = webflowProducts.find( - (webflowProduct) => - webflowProduct.product.id === - mainPrintfulProduct.externalId.split("-")[0], + (webflowProduct) => webflowProduct.product.id === metaPrintfulProduct.externalId.split("-")[0], ); if (existingWebflowProduct) { // SYNC PRODUCT UPDATE + this.log.info("existing webflow product found, updating it"); // do not update images - for (const webflowSku of webflowSkus) { + for (const webflowSku of webflowSkus) delete webflowSku.fieldData?.["main-image"]; - } - const webflowProductId = - mainPrintfulProduct.externalId.split("-")[0]; - if (!webflowProductId) - throw new Error("Malformed printful product ID"); + const webflowProductId = metaPrintfulProduct.externalId.split("-")[0]; + if (!webflowProductId) throw new Error("Malformed printful product ID"); + await this.webflow.Products.update(webflowProductId, { product: { fieldData: { - name: mainPrintfulProduct.name, - slug: formatSlug(mainPrintfulProduct.name), + name: metaPrintfulProduct.name, + slug: formatSlug(metaPrintfulProduct.name), shippable: true, "tax-category": "standard-taxable", "sku-properties": [ @@ -209,11 +212,13 @@ export class ProductSyncer { } } else { // SYNC PRODUCT CREATE + this.log.info("existing webflow product not found, creating it"); + const webflowProductId = await this.webflow.Products.create({ product: { fieldData: { - name: mainPrintfulProduct.name, - slug: formatSlug(mainPrintfulProduct.name), + name: metaPrintfulProduct.name, + slug: formatSlug(metaPrintfulProduct.name), shippable: true, "tax-category": "standard-taxable", "sku-properties": [ @@ -249,12 +254,11 @@ export class ProductSyncer { existingWebflowProduct = await this.webflow.Products.get(webflowProductId); if (!existingWebflowProduct) - throw new Error("Webflow product missing after create"); + throw new Error("webflow product missing after create"); - for (const specialVariant of mainPrintfulProduct.variants) { - const newPrintfulVariants: DeepPartial[] = - []; - for (const printfulVariant of specialVariant.product.sync_variants) { + for (const metaVariant of metaPrintfulProduct.variants) { + const newPrintfulVariants: DeepPartial[] = []; + for (const printfulVariant of metaVariant.product.sync_variants) { const associatedWebflowSku = existingWebflowProduct.skus.find( (sku) => sku.fieldData["sku-values"]?.["color"] === printfulVariant.color && @@ -269,12 +273,13 @@ export class ProductSyncer { } await sleep(10000); + this.log.info({ printfulProduct: metaVariant.product.sync_product.id }, "updating printful product"); await this.printful.Products.update( - specialVariant.product.sync_product.id, + metaVariant.product.sync_product.id, { sync_product: { - id: specialVariant.product.sync_product.id, - external_id: `${webflowProductId}-${specialVariant.color}`, + id: metaVariant.product.sync_product.id, + external_id: `${webflowProductId}-${metaVariant.color}`, }, sync_variants: newPrintfulVariants, }, @@ -282,13 +287,13 @@ export class ProductSyncer { } } } - console.log("Done"); - } - finally { + + this.log.info({ durationMs: Date.now() - syncStart }, "sync complete"); + } finally { try { await this.setState({ isSyncing: false, syncingIds: [] }); } catch { - console.error("Failed to reset sync state"); + this.log.error("failed to reset sync state"); } } }