From 0414067d68ddd598ae8b177df4de48e3a98acdce Mon Sep 17 00:00:00 2001 From: Joe Furfaro Date: Tue, 2 Dec 2025 09:52:34 -0800 Subject: [PATCH] Added all missing types using Copilot and Opus 4.5 --- lib/services/auth.ts | 55 ++++++--- lib/services/customers.ts | 55 ++++++--- lib/services/email.ts | 13 +- lib/services/events.ts | 199 +++++++++++++++++++++++++++---- lib/services/guests.ts | 81 ++++++++++--- lib/services/orders.ts | 223 +++++++++++++++++++++++++++-------- lib/services/products.ts | 61 ++++++++-- lib/services/promos.ts | 26 ++-- lib/services/tickets.ts | 215 +++++++++++++++++++++++++++------ lib/services/transactions.ts | 56 +++++++-- 10 files changed, 793 insertions(+), 191 deletions(-) diff --git a/lib/services/auth.ts b/lib/services/auth.ts index 8d6583a..cdcb021 100644 --- a/lib/services/auth.ts +++ b/lib/services/auth.ts @@ -15,7 +15,7 @@ export type User = { username: string; displayName: string; role: string; - subClaim: string; + subClaim: string | null; }; class AuthServiceError extends Error { @@ -38,7 +38,7 @@ type AccessToken = { role: string; name: string; }; -function generateAccessToken(user: User) { +function generateAccessToken(user: User): string { return jwt.sign({ exp: Math.floor(Date.now()/1000) + (60*20), // In seconds, 20m expiration iss: 'mustachebash', @@ -49,7 +49,7 @@ function generateAccessToken(user: User) { config.jwt.secret); } -export function validateAccessToken(accessToken: string) { +export function validateAccessToken(accessToken: string): AccessToken { const tokenPayload = jwt.verify(accessToken, config.jwt.secret, {issuer: 'mustachebash'}); if( @@ -66,7 +66,7 @@ type RefreshToken = { jti: string; sub: string; }; -function generateRefreshToken(user: User, jti: string) { +function generateRefreshToken(user: User, jti: string): string { return jwt.sign({ exp: Math.floor(Date.now()/1000) + (60*60*24*30), // In seconds, 30d expiration iss: 'mustachebash', @@ -77,7 +77,7 @@ function generateRefreshToken(user: User, jti: string) { config.jwt.secret); } -function validateRefreshToken(refreshToken: string) { +function validateRefreshToken(refreshToken: string): RefreshToken { const tokenPayload = jwt.verify(refreshToken, config.jwt.secret, {issuer: 'mustachebash', audience: 'mustachebash-refresh'}); if( @@ -91,7 +91,12 @@ function validateRefreshToken(refreshToken: string) { return tokenPayload as RefreshToken; } -export async function authenticateGoogleUser(token: string, {userAgent, ip}: {userAgent: string, ip: string}) { +type AuthResult = { + accessToken: string; + refreshToken: string; +}; + +export async function authenticateGoogleUser(token: string, {userAgent, ip}: {userAgent: string, ip: string}): Promise { if(!token) throw new AuthServiceError('Missing token', 'UNAUTHORIZED'); let payload: TokenPayload | undefined, googleUserId: string | null; @@ -192,7 +197,11 @@ export async function authenticateGoogleUser(token: string, {userAgent, ip}: {us return {accessToken, refreshToken}; } -export async function refreshAccessToken(refreshToken: string, {userAgent, ip}: {userAgent: string, ip: string}) { +type RefreshTokenData = { + userId: string; +}; + +export async function refreshAccessToken(refreshToken: string, {userAgent, ip}: {userAgent: string, ip: string}): Promise { let sub: string, jti: string; try { ({ sub, jti } = validateRefreshToken(refreshToken)); @@ -200,7 +209,7 @@ export async function refreshAccessToken(refreshToken: string, {userAgent, ip}: throw new AuthServiceError('Invalid refresh token', 'UNAUTHORIZED'); } - let user; + let user: User | undefined; try { [user] = await sql` SELECT id, display_name, role, sub_claim @@ -211,9 +220,9 @@ export async function refreshAccessToken(refreshToken: string, {userAgent, ip}: throw new AuthServiceError('Failed to query for user', 'DB_ERROR', e); } - let refreshTokenData; + let refreshTokenData: RefreshTokenData | undefined; try { - [refreshTokenData] = await sql` + [refreshTokenData] = await sql` SELECT user_id FROM refresh_tokens WHERE id = ${jti} @@ -240,7 +249,7 @@ export async function refreshAccessToken(refreshToken: string, {userAgent, ip}: return generateAccessToken(user); } -export function checkScope(userRole: string, scopeRequired: string) { +export function checkScope(userRole: string, scopeRequired: string): boolean { const roles = [ 'root', 'god', @@ -252,13 +261,23 @@ export function checkScope(userRole: string, scopeRequired: string) { const userLevel = roles.indexOf(userRole); - return ~userLevel && userLevel <= roles.indexOf(scopeRequired); + return Boolean(~userLevel && userLevel <= roles.indexOf(scopeRequired)); } -export async function getUsers() { - let users; +type UserPublic = { + id: string; + username: string; + displayName: string; + role: string; + status: string; + created: Date; + updated: Date; +}; + +export async function getUsers(): Promise { + let users: UserPublic[]; try { - users = await sql` + users = await sql` SELECT id, username, display_name, role, status, created, updated FROM users `; @@ -269,10 +288,10 @@ export async function getUsers() { return users; } -export async function getUser(id: string) { - let user; +export async function getUser(id: string): Promise { + let user: UserPublic | undefined; try { - [user] = await sql` + [user] = await sql` SELECT id, username, display_name, role, status, created, updated FROM users WHERE id = ${id} diff --git a/lib/services/customers.ts b/lib/services/customers.ts index affefb9..a02fed3 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; @@ -32,8 +32,25 @@ const customerColumns = [ 'meta' ]; - -export async function createCustomer({ firstName, lastName, email, meta }) { +export type Customer = { + id: string; + email: string; + firstName: string; + lastName: string; + created: Date; + updated: Date; + updatedBy: string | null; + meta: Record; +}; + +type CreateCustomerInput = { + firstName: string; + lastName: string; + email: string; + meta?: Record; +}; + +export async function createCustomer({ firstName, lastName, email, meta }: CreateCustomerInput): Promise { 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(): 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,15 @@ export async function getCustomer(id) { return customer; } -export async function updateCustomer(id, updates) { +type UpdateCustomerInput = { + firstName?: string; + lastName?: string; + email?: string; + meta?: Record; + updatedBy?: string; +}; + +export async function updateCustomer(id: string, updates: UpdateCustomerInput): Promise { for(const u in updates) { // Update whitelist if(![ @@ -103,14 +128,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..e0eca6b 100644 --- a/lib/services/email.ts +++ b/lib/services/email.ts @@ -25,7 +25,14 @@ 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 = [] }) { +type UpsertEmailSubscriberInput = { + email: string; + firstName: string; + lastName: string; + tags?: string[]; +}; + +export async function upsertEmailSubscriber(listId: string, { email, firstName, lastName, tags = [] }: UpsertEmailSubscriberInput): Promise { const memberHash = md5(email.toLowerCase()); try { @@ -50,7 +57,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): void { mailgun.messages().send({ from: 'Mustache Bash Tickets ', to: guestFirstName + ' ' + guestLastName + ' <' + guestEmail + '> ', @@ -360,7 +367,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): void { 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..e8cf0b5 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; @@ -34,17 +37,43 @@ const eventColumns = [ 'meta' ]; -const convertNumericTypeToNumbers = e => ({ +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 EventRaw = Omit & { + alcoholRevenue: string | number | null; + budget: string | number | null; + foodRevenue: string | number | null; +}; + +const convertNumericTypeToNumbers = (e: EventRaw): 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)} : {}) + alcoholRevenue: typeof e.alcoholRevenue === 'string' ? Number(e.alcoholRevenue) : e.alcoholRevenue, + budget: typeof e.budget === 'string' ? Number(e.budget) : e.budget, + foodRevenue: typeof e.foodRevenue === 'string' ? Number(e.foodRevenue) : e.foodRevenue }); -export async function getEvents({ status }) { +type GetEventsQuery = { + status?: string; +}; + +export async function getEvents({ status }: GetEventsQuery = {}): Promise { try { - const events = await sql` + const events = await sql` SELECT ${sql(eventColumns)} FROM events ${status ? sql`WHERE status = ${status}` : sql``} @@ -56,10 +85,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 +102,21 @@ export async function getEvent(id) { return event; } -export async function createEvent(newEvent) { +type CreateEventInput = { + id?: string; + date: Date; + name: string; + openingSales?: Date; + salesEnabled?: boolean; + maxCapacity?: number; + budget?: number; + alcoholRevenue?: number; + foodRevenue?: number; + status?: string; + meta?: Record; +}; + +export async function createEvent(newEvent: CreateEventInput): Promise { for(const u in newEvent) { // Fields whitelist if(![ @@ -108,7 +151,7 @@ export async function createEvent(newEvent) { }; try { - const [createdEvent] = (await sql` + const [createdEvent] = (await sql` INSERT INTO events ${sql(event)} RETURNING ${sql(eventColumns)} `).map(convertNumericTypeToNumbers); @@ -119,7 +162,21 @@ export async function createEvent(newEvent) { } } -export async function updateEvent(id, updates) { +type UpdateEventInput = { + date?: Date; + name?: string; + openingSales?: Date; + salesEnabled?: boolean; + maxCapacity?: number; + alcoholRevenue?: number; + foodRevenue?: number; + status?: string; + budget?: number; + meta?: Record; + updatedBy?: string; +}; + +export async function updateEvent(id: string, updates: UpdateEventInput): Promise { for(const u in updates) { // Update whitelist if(![ @@ -139,9 +196,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 +213,33 @@ export async function updateEvent(id, updates) { return event; } +type EventProduct = { + id: string; + name: string; + description: string; + price: number; + 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: EventProduct[]; +}; + // !!! 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 +280,28 @@ export async function getEventSettings(id) { return event; } -export async function getEventSummary(id) { +type EventSummary = { + eventId: string; + totalGuests: number; + totalPaidGuests: number; + totalCompedGuests: number; + totalVipGuests: number; + guestsToday: number; + checkedIn: number; +}; + +type EventSummaryRaw = Omit & { + totalGuests: string; + totalPaidGuests: string; + totalCompedGuests: string; + totalVipGuests: string; + guestsToday: string; + checkedIn: string; +}; + +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, @@ -236,9 +334,38 @@ export async function getEventSummary(id) { } } -export async function getEventExtendedStats(id) { +type SalesTier = { + name: string; + quantity: number; + price: number; +}; + +type EventExtendedStats = { + eventId: string; + eventBudget: number; + eventMaxCapacity: number | null; + alcoholRevenue: number; + foodRevenue: number; + salesTiers: SalesTier[]; + totalRevenue: number; + totalPromoRevenue: number; + revenueToday: number; + promoRevenueToday: number; +}; + +type EventExtendedStatsRaw = Omit & { + eventBudget: string; + alcoholRevenue: string; + foodRevenue: string; + totalRevenue: string; + totalPromoRevenue: string; + revenueToday: string; + promoRevenueToday: string; +}; + +export async function getEventExtendedStats(id: string): Promise { try { - const [extendedStats] = (await sql` + const [extendedStats] = (await sql` WITH ProductAggregation AS ( SELECT p.event_id, @@ -325,9 +452,19 @@ export async function getEventExtendedStats(id) { } } -export async function getEventDailyTickets(id: string) { +type DailyTicket = { + date: Date; + tickets: number; +}; + +type DailyTicketRaw = { + date: Date; + tickets: 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 +488,12 @@ export async function getEventDailyTickets(id: string) { } } -export async function getOpeningSales(id: string) { +type OpeningSale = { + minuteCreated: string; + tickets: number; +}; + +export async function getOpeningSales(id: string): Promise { try { const chart = (await sql<{minuteCreated: string; tickets: string}[]>` SELECT @@ -381,7 +523,12 @@ export async function getOpeningSales(id: string) { } } -export async function getEventCheckins(id: string) { +type EventCheckin = { + minuteCheckedIn: string; + checkins: number; +}; + +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..4d893d2 100644 --- a/lib/services/guests.ts +++ b/lib/services/guests.ts @@ -41,7 +41,35 @@ const guestColumns = [ 'meta' ]; -export async function createGuest({ firstName, lastName, createdReason, orderId = null, createdBy = null, eventId, admissionTier, meta }) { +export type Guest = { + id: string; + firstName: string; + lastName: string; + admissionTier: string; + created: Date; + updated: Date; + updatedBy: string | null; + createdBy: string | null; + createdReason: string; + status: string; + checkInTime: Date | null; + orderId: string | null; + eventId: string; + meta: Record; +}; + +type CreateGuestInput = { + firstName: string; + lastName: string; + createdReason: string; + 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 }: CreateGuestInput): 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 GetGuestsQuery = { + limit?: number | string; + eventId?: string; + admissionTier?: string; + createdReason?: string; + orderBy?: string; + sort?: string; +}; + +export async function getGuests({ limit, eventId, admissionTier, createdReason, orderBy = 'created', sort = 'desc' }: GetGuestsQuery = {}): 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,21 @@ export async function getGuest(id) { return guest; } -export async function updateGuest(id, updates) { +type UpdateGuestInput = { + status?: string; + firstName?: string; + lastName?: string; + updatedBy?: string; + meta?: Record; + admissionTier?: string; + checkInTime?: ReturnType | null; +}; + +type MinimumAdmissionTier = { + admissionTier: string; +}; + +export async function updateGuest(id: string, updates: UpdateGuestInput): 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 +180,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: MinimumAdmissionTier | undefined; if (updates.admissionTier) { try { - [minimumAdmissionTier] = await sql` + [minimumAdmissionTier] = await sql` SELECT p.admission_tier FROM guests AS g LEFT JOIN order_items AS oi @@ -158,9 +209,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 +226,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..a6acc0f 100644 --- a/lib/services/orders.ts +++ b/lib/services/orders.ts @@ -47,6 +47,37 @@ const orderColumns = [ 'meta' ]; +export type Order = { + id: string; + amount: number; + created: Date; + customerId: string; + promoId: string | null; + parentOrderId: string | null; + status: string; + meta: Record; +}; + +type OrderWithItems = Order & { + items: OrderItem[]; +}; + +type OrderWithCustomer = Order & { + items: OrderItem[]; + customerEmail?: string; + customerFirstName?: string; + customerLastName?: string; +}; + +type OrderItem = { + productId: string; + quantity: number; +}; + +type OrderRaw = Omit & { + amount: string | number; +}; + const aggregateOrderItems = sql` ARRAY_AGG( JSON_BUILD_OBJECT( @@ -56,11 +87,20 @@ const aggregateOrderItems = sql` ) as items `; -const convertAmountToNumber = o => ({...o, ...(typeof o.amount === 'string' ? {amount: Number(o.amount)} : {})}); +const convertAmountToNumber = (o: OrderRaw): Order => ({...o, ...(typeof o.amount === 'string' ? {amount: Number(o.amount)} : {})} as Order); -export async function getOrders({ eventId, productId, status, limit, orderBy = 'created', sort = 'desc' }) { +type GetOrdersQuery = { + eventId?: string; + productId?: string; + status?: string; + limit?: number | string; + orderBy?: string; + sort?: string; +}; + +export async function getOrders({ eventId, productId, status, limit, orderBy = 'created', sort = 'desc' }: GetOrdersQuery = {}): Promise { try { - let orders; + let orders: (OrderRaw & { items: OrderItem[]; customerEmail?: string; customerFirstName?: string; customerLastName?: string })[]; if(eventId) { orders = await sql` WITH FilteredOrders AS ( @@ -129,12 +169,63 @@ export async function getOrders({ eventId, productId, status, limit, orderBy = ' } // https://github.com/porsager/postgres#numbers-bigint-numeric - return orders.map(convertAmountToNumber); + return orders.map(o => ({...convertAmountToNumber(o), items: o.items, customerEmail: o.customerEmail, customerFirstName: o.customerFirstName, customerLastName: o.customerLastName})); } catch(e) { throw new OrdersServiceError('Could not query orders', 'UNKNOWN', e); } } +type ProductWithTotalSold = Product & { + totalSold: number; +}; + +type ProductWithTotalSoldRaw = Omit & { + totalSold: string; + price: string | number; +}; + +type Customer = { + id: string; + firstName: string; + lastName: string; + email: string; +}; + +type TargetGuest = { + id: string; + admissionTier: string; + status: string; +}; + +type OrderDetail = { + productId: string; + quantity: number; + product: ProductWithTotalSold; + bundledProduct: Product | undefined; +}; + +type ProductToArchive = { + id: string; + eventId: string; + nextTierProductId: string | null; +}; + +type Transaction = { + id: string; + amount: number | null; + processor: string; + processorTransactionId: string; + processorCreatedAt: Date; + type: string; + orderId: string; +}; + +type CreateOrderResult = { + order: Order; + transaction: Transaction; + customer: Customer; +}; + type OrderInput = { paymentMethodNonce: string; @@ -144,20 +235,21 @@ type OrderInput = { }[]; customer: { - firstName: string; - lastName: string; - email: string; + firstName?: string; + lastName?: string; + email?: string; }; promoId?: string; targetGuestId?: string; }; -export async function createOrder({ paymentMethodNonce, cart = [], customer = {}, promoId, targetGuestId }: OrderInput) { + +export async function createOrder({ paymentMethodNonce, cart = [], customer = {}, promoId, targetGuestId }: OrderInput): Promise { // First do some validation if (!cart.length || !paymentMethodNonce || !customer.firstName || !customer.lastName || !customer.email) throw new OrdersServiceError('Invalid payment parameters', 'INVALID'); - const products = (await sql<(Product & {totalSold: string})[]>` + const products = (await sql` SELECT p.*, COALESCE(SUM(oi.quantity), 0) as total_sold FROM products as p LEFT JOIN order_items as oi @@ -169,10 +261,10 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} ...p, ...(typeof p.price === 'string' ? {price: Number(p.price)} : {}), ...(typeof p.totalSold === 'string' ? {totalSold: Number(p.totalSold)} : {}) - })); + })) as ProductWithTotalSold[]; // Fetch all matching bundled products - for now it's just tickets - const bundledProducts = (await sql` + const bundledProducts = (await sql<(Product & { price: string | number })[]>` SELECT p.* FROM products as p WHERE p.type = 'bundle-ticket' @@ -183,9 +275,15 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} ...(typeof p.price === 'string' ? {price: Number(p.price)} : {}) })); - let promo: Promo; +type PromoRaw = Omit & { + price: string | number | undefined; + percentDiscount: string | number | undefined; + flatDiscount: string | number | undefined; +}; + + let promo: Promo | undefined; if(promoId) { - [promo] = (await sql` + [promo] = (await sql` SELECT * FROM promos WHERE id = ${promoId} @@ -195,13 +293,14 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} ...(typeof p.price === 'string' ? {price: Number(p.price)} : {}), ...(typeof p.percentDiscount === 'string' ? {percentDiscount: Number(p.percentDiscount)} : {}), ...(typeof p.flatDiscount === 'string' ? {flatDiscount: Number(p.flatDiscount)} : {}) - })); + })) as Promo[]; - const [promoUses] = (await sql<{promoUses: number}[]>` + const [promoUsesResult] = await sql<{promoUses: string}[]>` SELECT count(id) as promo_uses FROM orders WHERE promo_id = ${promoId} - `).map(pu => (pu.promoUses)); + `; + const promoUses = Number(promoUsesResult.promoUses); if(!promo) throw new OrdersServiceError('Invalid promo code', 'INVALID'); if(typeof promo.maxUses === 'number' && promo.maxUses <= promoUses) throw new OrdersServiceError('Promo code no longer available', 'GONE'); @@ -209,11 +308,11 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} // targetGuestId is actually what the user sees as a "ticket", so the 1:1 restriction remains valid per upgrade product, however // the API should maybe allow for an array of objects mapping guestIds to upgrade productIds so a user may upgrade multiple tickets at once - let targetGuest; + let targetGuest: TargetGuest | undefined; if(targetGuestId) { if(products.length > 1 || products[0].type !== 'upgrade') throw new OrdersServiceError('Missing/incorrect Upgrade Product', 'INVALID'); - [targetGuest] = await sql` + [targetGuest] = await sql` SELECT * FROM guests WHERE id = ${targetGuestId} @@ -227,9 +326,9 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} // if(!products.length || products.length !== cart.length) throw new OrdersServiceError('Empty/Invalid items in cart', 'INVALID'); if(!products.length || products.length !== cart.length) throw new OrdersServiceError('Unavailable items in cart', 'GONE'); - const productsToArchive = []; - const orderDetails = cart.map(i => { - const product = products.find(p => p.id === i.productId), + const productsToArchive: ProductToArchive[] = []; + const orderDetails: OrderDetail[] = cart.map(i => { + const product = products.find(p => p.id === i.productId) as ProductWithTotalSold, bundledProduct = bundledProducts.find(p => p.targetProductId === i.productId), remaining = typeof product.maxQuantity === 'number' && product.maxQuantity > 0 ? product.maxQuantity - product.totalSold : null; @@ -238,6 +337,7 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} promo && promo.productId === i.productId && promo.type === 'single-use' && + promo.productQuantity && promo.productQuantity < i.quantity ) throw new OrdersServiceError('Promo quantity exceeded', 'INVALID'); @@ -261,7 +361,7 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} productsToArchive.push({ id: product.id, eventId: product.eventId, - nextTierProductId: product.meta.nextTierProductId ?? null + nextTierProductId: (product.meta as { nextTierProductId?: string })?.nextTierProductId ?? null }); } } @@ -339,8 +439,8 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} // Find or insert a customer record immediately before attempting charge const normalizedEmail = customer.email.toLowerCase().trim(); - let dbCustomer; - [dbCustomer] = await sql` + let dbCustomer: Customer | undefined; + [dbCustomer] = await sql` SELECT * FROM customers WHERE email = ${normalizedEmail} @@ -354,7 +454,7 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} email: normalizedEmail }; - [dbCustomer] = await sql` + [dbCustomer] = await sql` INSERT INTO customers ${sql(newCustomer)} RETURNING * `; @@ -388,14 +488,16 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} btAmount = Number(braintreeTransaction.amount); // Package the order, order_item, and transaction objects - const order = { + const order: Omit = { id: orderId, customerId: dbCustomer.id, status: 'complete', - amount + amount, + promoId: promoId ?? null, + parentOrderId: null }; - const orderItems = cart.map(i => ({ + const orderItems: { productId: string; quantity: number; orderId: string }[] = cart.map(i => ({ productId: i.productId, quantity: i.quantity, orderId @@ -412,7 +514,7 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} } }); - const transaction = { + const transaction: Transaction = { id: uuidV4(), amount: !Number.isNaN(btAmount) ? btAmount : null, processor: 'braintree', @@ -506,16 +608,23 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} }); return { - order, + order: { ...order, created: new Date(), meta: {} } as Order, transaction, customer: dbCustomer }; } -export async function generateOrderToken(id) { - let order; +type OrderTokenPayload = { + iss: string; + aud: string; + iat: number; + sub: string; +}; + +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} @@ -529,20 +638,20 @@ export async function generateOrderToken(id) { return jwt.sign({ iss: 'mustachebash', aud: 'tickets', - iat: Math.round(order.created / 1000), + iat: Math.round(order.created.getTime() / 1000), sub: id }, orderSecret); } -export function validateOrderToken(token) { - return jwt.verify(token, orderSecret, {issuer: 'mustachebash'}); +export function validateOrderToken(token: string): OrderTokenPayload { + return jwt.verify(token, orderSecret, {issuer: 'mustachebash'}) as OrderTokenPayload; } -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<(OrderRaw & { items: OrderItem[] })[]>` SELECT ${sql(orderColumns.map(c => `o.${c}`))}, ${aggregateOrderItems} @@ -551,7 +660,7 @@ export async function getOrder(id) { ON o.id = i.order_id WHERE id = ${id} GROUP BY o.id - `).map(convertAmountToNumber); + `).map(o => ({...convertAmountToNumber(o), items: o.items})); } catch(e) { throw new OrdersServiceError('Could not query orders', 'UNKNOWN', e); } @@ -561,10 +670,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,10 +688,30 @@ export async function getOrderTransfers(id) { } // Full order refund -export async function refundOrder(id) { - let order; +type OrderForRefund = { + orderStatus: string; + transactionId: string; + transactionType: string; + processorTransactionId: string; + processor: string; + parentTransactionId: string | null; +}; + +type NewTransaction = { + id: string; + orderId: string; + processor: string; + parentTransactionId: string; + type?: string; + processorTransactionId?: string; + processorCreatedAt?: Date; + amount?: number; +}; + +export async function refundOrder(id: string): Promise { + let order: OrderForRefund | undefined; try { - [order] = await sql` + [order] = await sql` SELECT o.status AS order_status, t.id AS transaction_id, @@ -603,7 +732,7 @@ export async function refundOrder(id) { if (!order) throw new OrdersServiceError('Order not found', 'NOT_FOUND'); if (order.orderStatus !== 'complete') throw new OrdersServiceError(`Cannot refund order with status: ${order.orderStatus}`, 'REFUND_NOT_ALLOWED'); - const newTransaction = { + const newTransaction: NewTransaction = { id: uuidV4(), orderId: id, processor: order.processor, diff --git a/lib/services/products.ts b/lib/services/products.ts index 49c5727..ffde8bb 100644 --- a/lib/services/products.ts +++ b/lib/services/products.ts @@ -39,10 +39,13 @@ const productColumns = [ 'meta' ]; -const convertPriceToNumber = (p: Record) => ({...p, ...(typeof p.price === 'string' ? {price: Number(p.price)} : {})}); +type ProductRaw = Omit & { + price: string | number; +}; + +const convertPriceToNumber = (p: ProductRaw): Product => ({...p, ...(typeof p.price === 'string' ? {price: Number(p.price)} : {})} as Product); type ProductType = 'ticket' | 'upgrade' | 'bundle-ticket' | 'accomodation'; -type AdmissionTier = 'general' | 'vip' | 'sponsor' | 'stachepass'; export type Product = { id: string; price: number; @@ -54,17 +57,34 @@ export type Product = { admissionTier: string; targetProductId: string; promo: boolean; + status: string; + created: Date; + updated: Date; + updatedBy: string | null; meta: Record; }; -export async function createProduct({ price, name, description, type, maxQuantity, eventId, admissionTier, targetProductId, promo, meta }: Omit) { +type CreateProductInput = { + price: number; + name: string; + description: string; + type: ProductType; + maxQuantity?: number | null; + eventId?: string; + admissionTier?: string; + targetProductId?: string; + promo?: boolean; + meta?: Record; +}; + +export async function createProduct({ price, name, description, type, maxQuantity, eventId, admissionTier, targetProductId, promo, meta }: CreateProductInput): Promise { 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'); if(type === 'upgrade' && (!targetProductId || !admissionTier)) throw new ProductsServiceError('No product target set for ticket upgrade', 'INVALID'); if(type === 'bundle-ticket' && (!eventId || !targetProductId || !admissionTier)) throw new ProductsServiceError('No product target set for bundle ticket', 'INVALID'); - const product: Product = { + const product: Partial = { id: uuidV4(), price, name, @@ -102,7 +122,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); @@ -113,9 +133,14 @@ export async function createProduct({ price, name, description, type, maxQuantit } } -export async function getProducts({eventId, type}: {eventId?: string; type?: string} = {}) { +type GetProductsQuery = { + eventId?: string; + type?: string; +}; + +export async function getProducts({eventId, type}: GetProductsQuery = {}): Promise { try { - const products = await sql` + const products = await sql` SELECT ${sql(productColumns)} FROM products WHERE true @@ -129,10 +154,10 @@ export async function getProducts({eventId, type}: {eventId?: string; type?: str } } -export async function getProduct(id: string) { - let product; +export async function getProduct(id: string): Promise { + let product: Product | undefined; try { - [product] = (await sql` + [product] = (await sql` SELECT ${sql(productColumns)} FROM products WHERE id = ${id} @@ -146,7 +171,17 @@ export async function getProduct(id: string) { return product; } -export async function updateProduct(id: string, updates: Record) { +type UpdateProductInput = { + name?: string; + price?: number; + description?: string; + status?: string; + maxQuantity?: number | null; + meta?: Record; + updatedBy?: string; +}; + +export async function updateProduct(id: string, updates: UpdateProductInput): Promise { for(const u in updates) { // Update whitelist if(![ @@ -162,9 +197,9 @@ export async function updateProduct(id: string, updates: Record if(Object.keys(updates).length === 1 && updates.updatedBy) throw new ProductsServiceError('Invalid product data', 'INVALID'); - let product; + let product: Product | undefined; 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..9e28301 100644 --- a/lib/services/promos.ts +++ b/lib/services/promos.ts @@ -75,7 +75,7 @@ type PromoInput = { createdBy: string; }; type PromoInsert = Omit; -export async function createPromo({ price, flatDiscount, percentDiscount, maxUses, type, productId, productQuantity = 1, recipientName, meta, createdBy }: PromoInput) { +export async function createPromo({ price, flatDiscount, percentDiscount, maxUses, type, productId, productQuantity = 1, recipientName, meta, createdBy }: PromoInput): Promise { if(!productId || !type) throw new PromoServiceError('Missing promo data', 'INVALID'); if(type === 'single-use') { if(typeof productQuantity !== 'number' || productQuantity < 1 || productQuantity > 5) throw new PromoServiceError('Invalid product quantity for single-use promo', 'INVALID'); @@ -118,9 +118,13 @@ export async function createPromo({ price, flatDiscount, percentDiscount, maxUse } } -export async function getPromos({ eventId }: {eventId?: string;} = {}) { +type GetPromosQuery = { + eventId?: string; +}; + +export async function getPromos({ eventId }: GetPromosQuery = {}): Promise { try { - let promos; + let promos: Promo[]; if(eventId) { promos = await sql` SELECT ${sql(promoColumns.map(c => `p.${c}`))} @@ -142,8 +146,8 @@ export async function getPromos({ eventId }: {eventId?: string;} = {}) { } } -export async function getPromo(id: string) { - let promo; +export async function getPromo(id: string): Promise { + let promo: Promo | undefined; try { [promo] = (await sql` SELECT ${sql(promoColumns)} @@ -159,7 +163,15 @@ export async function getPromo(id: string) { return promo; } -export async function updatePromo(id: string, updates: Record) { +type UpdatePromoInput = { + recipientName?: string; + price?: number; + status?: string; + meta?: Record; + updatedBy?: string; +}; + +export async function updatePromo(id: string, updates: UpdatePromoInput): Promise { for(const u in updates) { // Update whitelist if(![ @@ -173,7 +185,7 @@ export async function updatePromo(id: string, updates: Record) if(Object.keys(updates).length === 1 && updates.updatedBy) throw new PromoServiceError('Invalid promo data', 'INVALID'); - let promo; + let promo: Promo | undefined; try { [promo] = (await sql` UPDATE promos diff --git a/lib/services/tickets.ts b/lib/services/tickets.ts index ab2a6ae..391cf9a 100644 --- a/lib/services/tickets.ts +++ b/lib/services/tickets.ts @@ -27,14 +27,38 @@ export class TicketsServiceError extends Error { // 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. // This keeps the QR payload very short, and much quicker for scanning (both ease of reading and input time) -function generateQRPayload(ticketSeed: string) { +function generateQRPayload(ticketSeed: string): string { return ticketSeed; } -export async function getOrderTickets(orderId: string) { - let guests; +type GuestForTicket = { + id: string; + admissionTier: string; + eventId: string; + eventName: string; + eventDate: Date; + ticketSeed: string; + status: string; + firstName: string; + lastName: string; +}; + +type Ticket = { + id: string; + admissionTier: string; + eventId: string; + eventName: string; + eventDate: Date; + status: string; + firstName: string; + lastName: string; + qrPayload: string; +}; + +export async function getOrderTickets(orderId: string): Promise { + let guests: GuestForTicket[]; try { - guests = await sql` + guests = await sql` SELECT g.id, g.admission_tier, @@ -55,7 +79,7 @@ export async function getOrderTickets(orderId: string) { } // Inject the QR Codes - const tickets = []; + const tickets: Ticket[] = []; for (const guest of guests) { const qrPayload = generateQRPayload(guest.ticketSeed); @@ -75,10 +99,44 @@ export async function getOrderTickets(orderId: string) { return tickets; } -export async function getCustomerActiveTicketsByOrderId(orderId: string) { - let rows; +type CustomerTicketRow = { + orderCreated: Date; + customerId: string; + guestId: string; + guestStatus: string; + guestCheckInTime: Date | null; + guestAdmissionTier: string; + guestTicketSeed: string; + guestOrderId: string; + eventId: string; + eventName: string; + eventDate: Date; + upgradeProductId: string | null; + upgradePrice: number | null; + upgradeName: string | null; +}; + +type CustomerActiveTicket = { + 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 async function getCustomerActiveTicketsByOrderId(orderId: string): Promise { + let rows: CustomerTicketRow[]; try { - rows = await sql` + rows = await sql` SELECT DISTINCT o.created AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles' as order_created, o.customer_id, @@ -120,7 +178,7 @@ export async function getCustomerActiveTicketsByOrderId(orderId: string) { `; // Inject the QR Codes - const tickets = []; + const tickets: CustomerActiveTicket[] = []; for (const row of rows) { const qrPayload = generateQRPayload(row.guestTicketSeed); @@ -151,10 +209,30 @@ export async function getCustomerActiveTicketsByOrderId(orderId: string) { } } -export async function getCustomerActiveAccommodationsByOrderId(orderId: string) { - let rows; +type AccommodationRow = { + orderCreated: Date; + orderId: string; + customerId: string; + productName: string; + eventId: string; + eventName: string; + eventDate: Date; +}; + +type CustomerAccommodation = { + customerId: string; + orderId: string; + orderCreated: Date; + productName: string; + eventId: string; + eventName: string; + eventDate: Date; +}; + +export async function getCustomerActiveAccommodationsByOrderId(orderId: string): Promise { + let rows: AccommodationRow[]; try { - rows = await sql` + rows = await sql` SELECT DISTINCT o.created AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles' as order_created, o.id as order_id, @@ -181,7 +259,7 @@ export async function getCustomerActiveAccommodationsByOrderId(orderId: string) `; // Inject the QR Codes - const accomodations = []; + const accomodations: CustomerAccommodation[] = []; for (const row of rows) { accomodations.push({ customerId: row.customerId, @@ -218,30 +296,64 @@ export async function getCustomerActiveAccommodationsByOrderId(orderId: string) * are merely records of "guest" transfers * - The transferee will be upserted into customers, which means the original customer will need to input first/last/email */ +type TransferInput = { + transferee: { + email: string; + firstName: string; + lastName: string; + }; + guestIds: string[]; +}; + +type TransferOrder = { + id: string; + parentOrderId: string; + status: string; + amount: number; + customerId: string; +}; + +type TransferResult = { + transferee: Customer; + order: TransferOrder; +}; + +type Customer = { + id: string; + firstName: string; + lastName: string; + email: string; +}; + +type OrderForTransfer = { + id: string; + status: string; +}; + +type GuestForTransfer = { + id: string; + status: string; + eventId: string; + admissionTier: string; +}; + export async function transferTickets( orderId: string, { transferee, guestIds - }: { - transferee: { - email: string; - firstName: string; - lastName: string; - }; - guestIds: string[]; - } -) { + }: TransferInput +): Promise { if(!transferee) throw new TicketsServiceError('No transferee specified', 'INVALID'); if(!guestIds?.length) throw new TicketsServiceError('No tickets specified', 'INVALID'); - let order; + let order: OrderForTransfer | undefined; try { - [order] = (await sql` + [order] = await sql` SELECT * FROM orders WHERE id = ${orderId} - `); + `; } catch(e) { throw new TicketsServiceError('Could not query orders', 'UNKNOWN', e); } @@ -249,9 +361,9 @@ export async function transferTickets( if(!order) throw new TicketsServiceError('Order not found', 'NOT_FOUND'); if(order.status === 'canceled') throw new TicketsServiceError('Cannot transfer this order', 'NOT_PERMITTED'); - let originalGuests; + let originalGuests: GuestForTransfer[]; try { - originalGuests = await sql` + originalGuests = await sql` SELECT * FROM guests WHERE order_id = ${orderId} @@ -266,9 +378,9 @@ export async function transferTickets( // Find or insert a customer record for the transferee const normalizedEmail = transferee.email.toLowerCase().trim(); - let dbCustomer; + let dbCustomer: Customer | undefined; try { - [dbCustomer] = await sql` + [dbCustomer] = await sql` SELECT * FROM customers WHERE email = ${normalizedEmail} @@ -282,7 +394,7 @@ export async function transferTickets( email: normalizedEmail }; - [dbCustomer] = await sql` + [dbCustomer] = await sql` INSERT INTO customers ${sql(newCustomer)} RETURNING * `; @@ -294,7 +406,7 @@ export async function transferTickets( // Create a new order for 0 dollars, create guests and tickets // Package the order object const transfereeOrderId = uuidV4(), - transfereeOrder = { + transfereeOrder: TransferOrder = { id: transfereeOrderId, parentOrderId: order.id, status: 'complete', @@ -357,11 +469,26 @@ export async function transferTickets( }; } -export async function inspectTicket(ticketToken: string) { - let guest; +type GuestInspection = { + 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; +}; + +export async function inspectTicket(ticketToken: string): Promise { + let guest: GuestInspection | 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 +516,25 @@ export async function inspectTicket(ticketToken: string) { return guest; } -export async function checkInWithTicket(ticketToken: string, scannedBy: string) { - let guest; +type GuestCheckIn = { + id: string; + firstName: string; + lastName: string; + status: string; + orderId: string; + admissionTier: string; + checkInTime: Date | null; + eventId: string; + eventName: string; + eventDate: Date; + eventStatus: string; +}; + +export async function checkInWithTicket(ticketToken: string, scannedBy: string): Promise { + let guest: GuestCheckIn | 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..7e28c4e 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; @@ -38,11 +41,36 @@ const transactionColumns = [ 'meta' ]; -const convertAmountToNumber = o => ({...o, ...(typeof o.amount === 'string' ? {amount: Number(o.amount)} : {})}); +export type Transaction = { + id: string; + amount: number; + created: Date; + type: string; + orderId: string; + processorTransactionId: string; + processorCreatedAt: Date; + processor: string; + parentTransactionId: string | null; + meta: Record; +}; + +type TransactionRaw = Omit & { + amount: string | number; +}; + +const convertAmountToNumber = (o: TransactionRaw): Transaction => ({...o, ...(typeof o.amount === 'string' ? {amount: Number(o.amount)} : {})} as Transaction); -export async function getTransactions({ type, orderId, orderBy = 'created', limit, sort = 'desc' }) { +type GetTransactionsQuery = { + type?: string; + orderId?: string; + orderBy?: string; + limit?: number | string; + sort?: string; +}; + +export async function getTransactions({ type, orderId, orderBy = 'created', limit, sort = 'desc' }: GetTransactionsQuery = {}): Promise { try { - const transactions = await sql` + const transactions = await sql` SELECT ${sql(transactionColumns)} FROM transactions WHERE 1 = 1 @@ -58,10 +86,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 +103,18 @@ export async function getTransaction(id) { return transaction; } -export async function getTransactionProcessorDetails(id) { - let transaction; +type TransactionProcessorLookup = { + processorTransactionId: string; +}; + +type ProcessorDetails = braintree.Transaction & { + merchantId: string; +}; + +export async function getTransactionProcessorDetails(id: string): Promise { + let transaction: TransactionProcessorLookup | undefined; try { - [transaction] = await sql` + [transaction] = await sql` SELECT processor_transaction_id FROM transactions WHERE id = ${id}