From e48329bb9e391f4f7cad3fe320f5fd2a3578840c Mon Sep 17 00:00:00 2001 From: Joe Furfaro Date: Sat, 27 Dec 2025 12:59:09 -0800 Subject: [PATCH] Claude Code Opus 4.5: added types to all service methods, first pass --- lib/routes/customers.ts | 3 + lib/routes/events.ts | 5 ++ lib/routes/guests.ts | 5 ++ lib/routes/orders.ts | 5 +- lib/routes/products.ts | 5 ++ lib/routes/promos.ts | 3 + lib/routes/sites.ts | 2 + lib/services/customers.ts | 45 +++++++---- lib/services/email.ts | 8 +- lib/services/events.ts | 151 ++++++++++++++++++++++++++++------- lib/services/guests.ts | 67 ++++++++++++---- lib/services/orders.ts | 51 +++++++++--- lib/services/products.ts | 25 +++--- lib/services/promos.ts | 26 +++--- lib/services/tickets.ts | 72 ++++++++++++++--- lib/services/transactions.ts | 41 +++++++--- 16 files changed, 398 insertions(+), 116 deletions(-) diff --git a/lib/routes/customers.ts b/lib/routes/customers.ts index 501feaf..ff90945 100644 --- a/lib/routes/customers.ts +++ b/lib/routes/customers.ts @@ -2,6 +2,7 @@ import Router from '@koa/router'; import { AppContext } from '../index.js'; import { authorizeUser, requiresPermission } from '../middleware/auth.js'; import { createCustomer, getCustomers, getCustomer, updateCustomer } from '../services/customers.js'; +import { isRecordLike } from '../utils/type-guards.js'; const customersRouter = new Router({ prefix: '/customers' @@ -18,6 +19,8 @@ customersRouter } }) .post('/', authorizeUser, requiresPermission('admin'), async ctx => { + if(!isRecordLike(ctx.request.body)) throw ctx.throw(400); + try { const customer = await createCustomer({...ctx.request.body, createdBy: ctx.state.user.id}); diff --git a/lib/routes/events.ts b/lib/routes/events.ts index cdd7ae8..b717c2f 100644 --- a/lib/routes/events.ts +++ b/lib/routes/events.ts @@ -1,6 +1,7 @@ import Router from '@koa/router'; import { authorizeUser, requiresPermission } from '../middleware/auth.js'; import { getEvents, getEvent, createEvent, updateEvent, getEventSummary, getOpeningSales, getEventExtendedStats, getEventDailyTickets, getEventCheckins } from '../services/events.js'; +import { isRecordLike } from '../utils/type-guards.js'; const eventsRouter = new Router({ prefix: '/events' @@ -19,6 +20,8 @@ eventsRouter } }) .post('/', requiresPermission('admin'), async ctx => { + if(!isRecordLike(ctx.request.body)) throw ctx.throw(400); + try { const event = await createEvent(ctx.request.body); @@ -43,6 +46,8 @@ eventsRouter } }) .patch('/:id', requiresPermission('admin'), async ctx => { + if(!isRecordLike(ctx.request.body)) throw ctx.throw(400); + try { const event = await updateEvent(ctx.params.id, {...ctx.request.body, updatedBy: ctx.state.user.id}); diff --git a/lib/routes/guests.ts b/lib/routes/guests.ts index bb6a07f..0063871 100644 --- a/lib/routes/guests.ts +++ b/lib/routes/guests.ts @@ -7,6 +7,7 @@ import { updateGuest, archiveGuest } from '../services/guests.js'; +import { isRecordLike } from '../utils/type-guards.js'; const guestsRouter = new Router({ prefix: '/guests' @@ -25,6 +26,8 @@ guestsRouter } }) .post('/', requiresPermission('write'), async ctx => { + if(!isRecordLike(ctx.request.body)) throw ctx.throw(400); + try { const guest = await createGuest({...ctx.request.body, createdBy: ctx.state.user.id, createdReason: 'comp'}); @@ -51,6 +54,8 @@ guestsRouter } }) .patch('/:id', async ctx => { + if(!isRecordLike(ctx.request.body)) throw ctx.throw(400); + try { const guest = await updateGuest(ctx.params.id, {updatedBy: ctx.state.user.id, ...ctx.request.body}); diff --git a/lib/routes/orders.ts b/lib/routes/orders.ts index 1ebcc34..42acb07 100644 --- a/lib/routes/orders.ts +++ b/lib/routes/orders.ts @@ -4,6 +4,7 @@ import { getOrderTickets, transferTickets } from '../services/tickets.js'; import { createOrder, getOrders, getOrder, getOrderTransfers, refundOrder, generateOrderToken } from '../services/orders.js'; import { sendReceipt, upsertEmailSubscriber, sendTransfereeConfirmation, sendUpgradeReceipt } from '../services/email.js'; import { getTransactions } from '../services/transactions.js'; +import { isRecordLike } from '../utils/type-guards.js'; // TODO: make this configurable at some point const EMAIL_LIST = '90392ecd5e', @@ -24,7 +25,7 @@ ordersRouter } }) .post('/', async ctx => { - if(!ctx.request.body) throw ctx.throw(400); + if(!isRecordLike(ctx.request.body)) throw ctx.throw(400); try { const { order, transaction, customer } = await createOrder({...ctx.request.body}), @@ -136,7 +137,7 @@ ordersRouter } }) .post('/:id/transfers', authorizeUser, requiresPermission('write'), async ctx => { - if(!ctx.request.body) throw ctx.throw(400); + if(!isRecordLike(ctx.request.body)) throw ctx.throw(400); try { const { transferee, order } = await transferTickets(ctx.params.id, ctx.request.body, ctx.state.user.id), diff --git a/lib/routes/products.ts b/lib/routes/products.ts index e3c0935..a8ff06e 100644 --- a/lib/routes/products.ts +++ b/lib/routes/products.ts @@ -1,6 +1,7 @@ import Router from '@koa/router'; import { authorizeUser } from '../middleware/auth.js'; import { createProduct, getProducts, getProduct, updateProduct } from '../services/products.js'; +import { isRecordLike } from '../utils/type-guards.js'; const productsRouter = new Router({ prefix: '/products' @@ -19,6 +20,8 @@ productsRouter } }) .post('/', async ctx => { + if(!isRecordLike(ctx.request.body)) throw ctx.throw(400); + try { const product = await createProduct(ctx.request.body); @@ -45,6 +48,8 @@ productsRouter } }) .patch('/:id', async ctx => { + if(!isRecordLike(ctx.request.body)) throw ctx.throw(400); + try { const product = await updateProduct(ctx.params.id, {...ctx.request.body, updatedBy: ctx.state.user.id}); diff --git a/lib/routes/promos.ts b/lib/routes/promos.ts index dba61a0..2949d35 100644 --- a/lib/routes/promos.ts +++ b/lib/routes/promos.ts @@ -2,6 +2,7 @@ import Router from '@koa/router'; import { authorizeUser, requiresPermission } from '../middleware/auth.js'; import { createPromo, getPromos, getPromo, updatePromo } from '../services/promos.js'; import { getProduct } from '../services/products.js'; +import { isRecordLike } from '../utils/type-guards.js'; const promosRouter = new Router({ prefix: '/promos' @@ -18,6 +19,8 @@ promosRouter } }) .post('/', authorizeUser, requiresPermission('write'), async ctx => { + if(!isRecordLike(ctx.request.body)) throw ctx.throw(400); + try { const promo = await createPromo({...ctx.request.body, createdBy: ctx.state.user.id}); diff --git a/lib/routes/sites.ts b/lib/routes/sites.ts index 5b92144..4edb69e 100644 --- a/lib/routes/sites.ts +++ b/lib/routes/sites.ts @@ -1,5 +1,6 @@ import Router from '@koa/router'; import { upsertEmailSubscriber } from '../services/email.js'; +import { isRecordLike } from '../utils/type-guards.js'; // TODO: make this configurable at some point const EMAIL_LIST_ID = '90392ecd5e'; @@ -11,6 +12,7 @@ const sitesRouter = new Router({ // Keeping this unti the front end is updated to hit a different route sitesRouter .post('/:id/mailing-list', async ctx => { + if(!isRecordLike(ctx.request.body)) throw ctx.throw(400); if(!ctx.request.body.email || !ctx.request.body.firstName || !ctx.request.body.lastName) throw ctx.throw(400); try { diff --git a/lib/services/customers.ts b/lib/services/customers.ts index affefb9..4d7ec49 100644 --- a/lib/services/customers.ts +++ b/lib/services/customers.ts @@ -10,7 +10,7 @@ export class CustomerServiceError extends Error { code: string; context?: unknown; - constructor(message = 'An unknown error occured', code = 'UNKNOWN', context) { + constructor(message = 'An unknown error occured', code = 'UNKNOWN', context?: unknown) { super(message); this.name = this.constructor.name; @@ -21,6 +21,17 @@ export class CustomerServiceError extends Error { } } +export type Customer = { + id: string; + email: string; + firstName: string; + lastName: string; + created: Date; + updated: Date; + updatedBy: string | null; + meta: Record; +}; + const customerColumns = [ 'id', 'email', @@ -32,8 +43,14 @@ const customerColumns = [ 'meta' ]; +type CustomerInput = Record & { + firstName?: string; + lastName?: string; + email?: string; + meta?: Record; +}; -export async function createCustomer({ firstName, lastName, email, meta }) { +export async function createCustomer({ firstName, lastName, email, meta }: CustomerInput) { if(!firstName || !lastName || !email) throw new CustomerServiceError('Missing customer data', 'INVALID'); if(!/.+@.+\..{2,}/.test(email)) throw new CustomerServiceError('Invalid email', 'INVALID'); @@ -48,10 +65,10 @@ export async function createCustomer({ firstName, lastName, email, meta }) { }; try { - const [createdCustomer] = (await sql` + const [createdCustomer] = await sql` INSERT INTO customers ${sql(customer)} RETURNING ${sql(customerColumns)} - `); + `; return createdCustomer; } catch(e) { @@ -59,9 +76,9 @@ export async function createCustomer({ firstName, lastName, email, meta }) { } } -export async function getCustomers() { +export async function getCustomers(_options?: Record): Promise { try { - const customers = await sql` + const customers = await sql` SELECT ${sql(customerColumns)} FROM customers `; @@ -72,14 +89,14 @@ export async function getCustomers() { } } -export async function getCustomer(id) { - let customer; +export async function getCustomer(id: string): Promise { + let customer: Customer | undefined; try { - [customer] = (await sql` + [customer] = await sql` SELECT ${sql(customerColumns)} FROM customers WHERE id = ${id} - `); + `; } catch(e) { throw new CustomerServiceError('Could not query customers', 'UNKNOWN', e); } @@ -89,7 +106,7 @@ export async function getCustomer(id) { return customer; } -export async function updateCustomer(id, updates) { +export async function updateCustomer(id: string, updates: Record): Promise { for(const u in updates) { // Update whitelist if(![ @@ -103,14 +120,14 @@ export async function updateCustomer(id, updates) { if(Object.keys(updates).length === 1 && updates.updatedBy) throw new CustomerServiceError('Invalid customer data', 'INVALID'); - let customer; + let customer: Customer | undefined; try { - [customer] = (await sql` + [customer] = await sql` UPDATE customers SET ${sql(updates)}, updated = now() WHERE id = ${id} RETURNING ${sql(customerColumns)} - `); + `; } catch(e) { throw new CustomerServiceError('Could not update customer', 'UNKNOWN', e); } diff --git a/lib/services/email.ts b/lib/services/email.ts index d3867e0..e92063b 100644 --- a/lib/services/email.ts +++ b/lib/services/email.ts @@ -12,7 +12,7 @@ import MailChimpClient from 'mailchimp-api-v3'; const mailgun = MailgunJs({apiKey: config.mailgun.apiKey, domain: config.mailgun.domain}); const mailchimp = new MailChimpClient(config.mailchimp.apiKey), - md5 = string => crypto.createHash('md5').update(string).digest('hex'); + md5 = (string: string) => crypto.createHash('md5').update(string).digest('hex'); /* eslint-disable max-len */ /** @@ -25,7 +25,7 @@ const mailchimp = new MailChimpClient(config.mailchimp.apiKey), * @param {Array} [tags=[] string }] tags to apply to member * @return {Promise} */ -export async function upsertEmailSubscriber(listId, { email, firstName, lastName, tags = [] }) { +export async function upsertEmailSubscriber(listId: string, { email, firstName, lastName, tags = [] }: { email: string; firstName: string; lastName: string; tags?: string[] }) { const memberHash = md5(email.toLowerCase()); try { @@ -50,7 +50,7 @@ export async function upsertEmailSubscriber(listId, { email, firstName, lastName } } -export function sendReceipt(guestFirstName, guestLastName, guestEmail, confirmation, orderId, orderToken, amount) { +export function sendReceipt(guestFirstName: string, guestLastName: string, guestEmail: string, confirmation: string, orderId: string, orderToken: string, amount: number) { mailgun.messages().send({ from: 'Mustache Bash Tickets ', to: guestFirstName + ' ' + guestLastName + ' <' + guestEmail + '> ', @@ -360,7 +360,7 @@ table[class=body] .article { .catch(err => log.error({err, customerEmail, confirmation}, 'Receipt email failed to send')); } -export function sendTransfereeConfirmation(transfereeFirstName, transfereeLastName, transfereeEmail, parentOrderId, orderToken) { +export function sendTransfereeConfirmation(transfereeFirstName: string, transfereeLastName: string, transfereeEmail: string, parentOrderId: string, orderToken: string) { mailgun.messages().send({ from: 'Mustache Bash Tickets ', to: transfereeFirstName + ' ' + transfereeLastName + ' <' + transfereeEmail + '> ', diff --git a/lib/services/events.ts b/lib/services/events.ts index b7c4ffb..3f859e3 100644 --- a/lib/services/events.ts +++ b/lib/services/events.ts @@ -7,7 +7,10 @@ import { sql } from '../utils/db.js'; import { v4 as uuidV4 } from 'uuid'; class EventsServiceError extends Error { - constructor(message = 'An unknown error occured', code = 'UNKNOWN', context) { + code: string; + context: unknown; + + constructor(message = 'An unknown error occured', code = 'UNKNOWN', context?: unknown) { super(message); this.name = this.constructor.name; @@ -18,6 +21,28 @@ class EventsServiceError extends Error { } } +export type Event = { + id: string; + name: string; + date: Date; + status: string; + created: Date; + updated: Date; + openingSales: Date | null; + salesEnabled: boolean; + maxCapacity: number | null; + budget: number | null; + alcoholRevenue: number | null; + foodRevenue: number | null; + meta: Record; +}; + +type EventRow = Omit & { + budget: string | null; + alcoholRevenue: string | null; + foodRevenue: string | null; +}; + const eventColumns = [ 'id', 'name', @@ -34,17 +59,17 @@ const eventColumns = [ 'meta' ]; -const convertNumericTypeToNumbers = e => ({ +const convertNumericTypeToNumbers = (e: EventRow): Event => ({ ...e, - ...(typeof e.alcoholRevenue === 'string' ? {alcoholRevenue: Number(e.alcoholRevenue)} : {}), - ...(typeof e.budget === 'string' ? {budget: Number(e.budget)} : {}), - ...(typeof e.foodRevenue === 'string' ? {foodRevenue: Number(e.foodRevenue)} : {}) + budget: e.budget !== null ? Number(e.budget) : null, + alcoholRevenue: e.alcoholRevenue !== null ? Number(e.alcoholRevenue) : null, + foodRevenue: e.foodRevenue !== null ? Number(e.foodRevenue) : null }); -export async function getEvents({ status }) { +export async function getEvents({ status }: { status?: string } = {}): Promise { try { - const events = await sql` + const events = await sql` SELECT ${sql(eventColumns)} FROM events ${status ? sql`WHERE status = ${status}` : sql``} @@ -56,10 +81,10 @@ export async function getEvents({ status }) { } } -export async function getEvent(id) { - let event; +export async function getEvent(id: string): Promise { + let event: Event | undefined; try { - [event] = (await sql` + [event] = (await sql` SELECT ${sql(eventColumns)} FROM events WHERE id = ${id} @@ -73,7 +98,7 @@ export async function getEvent(id) { return event; } -export async function createEvent(newEvent) { +export async function createEvent(newEvent: Record): Promise { for(const u in newEvent) { // Fields whitelist if(![ @@ -103,12 +128,12 @@ export async function createEvent(newEvent) { alcoholRevenue: newEvent.alcoholRevenue, foodRevenue: newEvent.foodRevenue, meta: { - ...newEvent.meta + ...newEvent.meta as Record } }; try { - const [createdEvent] = (await sql` + const [createdEvent] = (await sql` INSERT INTO events ${sql(event)} RETURNING ${sql(eventColumns)} `).map(convertNumericTypeToNumbers); @@ -119,7 +144,7 @@ export async function createEvent(newEvent) { } } -export async function updateEvent(id, updates) { +export async function updateEvent(id: string, updates: Record): Promise { for(const u in updates) { // Update whitelist if(![ @@ -139,9 +164,9 @@ export async function updateEvent(id, updates) { if(Object.keys(updates).length === 1 && updates.updatedBy) throw new EventsServiceError('Invalid event data', 'INVALID'); - let event; + let event: Event | undefined; try { - [event] = (await sql` + [event] = (await sql` UPDATE events SET ${sql(updates)}, updated = now() WHERE id = ${id} @@ -156,11 +181,33 @@ export async function updateEvent(id, updates) { return event; } +type EventSettingsProduct = { + id: string; + name: string; + description: string; + price: string; + status: string; + eventId: string; + admissionTier: string; + type: string; + meta: Record; +}; + +type EventSettings = { + id: string; + name: string; + date: Date; + openingSales: Date | null; + salesEnabled: boolean; + meta: Record; + products: EventSettingsProduct[]; +}; + // !!! THIS GETS EXPOSED PUBLICLY -export async function getEventSettings(id) { - let event; +export async function getEventSettings(id: string): Promise { + let event: EventSettings | undefined; try { - [event] = await sql` + [event] = await sql` SELECT e.id, e.name, @@ -201,9 +248,29 @@ export async function getEventSettings(id) { return event; } -export async function getEventSummary(id) { +type EventSummaryRow = { + eventId: string; + totalGuests: string; + totalPaidGuests: string; + totalCompedGuests: string; + totalVipGuests: string; + guestsToday: string; + checkedIn: string; +}; + +type EventSummary = { + eventId: string; + totalGuests: number; + totalPaidGuests: number; + totalCompedGuests: number; + totalVipGuests: number; + guestsToday: number; + checkedIn: number; +}; + +export async function getEventSummary(id: string): Promise { try { - const [summary] = (await sql` + const [summary] = (await sql` SELECT e.id as event_id, count(g.id) FILTER (WHERE g.status <> 'archived') as total_guests, @@ -220,7 +287,7 @@ export async function getEventSummary(id) { ON g.event_id = e.id WHERE e.id = ${id} GROUP BY e.id - `).map(s => ({ + `).map((s): EventSummary => ({ ...s, totalGuests: Number(s.totalGuests), totalPaidGuests: Number(s.totalPaidGuests), @@ -236,9 +303,35 @@ export async function getEventSummary(id) { } } -export async function getEventExtendedStats(id) { +type EventExtendedStatsRow = { + eventId: string; + eventBudget: string | null; + eventMaxCapacity: number | null; + alcoholRevenue: string | null; + foodRevenue: string | null; + salesTiers: Array<{ name: string; quantity: string; price: string }>; + totalRevenue: string | null; + totalPromoRevenue: string | null; + revenueToday: string; + promoRevenueToday: string; +}; + +type EventExtendedStats = { + eventId: string; + eventBudget: number; + eventMaxCapacity: number | null; + alcoholRevenue: number; + foodRevenue: number; + salesTiers: Array<{ name: string; quantity: string; price: string }>; + totalRevenue: number; + totalPromoRevenue: number; + revenueToday: number; + promoRevenueToday: number; +}; + +export async function getEventExtendedStats(id: string): Promise { try { - const [extendedStats] = (await sql` + const [extendedStats] = (await sql` WITH ProductAggregation AS ( SELECT p.event_id, @@ -308,7 +401,7 @@ export async function getEventExtendedStats(id) { alcoholRevenue, foodRevenue, ...rest - }) => ({ + }): EventExtendedStats => ({ ...rest, revenueToday: Number(revenueToday), promoRevenueToday: Number(promoRevenueToday), @@ -325,9 +418,9 @@ export async function getEventExtendedStats(id) { } } -export async function getEventDailyTickets(id: string) { +export async function getEventDailyTickets(id: string): Promise> { try { - const chart = (await sql` + const chart = (await sql>` SELECT (o.created AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')::DATE AS date, SUM(oi.quantity) as tickets @@ -351,7 +444,7 @@ export async function getEventDailyTickets(id: string) { } } -export async function getOpeningSales(id: string) { +export async function getOpeningSales(id: string): Promise> { try { const chart = (await sql<{minuteCreated: string; tickets: string}[]>` SELECT @@ -381,7 +474,7 @@ export async function getOpeningSales(id: string) { } } -export async function getEventCheckins(id: string) { +export async function getEventCheckins(id: string): Promise> { try { const chart = (await sql<{minuteCheckedIn: string; checkins: string}[]>` SELECT diff --git a/lib/services/guests.ts b/lib/services/guests.ts index 0cc17f4..58eea3e 100644 --- a/lib/services/guests.ts +++ b/lib/services/guests.ts @@ -24,6 +24,23 @@ class GuestsServiceError extends Error { } } +export type Guest = { + id: string; + firstName: string; + lastName: string; + admissionTier: string; + created: Date; + updated: Date; + updatedBy: string | null; + createdBy: string | null; + createdReason: 'purchase' | 'comp' | 'transfer'; + status: 'active' | 'checked_in' | 'archived'; + checkInTime: Date | null; + orderId: string | null; + eventId: string; + meta: Record; +}; + const guestColumns = [ 'id', 'first_name', @@ -41,7 +58,18 @@ const guestColumns = [ 'meta' ]; -export async function createGuest({ firstName, lastName, createdReason, orderId = null, createdBy = null, eventId, admissionTier, meta }) { +type GuestInput = { + firstName: string; + lastName: string; + createdReason: 'purchase' | 'comp' | 'transfer'; + orderId?: string | null; + createdBy?: string | null; + eventId: string; + admissionTier: string; + meta?: Record; +}; + +export async function createGuest({ firstName, lastName, createdReason, orderId = null, createdBy = null, eventId, admissionTier, meta }: GuestInput): Promise { if(!firstName || !lastName || !eventId || !admissionTier || !createdReason || (orderId === null && createdReason === 'purchase')) throw new GuestsServiceError('Missing guest data', 'INVALID'); const guest = { @@ -60,7 +88,7 @@ export async function createGuest({ firstName, lastName, createdReason, orderId }; try { - const [createdGuest] = await sql` + const [createdGuest] = await sql` INSERT INTO guests ${sql(guest)} RETURNING ${sql(guestColumns)} `; @@ -71,9 +99,18 @@ export async function createGuest({ firstName, lastName, createdReason, orderId } } -export async function getGuests({ limit, eventId, admissionTier, createdReason, orderBy = 'created', sort = 'desc' }) { +type GuestQueryOptions = { + limit?: number; + eventId?: string; + admissionTier?: string; + createdReason?: string; + orderBy?: string; + sort?: 'asc' | 'desc'; +}; + +export async function getGuests({ limit, eventId, admissionTier, createdReason, orderBy = 'created', sort = 'desc' }: GuestQueryOptions = {}): Promise { try { - const guests = await sql` + const guests = await sql` SELECT ${sql(guestColumns)} FROM guests WHERE 1 = 1 @@ -90,10 +127,10 @@ export async function getGuests({ limit, eventId, admissionTier, createdReason, } } -export async function getGuest(id) { - let guest; +export async function getGuest(id: string): Promise { + let guest: Guest | undefined; try { - [guest] = await sql` + [guest] = await sql` SELECT ${sql(guestColumns)} FROM guests WHERE id = ${id} @@ -107,7 +144,7 @@ export async function getGuest(id) { return guest; } -export async function updateGuest(id, updates) { +export async function updateGuest(id: string, updates: Record): Promise { for(const u in updates) { // Update whitelist if(!['status', 'firstName', 'lastName', 'updatedBy', 'meta', 'admissionTier'].includes(u)) throw new GuestsServiceError('Invalid guest data', 'INVALID'); @@ -129,10 +166,10 @@ export async function updateGuest(id, updates) { // Prevent accidental downgrading of a guest below their purchased tier // TODO: this also inadvertantly prevents from upgrading guests that were created by transfers - let minimumAdmissionTier; + let minimumAdmissionTier: { admissionTier: string } | undefined; if (updates.admissionTier) { try { - [minimumAdmissionTier] = await sql` + [minimumAdmissionTier] = await sql<{ admissionTier: string }[]>` SELECT p.admission_tier FROM guests AS g LEFT JOIN order_items AS oi @@ -158,9 +195,9 @@ export async function updateGuest(id, updates) { } } - let updatedGuest; + let updatedGuest: Guest | undefined; try { - [updatedGuest] = await sql` + [updatedGuest] = await sql` UPDATE guests SET ${sql(updates)}, updated = now() WHERE id = ${id} @@ -175,10 +212,10 @@ export async function updateGuest(id, updates) { return updatedGuest; } -export async function archiveGuest(id, updatedBy) { - let guest; +export async function archiveGuest(id: string, updatedBy: string): Promise { + let guest: Guest | undefined; try { - [guest] = await sql` + [guest] = await sql` UPDATE guests SET status = 'archived', updated = now(), updated_by = ${updatedBy} WHERE id = ${id} diff --git a/lib/services/orders.ts b/lib/services/orders.ts index e085c5f..395591c 100644 --- a/lib/services/orders.ts +++ b/lib/services/orders.ts @@ -36,6 +36,28 @@ class OrdersServiceError extends Error { } } +export type Order = { + id: string; + amount: number; + created: Date; + customerId: string; + promoId: string | null; + parentOrderId: string | null; + status: 'complete' | 'canceled' | 'transferred'; + meta: Record; +}; + +type OrderRow = Omit & { amount: string }; + +type OrderWithItems = Order & { + items: Array<{ productId: string; quantity: number }>; + customerEmail?: string; + customerFirstName?: string; + customerLastName?: string; +}; + +type OrderRowWithItems = Omit & { amount: string }; + const orderColumns = [ 'id', 'amount', @@ -56,9 +78,12 @@ const aggregateOrderItems = sql` ) as items `; -const convertAmountToNumber = o => ({...o, ...(typeof o.amount === 'string' ? {amount: Number(o.amount)} : {})}); +const convertAmountToNumber = (o: T): T extends OrderRowWithItems ? OrderWithItems : Order => ({ + ...o, + amount: Number(o.amount) +} as T extends OrderRowWithItems ? OrderWithItems : Order); -export async function getOrders({ eventId, productId, status, limit, orderBy = 'created', sort = 'desc' }) { +export async function getOrders({ eventId, productId, status, limit, orderBy = 'created', sort = 'desc' }: { eventId?: string; productId?: string; status?: string; limit?: number; orderBy?: string; sort?: 'asc' | 'desc' } = {}): Promise { try { let orders; if(eventId) { @@ -512,10 +537,10 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} }; } -export async function generateOrderToken(id) { - let order; +export async function generateOrderToken(id: string): Promise { + let order: Order | undefined; try { - [order] = (await sql` + [order] = (await sql` SELECT ${sql(orderColumns)} FROM orders WHERE id = ${id} @@ -535,14 +560,14 @@ export async function generateOrderToken(id) { orderSecret); } -export function validateOrderToken(token) { +export function validateOrderToken(token: string) { return jwt.verify(token, orderSecret, {issuer: 'mustachebash'}); } -export async function getOrder(id) { - let order; +export async function getOrder(id: string): Promise { + let order: OrderWithItems | undefined; try { - [order] = (await sql` + [order] = (await sql` SELECT ${sql(orderColumns.map(c => `o.${c}`))}, ${aggregateOrderItems} @@ -561,10 +586,10 @@ export async function getOrder(id) { return order; } -export async function getOrderTransfers(id) { - let transfers; +export async function getOrderTransfers(id: string): Promise { + let transfers: Order[]; try { - transfers = (await sql` + transfers = (await sql` SELECT ${sql(orderColumns.map(c => `o.${c}`))} FROM orders as o @@ -579,7 +604,7 @@ export async function getOrderTransfers(id) { } // Full order refund -export async function refundOrder(id) { +export async function refundOrder(id: string): Promise { let order; try { [order] = await sql` diff --git a/lib/services/products.ts b/lib/services/products.ts index 49c5727..348a595 100644 --- a/lib/services/products.ts +++ b/lib/services/products.ts @@ -39,24 +39,29 @@ const productColumns = [ 'meta' ]; -const convertPriceToNumber = (p: Record) => ({...p, ...(typeof p.price === 'string' ? {price: Number(p.price)} : {})}); +const convertPriceToNumber = (p: ProductRow): Product => ({...p, price: Number(p.price)}); type ProductType = 'ticket' | 'upgrade' | 'bundle-ticket' | 'accomodation'; -type AdmissionTier = 'general' | 'vip' | 'sponsor' | 'stachepass'; export type Product = { id: string; + status?: string; price: number; name: string; description: string; type: ProductType; maxQuantity: number | null; - eventId: string; - admissionTier: string; - targetProductId: string; - promo: boolean; + eventId?: string; + admissionTier?: string; + targetProductId?: string; + promo?: boolean; + created?: Date; + updated?: Date; + updatedBy?: string | null; meta: Record; }; +type ProductRow = Omit & { price: string }; + export async function createProduct({ price, name, description, type, maxQuantity, 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'); @@ -102,7 +107,7 @@ export async function createProduct({ price, name, description, type, maxQuantit } try { - const [createdProduct] = (await sql` + const [createdProduct] = (await sql` INSERT INTO products ${sql(product)} RETURNING ${sql(productColumns)} `).map(convertPriceToNumber); @@ -115,7 +120,7 @@ export async function createProduct({ price, name, description, type, maxQuantit export async function getProducts({eventId, type}: {eventId?: string; type?: string} = {}) { try { - const products = await sql` + const products = await sql` SELECT ${sql(productColumns)} FROM products WHERE true @@ -132,7 +137,7 @@ export async function getProducts({eventId, type}: {eventId?: string; type?: str export async function getProduct(id: string) { let product; try { - [product] = (await sql` + [product] = (await sql` SELECT ${sql(productColumns)} FROM products WHERE id = ${id} @@ -164,7 +169,7 @@ export async function updateProduct(id: string, updates: Record let product; try { - [product] = (await sql` + [product] = (await sql` UPDATE products SET ${sql(updates)}, updated = now() WHERE id = ${id} diff --git a/lib/services/promos.ts b/lib/services/promos.ts index 62c6104..5ab66e5 100644 --- a/lib/services/promos.ts +++ b/lib/services/promos.ts @@ -38,6 +38,12 @@ export type Promo = { meta: Record; }; +type PromoRow = Omit & { + price?: string; + percentDiscount?: string; + flatDiscount?: string; +}; + const promoColumns = [ 'id', 'created', @@ -55,11 +61,11 @@ const promoColumns = [ 'meta' ]; -const convertPriceAndDiscountsToNumbers = (p: Promo): Promo => ({ - ...p, - ...(typeof p.price === 'string' ? {price: Number(p.price)} : {}), - ...(typeof p.percentDiscount === 'string' ? {percentDiscount: Number(p.percentDiscount)} : {}), - ...(typeof p.flatDiscount === 'string' ? {flatDiscount: Number(p.flatDiscount)} : {}) +const convertPriceAndDiscountsToNumbers = ({ price, percentDiscount, flatDiscount, ...rest }: PromoRow): Promo => ({ + ...rest, + ...(price !== undefined ? {price: Number(price)} : {}), + ...(percentDiscount !== undefined ? {percentDiscount: Number(percentDiscount)} : {}), + ...(flatDiscount !== undefined ? {flatDiscount: Number(flatDiscount)} : {}) }); type PromoInput = { @@ -107,7 +113,7 @@ export async function createPromo({ price, flatDiscount, percentDiscount, maxUse } try { - const [createdPromo] = (await sql` + const [createdPromo] = (await sql` INSERT INTO promos ${sql(promo)} RETURNING ${sql(promoColumns)} `).map(convertPriceAndDiscountsToNumbers); @@ -122,7 +128,7 @@ export async function getPromos({ eventId }: {eventId?: string;} = {}) { try { let promos; if(eventId) { - promos = await sql` + promos = await sql` SELECT ${sql(promoColumns.map(c => `p.${c}`))} FROM promos as p JOIN products as pr @@ -130,7 +136,7 @@ export async function getPromos({ eventId }: {eventId?: string;} = {}) { WHERE pr.event_id = ${eventId} `; } else { - promos = await sql` + promos = await sql` SELECT ${sql(promoColumns)} FROM promos `; @@ -145,7 +151,7 @@ export async function getPromos({ eventId }: {eventId?: string;} = {}) { export async function getPromo(id: string) { let promo; try { - [promo] = (await sql` + [promo] = (await sql` SELECT ${sql(promoColumns)} FROM promos WHERE id = ${id} @@ -175,7 +181,7 @@ export async function updatePromo(id: string, updates: Record) let promo; try { - [promo] = (await sql` + [promo] = (await sql` UPDATE promos SET ${sql(updates)}, updated = now() WHERE id = ${id} diff --git a/lib/services/tickets.ts b/lib/services/tickets.ts index ab2a6ae..ac67b76 100644 --- a/lib/services/tickets.ts +++ b/lib/services/tickets.ts @@ -23,6 +23,60 @@ export class TicketsServiceError extends Error { } } +export type Ticket = { + id: string; + admissionTier: string; + eventId: string; + eventName: string; + eventDate: Date; + status: string; + firstName: string; + lastName: string; + qrPayload: string; +}; + +export type CustomerTicket = { + id: string; + customerId: string; + orderId: string; + orderCreated: Date; + admissionTier: string; + status: string; + checkInTime: Date | null; + eventId: string; + eventName: string; + eventDate: Date; + upgradeProductId: string | null; + upgradePrice: number | null; + upgradeName: string | null; + qrPayload: string; +}; + +export type CustomerAccommodation = { + customerId: string; + orderId: string; + orderCreated: Date; + productName: string; + eventId: string; + eventName: string; + eventDate: Date; +}; + +export type TicketInspection = { + id: string; + firstName: string; + lastName: string; + status: string; + orderId: string; + createdReason?: string; + admissionTier: string; + checkInTime: Date | null; + eventId: string; + eventName: string; + eventDate: Date; + eventStatus: string; +}; + // For now, ticket "seed" is fine to be used as plaintext since we can use it as // a revokable identifier, but are not rolling "live" tickets this year // ie, we aren't seeding a TOTP with it, and therefore it does not need to be a secret value. @@ -31,7 +85,7 @@ function generateQRPayload(ticketSeed: string) { return ticketSeed; } -export async function getOrderTickets(orderId: string) { +export async function getOrderTickets(orderId: string): Promise { let guests; try { guests = await sql` @@ -75,7 +129,7 @@ export async function getOrderTickets(orderId: string) { return tickets; } -export async function getCustomerActiveTicketsByOrderId(orderId: string) { +export async function getCustomerActiveTicketsByOrderId(orderId: string): Promise { let rows; try { rows = await sql` @@ -151,7 +205,7 @@ export async function getCustomerActiveTicketsByOrderId(orderId: string) { } } -export async function getCustomerActiveAccommodationsByOrderId(orderId: string) { +export async function getCustomerActiveAccommodationsByOrderId(orderId: string): Promise { let rows; try { rows = await sql` @@ -357,11 +411,11 @@ export async function transferTickets( }; } -export async function inspectTicket(ticketToken: string) { - let guest; +export async function inspectTicket(ticketToken: string): Promise { + let guest: TicketInspection | undefined; // For now, this is happening directly with ticket seeds try { - [guest] = await sql` + [guest] = await sql` SELECT g.id, g.first_name, @@ -389,11 +443,11 @@ export async function inspectTicket(ticketToken: string) { return guest; } -export async function checkInWithTicket(ticketToken: string, scannedBy: string) { - let guest; +export async function checkInWithTicket(ticketToken: string, scannedBy: string): Promise { + let guest: TicketInspection | undefined; // For now, this is happening directly with ticket seeds try { - [guest] = await sql` + [guest] = await sql` SELECT g.id, g.first_name, diff --git a/lib/services/transactions.ts b/lib/services/transactions.ts index c31da9e..32dbf91 100644 --- a/lib/services/transactions.ts +++ b/lib/services/transactions.ts @@ -14,7 +14,10 @@ const gateway = new braintree.BraintreeGateway({ }); class TransactionsServiceError extends Error { - constructor(message = 'An unknown error occured', code = 'UNKNOWN', context) { + code: string; + context: unknown; + + constructor(message = 'An unknown error occured', code = 'UNKNOWN', context?: unknown) { super(message); this.name = this.constructor.name; @@ -25,6 +28,21 @@ class TransactionsServiceError extends Error { } } +export type Transaction = { + id: string; + amount: number | null; + created: Date; + type: 'sale' | 'refund' | 'void'; + orderId: string; + processorTransactionId: string; + processorCreatedAt: Date; + processor: string; + parentTransactionId: string | null; + meta: Record; +}; + +type TransactionRow = Omit & { amount: string | null }; + const transactionColumns = [ 'id', 'amount', @@ -38,11 +56,14 @@ const transactionColumns = [ 'meta' ]; -const convertAmountToNumber = o => ({...o, ...(typeof o.amount === 'string' ? {amount: Number(o.amount)} : {})}); +const convertAmountToNumber = (t: TransactionRow): Transaction => ({ + ...t, + amount: t.amount !== null ? Number(t.amount) : null +}); -export async function getTransactions({ type, orderId, orderBy = 'created', limit, sort = 'desc' }) { +export async function getTransactions({ type, orderId, orderBy = 'created', limit, sort = 'desc' }: { type?: string; orderId?: string; orderBy?: string; limit?: number; sort?: 'asc' | 'desc' } = {}): Promise { try { - const transactions = await sql` + const transactions = await sql` SELECT ${sql(transactionColumns)} FROM transactions WHERE 1 = 1 @@ -58,10 +79,10 @@ export async function getTransactions({ type, orderId, orderBy = 'created', limi } } -export async function getTransaction(id) { - let transaction; +export async function getTransaction(id: string): Promise { + let transaction: Transaction | undefined; try { - [transaction] = (await sql` + [transaction] = (await sql` SELECT ${sql(transactionColumns)} FROM transactions WHERE id = ${id} @@ -75,10 +96,10 @@ export async function getTransaction(id) { return transaction; } -export async function getTransactionProcessorDetails(id) { - let transaction; +export async function getTransactionProcessorDetails(id: string) { + let transaction: { processorTransactionId: string } | undefined; try { - [transaction] = await sql` + [transaction] = await sql<{ processorTransactionId: string }[]>` SELECT processor_transaction_id FROM transactions WHERE id = ${id}