Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Expand Down Expand Up @@ -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);
});
Comment on lines +219 to +226
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not run this as an interval, and instead use a recursive function

4 changes: 4 additions & 0 deletions lib/services/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand All @@ -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,
Expand Down
79 changes: 76 additions & 3 deletions lib/services/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) });

Expand All @@ -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;
Expand All @@ -46,7 +66,7 @@ export type Product = {

type ProductRow = Omit<Product, 'price'> & { price: string };

export async function createProduct({ price, name, description, type, maxQuantity, eventId, admissionTier, targetProductId, promo, meta }: Omit<Product, 'id'>) {
export async function createProduct({ price, name, description, type, maxQuantity, availableFrom, availableUntil, eventId, admissionTier, targetProductId, promo, meta }: Omit<Product, 'id'>) {
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');
Expand All @@ -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
}
Expand Down Expand Up @@ -142,7 +164,8 @@ export async function getProduct(id: string) {
export async function updateProduct(id: string, updates: Record<string, unknown>) {
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');
Expand All @@ -165,3 +188,53 @@ export async function updateProduct(id: string, updates: Record<string, unknown>

return product;
}

export async function syncScheduledProductAvailability({ updatedBy = null }: { updatedBy?: string | null } = {}) {
try {
const activatedProducts = (
await sql<ProductRow[]>`
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<ProductRow[]>`
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);
}
}
20 changes: 20 additions & 0 deletions lib/utils/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -97,6 +99,8 @@ export type ProductUpdateInput = {
description?: string;
status?: string;
maxQuantity?: number | null;
availableFrom?: string | Date | null;
availableUntil?: string | Date | null;
meta?: Record<string, unknown>;
};

Expand Down Expand Up @@ -416,6 +420,14 @@ export function validateProductCreate(body: unknown): ValidationResult<ProductCr
data.maxQuantity = body.maxQuantity;
}

if (body.availableFrom !== undefined) {
data.availableFrom = body.availableFrom as string | Date | null;
}

if (body.availableUntil !== undefined) {
data.availableUntil = body.availableUntil as string | Date | null;
}

if (body.eventId !== undefined) {
data.eventId = body.eventId as string;
}
Expand Down Expand Up @@ -487,6 +499,14 @@ export function validateProductUpdate(body: unknown): ValidationResult<ProductUp
data.maxQuantity = body.maxQuantity as number | null;
}

if (body.availableFrom !== undefined) {
data.availableFrom = body.availableFrom as string | Date | null;
}

if (body.availableUntil !== undefined) {
data.availableUntil = body.availableUntil as string | Date | null;
}

if (body.meta !== undefined) {
if (!isRecordLike(body.meta)) {
return { valid: false, error: 'meta must be an object if provided' };
Expand Down
Loading