From 9f3631bdebf22e912fe48a519c585f7effbced18 Mon Sep 17 00:00:00 2001 From: Clawrence Date: Tue, 14 Apr 2026 13:29:00 -0700 Subject: [PATCH 1/2] Add product availability windows and sync poller --- lib/index.ts | 30 ++++++++++++++- lib/services/orders.ts | 4 ++ lib/services/products.ts | 79 ++++++++++++++++++++++++++++++++++++++-- lib/utils/validation.ts | 20 ++++++++++ 4 files changed, 129 insertions(+), 4 deletions(-) diff --git a/lib/index.ts b/lib/index.ts index c72c204..b0c59d4 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -6,6 +6,7 @@ import cors from '@koa/cors'; import { v4 as uuidV4 } from 'uuid'; import log, { Logger } from './utils/log.js'; import apiRouter from './routes/index.js'; +import { syncScheduledProductAvailability } from './services/products.js'; type JSONValue = string | number | boolean | null | { [x: string]: JSONValue | unknown } | JSONValue[]; @@ -195,4 +196,31 @@ app.on('error', (err, ctx) => { } }); -app.listen(4000, () => log.info(`Mustache Bash API ${process.env.npm_package_version} listening on port 4000`)); +const productAvailabilityPollIntervalMs = 60 * 1000; + +async function runScheduledProductAvailabilitySync() { + try { + const { activatedProducts, archivedProducts } = await syncScheduledProductAvailability(); + + if (activatedProducts.length || archivedProducts.length) { + log.info( + { + activatedProductIds: activatedProducts.map(product => product.id), + archivedProductIds: archivedProducts.map(product => product.id) + }, + 'Synced scheduled product availability' + ); + } + } catch (err) { + log.error({ err }, 'Scheduled product availability sync failed'); + } +} + +app.listen(4000, () => { + log.info(`Mustache Bash API ${process.env.npm_package_version} listening on port 4000`); + + void runScheduledProductAvailabilitySync(); + setInterval(() => { + void runScheduledProductAvailabilitySync(); + }, productAvailabilityPollIntervalMs); +}); diff --git a/lib/services/orders.ts b/lib/services/orders.ts index d103b23..59bd445 100644 --- a/lib/services/orders.ts +++ b/lib/services/orders.ts @@ -189,6 +189,8 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} ON p.id = oi.product_id WHERE p.id in ${sql(cart.map(i => i.productId))} AND status = 'active' + AND (p.available_from IS NULL OR p.available_from <= now()) + AND (p.available_until IS NULL OR p.available_until > now() - interval '5 minutes') GROUP BY 1 ` ).map(p => ({ @@ -205,6 +207,8 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} WHERE p.type = 'bundle-ticket' AND p.target_product_id in ${sql(products.map(i => i.id))} AND status = 'active' + AND (p.available_from IS NULL OR p.available_from <= now()) + AND (p.available_until IS NULL OR p.available_until > now() - interval '5 minutes') ` ).map(p => ({ ...p, diff --git a/lib/services/products.ts b/lib/services/products.ts index daf596c..1523ae7 100644 --- a/lib/services/products.ts +++ b/lib/services/products.ts @@ -21,7 +21,25 @@ class ProductsServiceError extends Error { } } -const productColumns = ['id', 'status', 'type', 'name', 'description', 'admission_tier', 'price', 'event_id', 'target_product_id', 'promo', 'max_quantity', 'created', 'updated', 'updated_by', 'meta']; +const productColumns = [ + 'id', + 'status', + 'type', + 'name', + 'description', + 'admission_tier', + 'price', + 'event_id', + 'target_product_id', + 'promo', + 'max_quantity', + 'available_from', + 'available_until', + 'created', + 'updated', + 'updated_by', + 'meta' +]; const convertPriceToNumber = (p: ProductRow): Product => ({ ...p, price: Number(p.price) }); @@ -34,6 +52,8 @@ export type Product = { description: string; type: ProductType; maxQuantity: number | null; + availableFrom?: Date | string | null; + availableUntil?: Date | string | null; eventId?: string; admissionTier?: string; targetProductId?: string; @@ -46,7 +66,7 @@ export type Product = { type ProductRow = Omit & { price: string }; -export async function createProduct({ price, name, description, type, maxQuantity, eventId, admissionTier, targetProductId, promo, meta }: Omit) { +export async function createProduct({ price, name, description, type, maxQuantity, availableFrom, availableUntil, eventId, admissionTier, targetProductId, promo, meta }: Omit) { if (!name || !description || !type) throw new ProductsServiceError('Missing product data', 'INVALID'); if (typeof price !== 'number') throw new ProductsServiceError('Price must be a number', 'INVALID'); if (type === 'ticket' && (!eventId || !admissionTier)) throw new ProductsServiceError('No event set for ticket', 'INVALID'); @@ -60,6 +80,8 @@ export async function createProduct({ price, name, description, type, maxQuantit description, type, maxQuantity: maxQuantity || null, + availableFrom: availableFrom || null, + availableUntil: availableUntil || null, meta: { ...meta } @@ -142,7 +164,8 @@ export async function getProduct(id: string) { export async function updateProduct(id: string, updates: Record) { for (const u in updates) { // Update whitelist - if (!['name', 'price', 'description', 'status', 'maxQuantity', 'meta', 'updatedBy'].includes(u)) throw new ProductsServiceError('Invalid product data', 'INVALID'); + if (!['name', 'price', 'description', 'status', 'maxQuantity', 'availableFrom', 'availableUntil', 'meta', 'updatedBy'].includes(u)) + throw new ProductsServiceError('Invalid product data', 'INVALID'); } if (Object.keys(updates).length === 1 && updates.updatedBy) throw new ProductsServiceError('Invalid product data', 'INVALID'); @@ -165,3 +188,53 @@ export async function updateProduct(id: string, updates: Record return product; } + +export async function syncScheduledProductAvailability({ updatedBy = null }: { updatedBy?: string | null } = {}) { + try { + const activatedProducts = ( + await sql` + WITH sold_counts AS ( + SELECT + p.id AS product_id, + COALESCE(SUM(oi.quantity), 0) AS total_sold + FROM products AS p + LEFT JOIN order_items AS oi + ON oi.product_id = p.id + LEFT JOIN orders AS o + ON o.id = oi.order_id + AND o.status != 'canceled' + GROUP BY p.id + ) + UPDATE products AS p + SET status = 'active', updated = now(), updated_by = ${updatedBy} + FROM sold_counts AS sc + WHERE p.id = sc.product_id + AND p.status = 'inactive' + AND p.available_from IS NOT NULL + AND p.available_from > now() - interval '10 minutes' + AND p.available_from <= now() + AND (p.max_quantity IS NULL OR sc.total_sold < p.max_quantity) + RETURNING ${sql(productColumns.map(c => `p.${c}`))} + ` + ).map(convertPriceToNumber); + + const archivedProducts = ( + await sql` + UPDATE products AS p + SET status = 'archived', updated = now(), updated_by = ${updatedBy} + WHERE p.status = 'active' + AND p.available_until IS NOT NULL + AND p.available_until > now() - interval '10 minutes' + AND p.available_until <= now() + RETURNING ${sql(productColumns.map(c => `p.${c}`))} + ` + ).map(convertPriceToNumber); + + return { + activatedProducts, + archivedProducts + }; + } catch (e) { + throw new ProductsServiceError('Could not sync scheduled product availability', 'UNKNOWN', e); + } +} diff --git a/lib/utils/validation.ts b/lib/utils/validation.ts index 16c4127..282c39b 100644 --- a/lib/utils/validation.ts +++ b/lib/utils/validation.ts @@ -84,6 +84,8 @@ export type ProductCreateInput = { description: string; type: ProductType; maxQuantity: number | null; + availableFrom?: string | Date | null; + availableUntil?: string | Date | null; eventId?: string; admissionTier?: string; targetProductId?: string; @@ -97,6 +99,8 @@ export type ProductUpdateInput = { description?: string; status?: string; maxQuantity?: number | null; + availableFrom?: string | Date | null; + availableUntil?: string | Date | null; meta?: Record; }; @@ -416,6 +420,14 @@ export function validateProductCreate(body: unknown): ValidationResult Date: Tue, 5 May 2026 14:49:00 -0700 Subject: [PATCH 2/2] Address product availability sync review --- lib/index.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/index.ts b/lib/index.ts index b0c59d4..f129975 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -213,6 +213,10 @@ async function runScheduledProductAvailabilitySync() { } } catch (err) { log.error({ err }, 'Scheduled product availability sync failed'); + } finally { + setTimeout(() => { + void runScheduledProductAvailabilitySync(); + }, productAvailabilityPollIntervalMs); } } @@ -220,7 +224,4 @@ app.listen(4000, () => { log.info(`Mustache Bash API ${process.env.npm_package_version} listening on port 4000`); void runScheduledProductAvailabilitySync(); - setInterval(() => { - void runScheduledProductAvailabilitySync(); - }, productAvailabilityPollIntervalMs); });