diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index f503b4e..dd89015 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -22,9 +22,10 @@ import { StandardsParametersSchema } from "@blade-and-brawn/calculator"; // SERVICES // ----------- - -const calculatorService = new CalculatorService(); -const commerceService = new CommerceService(); +const s = { + Calculator: new CalculatorService(), + Commerce: new CommerceService() +}; // ELYSIA // ----------- @@ -103,7 +104,7 @@ export const app = new Elysia() (app) => app // Non-authenticated .post("/calculate", async ({ body }) => { - return { levels: calculatorService.calculate(body.player, body.activityPerformances) }; + return { levels: s.Calculator.calculate(body.player, body.activityPerformances) }; }, { body: t.Object({ player: PlayerSchema, @@ -117,23 +118,23 @@ export const app = new Elysia() return { sessionId: token.sessionId.toString() }; }) .put("/config/:id", async ({ params: { id }, body: { datasetId, parameters } }) => { - await calculatorService.standards.setConfig(id, datasetId, parameters); + await s.Calculator.Standards.Config.update(id, datasetId, parameters); }, { params: t.Object({ id: t.String() }), body: t.Object({ datasetId: t.String(), parameters: StandardsParametersSchema }) }) .post("/config", async ({ body: { datasetId, parameters } }) => { - await calculatorService.standards.createConfig(datasetId, parameters); + await s.Calculator.Standards.Config.create(datasetId, parameters); }, { body: t.Object({ datasetId: t.String(), parameters: StandardsParametersSchema }) }) - .post("/config/:id/select", async ({ params: { id } }) => { - await calculatorService.standards.selectConfig(id); + .post("/config/:id/switch", async ({ params: { id } }) => { + await s.Calculator.Standards.Config.switch(id); }, { params: t.Object({ id: t.String() }) }) .get("/config", async () => { - return await calculatorService.standards.getSelectedConfig(); + return await s.Calculator.Standards.Config.get(); }) ) @@ -149,17 +150,17 @@ export const app = new Elysia() app // Sync status .get("/", async ({ sessionId }) => { - const latestSync = await commerceService.getLatestSync(sessionId); + const latestSync = await s.Commerce.Sync.getLatest(sessionId); if (!latestSync) throw new NotFoundError("No sync found"); return { startDate: latestSync.started_at, - status: commerceService.deriveSyncStatus(latestSync), + status: s.Commerce.Sync.deriveStatus(latestSync), syncingPrintfulProductIds: latestSync.syncing_printful_product_ids } }) // Run sync .post("/:printfulProductId?", async ({ params: { printfulProductId }, sessionId }) => { - await commerceService.sync({ + await s.Commerce.Sync.start({ session: { id: sessionId, name: "Portal" }, filter: { printfulProductIds: printfulProductId ? [printfulProductId] : null @@ -168,13 +169,13 @@ export const app = new Elysia() }, { params: t.Object({ printfulProductId: t.Optional(t.Numeric()) }) }), ) .get("/:printfulProductId", async ({ params: { printfulProductId } }) => { - const printfulProduct = await commerceService.printful.Products.get(printfulProductId); + const printfulProduct = await s.Commerce.Printful.Products.get(printfulProductId); if (!printfulProduct) throw new NotFoundError("Missing printful product"); const webflowProductId = printfulProduct.sync_product.external_id.split("-")[0]; if (!webflowProductId) throw new NotFoundError("Missing webflow product ID"); - const webflowProduct = await commerceService.webflow.Products.get(webflowProductId); + const webflowProduct = await s.Commerce.Webflow.Products.get(webflowProductId); return { printfulProduct, webflowProduct }; }, { @@ -192,7 +193,7 @@ export const app = new Elysia() const printfulProduct = payload.data.sync_product; log.info({ productId: printfulProduct.id }, "printful webhook: product updated"); - await commerceService.sync({ + await s.Commerce.Sync.start({ session: { id: randomUUIDv7(), name: "Printful" }, filter: { printfulProductIds: [printfulProduct.id] @@ -206,7 +207,7 @@ export const app = new Elysia() log.info({ externalId: payload.data.sync_product.external_id, webflowProductId }, "printful webhook: product deleted"); if (!webflowProductId) throw new NotFoundError("Missing webflow product ID"); - await commerceService.webflow.Products.remove(webflowProductId); + await s.Commerce.Webflow.Products.remove(webflowProductId); break; } case Printful.Webhook.Event.PackageShipped: { @@ -214,12 +215,12 @@ export const app = new Elysia() const shipInfo = payload.data.shipment; log.info({ webflowOrderId, carrier: shipInfo.carrier, tracking: shipInfo.tracking_number }, "printful webhook: package shipped"); - await commerceService.webflow.Orders.update(webflowOrderId, { + await s.Commerce.Webflow.Orders.update(webflowOrderId, { shippingTrackingURL: shipInfo.tracking_url, shippingTracking: shipInfo.tracking_number, shippingProvider: shipInfo.carrier, }); - await commerceService.webflow.Orders.fulfill(webflowOrderId, { + await s.Commerce.Webflow.Orders.fulfill(webflowOrderId, { sendOrderFulfilledEmail: true, }); break; @@ -229,7 +230,7 @@ export const app = new Elysia() } }) .post("/webhook/webflow", async ({ request, body }) => { - if (!commerceService.webflow.Util.verifyWebflowSignature(request, body)) + if (!s.Commerce.Webflow.Util.verifyWebflowSignature(request, body)) throw status(400, "Invalid signature"); const payload = body as Webflow.Webhook.EventPayload; @@ -239,7 +240,7 @@ export const app = new Elysia() const webflowOrder = payload.payload; log.info({ orderId: webflowOrder.orderId }, "webflow webhook: order created"); - await commerceService.printful.Orders.create({ + await s.Commerce.Printful.Orders.create({ external_id: webflowOrder.orderId, // TODO: derive from webflow shipping: "STANDARD", @@ -272,8 +273,8 @@ app.listen(3000, async () => { log.info({ port: 3000 }, "server started") try { - const frontQueuedSync = await commerceService.queue.front(); - if (frontQueuedSync) await commerceService.sync(frontQueuedSync); + const frontQueuedSync = await s.Commerce.Sync.Queue.front(); + if (frontQueuedSync) await s.Commerce.Sync.start(frontQueuedSync); } catch (err) { log.error({ err }, "failed to resume product sync queue") diff --git a/apps/api/src/services/calculator.ts b/apps/api/src/services/calculator.ts index c49d618..03756af 100644 --- a/apps/api/src/services/calculator.ts +++ b/apps/api/src/services/calculator.ts @@ -5,45 +5,46 @@ import { Value } from "@sinclair/typebox/value"; import { log } from "../util"; export class CalculatorService { - private calculator: LevelCalculator = new LevelCalculator(new Standards(DEFAULT_STANDARDS_DATASET)); - standards: StandardsService = new StandardsService(this.calculator); + private Calculator = new LevelCalculator(new Standards(DEFAULT_STANDARDS_DATASET)); + Standards: StandardsService = new StandardsService(this.Calculator); calculate(player: Player, activityPerformances: ActivityPerformance[]) { - this.standards.refreshConfig({ onlyIfStale: true }).catch((err) => log.error({ err }, "failed to refresh standards config")); - return this.calculator.calculate(player, activityPerformances); + this.Standards.Config.refresh({ onlyIfStale: true }).catch((err) => log.error({ err }, "failed to refresh standards config")); + return this.Calculator.calculate(player, activityPerformances); } } class StandardsService { - private static readonly CONFIG_REFRESH_INTERVAL_MS = 30_000; + Config: StandardsConfigService; + + constructor(private Calculator: LevelCalculator) { + this.Config = new StandardsConfigService(this.Calculator); + } +} + +class StandardsConfigService { + private static readonly REFRESH_INTERVAL_MS = 30_000; private cachedParametersStringified: string | null = null; private cachedDatasetId: string | null = null; private lastRefreshedAt = 0; - constructor(private calculator: LevelCalculator) { } + constructor(private Calculator: LevelCalculator) { } - async getSelectedConfig(opt: { skipCache?: boolean } = {}): Promise { - if (!opt.skipCache) return this.calculator.standards.params; - - await this.refreshConfig(); - return this.calculator.standards.params; + async create(datasetId: string, parameters: StandardsParameters) { + await db.insertInto("standards_configs") + .values({ parameters, dataset_id: datasetId }) + .execute(); } - async selectConfig(id: string) { - await db.transaction().execute(async (trx) => { - await trx.updateTable("standards_configs") - .set({ is_selected: false }) - .where("is_selected", "=", true) - .execute(); - const result = await trx.updateTable("standards_configs") - .set({ is_selected: true }) - .where("id", "=", id) - .execute(); - if (result[0]?.numUpdatedRows === 0n) throw new Error(`No config with id "${id}"`); - }); + /** Retrieves the currently selected configuration */ + async get(opt: { skipCache?: boolean } = {}): Promise { + if (!opt.skipCache) return this.Calculator.Standards.params; + + await this.refresh(); + return this.Calculator.Standards.params; } - async setConfig(id: string, datasetId: string, parameters: StandardsParameters) { + async update(id: string, datasetId: string, parameters: StandardsParameters) { const result = await db.updateTable("standards_configs") .set({ parameters, dataset_id: datasetId }) .where("id", "=", id) @@ -51,15 +52,9 @@ class StandardsService { if (result[0]?.numUpdatedRows === 0n) throw new Error(`No config with id "${id}"`); } - async createConfig(datasetId: string, parameters: StandardsParameters) { - await db.insertInto("standards_configs") - .values({ parameters, dataset_id: datasetId }) - .execute(); - } - - async refreshConfig(opt: { onlyIfStale?: boolean } = {}) { + async refresh(opt: { onlyIfStale?: boolean } = {}) { if (opt.onlyIfStale) - if (Date.now() - this.lastRefreshedAt < StandardsService.CONFIG_REFRESH_INTERVAL_MS) return; + if (Date.now() - this.lastRefreshedAt < StandardsConfigService.REFRESH_INTERVAL_MS) return; this.lastRefreshedAt = Date.now(); @@ -79,8 +74,22 @@ class StandardsService { const parametersStringified = JSON.stringify(config.parameters); if (parametersStringified === this.cachedParametersStringified && config.datasetId === this.cachedDatasetId) return; - this.calculator.standards = new Standards(config.dataset, config.parameters); + this.Calculator.Standards = new Standards(config.dataset, config.parameters); this.cachedParametersStringified = parametersStringified; this.cachedDatasetId = config.datasetId; } + + async switch(id: string) { + await db.transaction().execute(async (trx) => { + await trx.updateTable("standards_configs") + .set({ is_selected: false }) + .where("is_selected", "=", true) + .execute(); + const result = await trx.updateTable("standards_configs") + .set({ is_selected: true }) + .where("id", "=", id) + .execute(); + if (result[0]?.numUpdatedRows === 0n) throw new Error(`No config with id "${id}"`); + }); + } } diff --git a/apps/api/src/services/commerce.ts b/apps/api/src/services/commerce.ts index 76c811b..bea9c25 100644 --- a/apps/api/src/services/commerce.ts +++ b/apps/api/src/services/commerce.ts @@ -19,32 +19,33 @@ type QueuedSync = { class AlreadyClaimedError extends Error { } export class CommerceService { - public readonly printful: PrintfulClient; - public readonly webflow: WebflowClient; - private readonly productSyncer: ProductSyncer; - readonly queue: SyncQueue; + public readonly Printful = new PrintfulClient({ + token: env.PRINTFUL_AUTH, + storeId: env.PRINTFUL_STORE_ID, + }); + public readonly Webflow = new WebflowClient({ + siteId: env.WEBFLOW_SITE_ID, + collectionsId: env.WEBFLOW_COLLECTION_ID, + token: env.WEBFLOW_AUTH, + webhookSecret: env.WEBFLOW_WEBHOOK_SECRET, + }); + readonly Sync = new SyncService(this.Printful, this.Webflow); +} - constructor() { - this.printful = new PrintfulClient({ - token: env.PRINTFUL_AUTH, - storeId: env.PRINTFUL_STORE_ID, - }); - this.webflow = new WebflowClient({ - siteId: env.WEBFLOW_SITE_ID, - collectionsId: env.WEBFLOW_COLLECTION_ID, - token: env.WEBFLOW_AUTH, - webhookSecret: env.WEBFLOW_WEBHOOK_SECRET, - }); - this.productSyncer = new ProductSyncer(this.printful, this.webflow, log); - this.queue = new SyncQueue(); +class SyncService { + readonly Queue = new SyncQueueService(); + private readonly ProductSyncer: ProductSyncer; + + constructor(private readonly Printful: PrintfulClient, private readonly Webflow: WebflowClient) { + this.ProductSyncer = new ProductSyncer(this.Printful, this.Webflow, log); } - async sync(queuedSync: QueuedSync) { + async start(queuedSync: QueuedSync) { let id = queuedSync.id ?? null; let freedSlot = false; try { log.info({ session: queuedSync.session }, "starting sync run"); - await this.productSyncer.sync({ + await this.ProductSyncer.run({ filter: queuedSync.filter, onStart: async (startDate) => { const values: Insertable = { @@ -95,7 +96,7 @@ export class CommerceService { return; } - const activeSync = await this.getActiveSync(); + const activeSync = await this.getActive(); const activeSyncConflict = activeSync && err instanceof DatabaseError && err.code === "23505"; if (activeSyncConflict) { @@ -106,11 +107,11 @@ export class CommerceService { .set({ syncing_printful_product_ids: [], ended_at: new Date(), has_failed: true }) .where("id", "=", activeSync.id) .execute(); - await this.sync(queuedSync); + await this.start(queuedSync); } else { log.info("sync already active, enqueuing next sync"); - await this.queue.enqueue(queuedSync); + await this.Queue.enqueue(queuedSync); } } else { @@ -127,8 +128,8 @@ export class CommerceService { finally { if (freedSlot) { try { - const frontQueuedSync = await this.queue.front(); - if (frontQueuedSync) await this.sync(frontQueuedSync); + const frontQueuedSync = await this.Queue.front(); + if (frontQueuedSync) await this.start(frontQueuedSync); } catch (err) { log.error({ err }, "failed to process next queued sync") @@ -137,7 +138,7 @@ export class CommerceService { } } - async getActiveSync(sessionId?: string) { + async getActive(sessionId?: string) { return await db.selectFrom("product_syncs") .selectAll() .$if(sessionId !== undefined, (qb) => @@ -149,7 +150,7 @@ export class CommerceService { .executeTakeFirst(); } - async getLatestSync(sessionId?: string) { + async getLatest(sessionId?: string) { return await db.selectFrom("product_syncs") .selectAll() .$if(sessionId !== undefined, (qb) => @@ -160,14 +161,14 @@ export class CommerceService { .executeTakeFirst(); } - deriveSyncStatus(sync: Selectable): SyncStatus { + deriveStatus(sync: Selectable): SyncStatus { if (!sync.started_at) return "queued"; if (!sync.ended_at) return "active"; return sync.has_failed ? "failed" : "succeeded"; } } -class SyncQueue { +class SyncQueueService { async enqueue(queuedSync: QueuedSync) { const values: Insertable = { session_id: queuedSync.session.id, diff --git a/packages/calculator/src/index.ts b/packages/calculator/src/index.ts index 6f2b6bf..0c6727b 100644 --- a/packages/calculator/src/index.ts +++ b/packages/calculator/src/index.ts @@ -73,10 +73,10 @@ const metricPriority = (m: NumberMetric) => { export const DEFAULT_STANDARDS_DATASET = defaultStandardsDataset as StandardsDataset; export class LevelCalculator { - standards: Standards; + Standards: Standards; public constructor(standards: Standards) { - this.standards = standards; + this.Standards = standards; } // ------------------------------------------------------------------------------------------------- @@ -138,7 +138,7 @@ export class LevelCalculator { (Object.values(Attribute) as Attribute[]).forEach((attribute) => { const attrActivityPerformances = activityPerformances.filter( (p) => - this.standards.byActivity(p.activity).getMetadata() + this.Standards.byActivity(p.activity).getMetadata() .attribute === attribute, ); attrLevels[attribute] = this.calculateAttributeLevel( @@ -160,7 +160,7 @@ export class LevelCalculator { if ( activityPerformances.some( (p) => - this.standards.byActivity(p.activity).getMetadata() + this.Standards.byActivity(p.activity).getMetadata() .attribute !== attribute, ) ) { @@ -168,7 +168,7 @@ export class LevelCalculator { } // must perform all of an attributes activities - for (const activity of this.standards.getAttributeActivities( + for (const activity of this.Standards.getAttributeActivities( attribute, )) { const filteredActivites = activityPerformances.map( @@ -183,7 +183,7 @@ export class LevelCalculator { } const activityLevels = activityPerformances.map((p) => { - const interpolatedStandard = this.standards + const interpolatedStandard = this.Standards .byActivity(p.activity) .byMetrics(player.metrics) .getOneInterpolated(); diff --git a/packages/commerce/src/sync.ts b/packages/commerce/src/sync.ts index 71a54bd..5795a52 100644 --- a/packages/commerce/src/sync.ts +++ b/packages/commerce/src/sync.ts @@ -30,14 +30,14 @@ export class ProductSyncer { private log: pino.Logger; constructor( - private printful: PrintfulClient, - private webflow: WebflowClient, + private Printful: PrintfulClient, + private Webflow: WebflowClient, log?: pino.Logger, ) { this.log = (log ?? pino({ level: "silent" })).child({ component: "ProductSyncer" }); } - async sync(opt: ProductSyncerOptions = {}) { + async run(opt: ProductSyncerOptions = {}) { const startDate = new Date(); await opt.onStart?.(startDate); @@ -47,10 +47,10 @@ export class ProductSyncer { this.log.info("sync started (all)"); this.log.debug("retrieving webflow products"); - const allWebflowProducts = await this.webflow.Products.list({ forceAll: true }); + const allWebflowProducts = await this.Webflow.Products.list({ forceAll: true }); this.log.debug("retrieving printful products"); - const allPrintfulProducts = await this.printful.Products.list({ forceAll: true }); + const allPrintfulProducts = await this.Printful.Products.list({ forceAll: true }); this.log.info({ printfulProductCount: allPrintfulProducts.length, filter: opt.filter ?? "N/A" }, "generating meta printful products"); const metaPrintfulProducts = await this.generateMetaPrintfulProducts(allWebflowProducts, allPrintfulProducts, opt); @@ -120,7 +120,7 @@ export class ProductSyncer { this.log.warn({ webflowSku: newWebflowSkus[0] }, "could not find existing webflow SKU that matches"); } - await this.webflow.Products.update(webflowProduct.product.id, { + await this.Webflow.Products.update(webflowProduct.product.id, { product: { fieldData: webflowProductFieldData }, sku: newWebflowSkus[0], }); @@ -133,13 +133,13 @@ export class ProductSyncer { newWebflowSku.id, ); if (existingWebflowSku) { - await this.webflow.Products.Skus.update( + await this.Webflow.Products.Skus.update( metaPrintfulProduct.webflowProductId, existingWebflowSku.id, newWebflowSku, ); } else { - await this.webflow.Products.Skus.create( + await this.Webflow.Products.Skus.create( metaPrintfulProduct.webflowProductId, [newWebflowSku], ); @@ -148,18 +148,18 @@ export class ProductSyncer { } else { 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: { fieldData: webflowProductFieldData }, sku: newWebflowSkus[0], }); metaPrintfulProduct.webflowProductId = webflowProductId; if (newWebflowSkus.length > 1) - await this.webflow.Products.Skus.create(webflowProductId, newWebflowSkus.slice(1)); + await this.Webflow.Products.Skus.create(webflowProductId, newWebflowSkus.slice(1)); } // Re-fetch after SKU mutations so newly created SKUs have their real IDs. - const freshWebflowProduct = await this.webflow.Products.get(metaPrintfulProduct.webflowProductId); + const freshWebflowProduct = await this.Webflow.Products.get(metaPrintfulProduct.webflowProductId); if (!freshWebflowProduct) throw new Error("webflow product missing"); // Update printful external IDs @@ -186,7 +186,7 @@ export class ProductSyncer { await sleep(10000); this.log.info({ printfulProduct: printfulProduct.sync_product.id, externalId: expectedExternalId }, "updating printful product's external ID"); - await this.printful.Products.update(printfulProduct.sync_product.id, { + await this.Printful.Products.update(printfulProduct.sync_product.id, { sync_product: { id: printfulProduct.sync_product.id, external_id: expectedExternalId, @@ -286,7 +286,7 @@ export class ProductSyncer { metaPrintfulProduct.webflowProductId = webflowProductId; } - const fullPrintfulProduct = await this.printful.Products.get(printfulProduct.id); + const fullPrintfulProduct = await this.Printful.Products.get(printfulProduct.id); if (!fullPrintfulProduct) throw new Error(`Printful product ${printfulProduct.id} not found`); const isDuplicate = allPrintfulProducts.some((p) => p.id !== printfulProduct.id && p.name === printfulProduct.name);