diff --git a/lib/services/auth.ts b/lib/services/auth.ts index 8d6583a..973051e 100644 --- a/lib/services/auth.ts +++ b/lib/services/auth.ts @@ -10,12 +10,28 @@ import * as config from '../config.js'; const googleAuthClient = new OAuth2Client(); +type UserRole = 'root' | 'god' | 'admin' | 'write' | 'read' | 'doorman'; + export type User = { id: string; username: string; displayName: string; - role: string; - subClaim: string; + role: UserRole; + subClaim: string | null; +}; + +type UserRow = { + id: string; + username: string; + displayName: string; + role: UserRole; + status: string; + created: Date; + updated: Date; +}; + +type RefreshTokenRow = { + userId: string; }; class AuthServiceError extends Error { @@ -211,9 +227,9 @@ export async function refreshAccessToken(refreshToken: string, {userAgent, ip}: throw new AuthServiceError('Failed to query for user', 'DB_ERROR', e); } - let refreshTokenData; + let refreshTokenData: RefreshTokenRow | undefined; try { - [refreshTokenData] = await sql` + [refreshTokenData] = await sql` SELECT user_id FROM refresh_tokens WHERE id = ${jti} @@ -255,10 +271,10 @@ export function checkScope(userRole: string, scopeRequired: string) { return ~userLevel && userLevel <= roles.indexOf(scopeRequired); } -export async function getUsers() { - let users; +export async function getUsers(): Promise { + let users: UserRow[]; try { - users = await sql` + users = await sql` SELECT id, username, display_name, role, status, created, updated FROM users `; @@ -269,10 +285,10 @@ export async function getUsers() { return users; } -export async function getUser(id: string) { - let user; +export async function getUser(id: string): Promise { + let user: UserRow | 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..fae6aa4 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,33 @@ 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; +}; + +type CustomerInput = { + firstName: string; + lastName: string; + email: string; + meta?: Record; +}; + +type CustomerUpdate = { + firstName?: string; + lastName?: string; + email?: string; + meta?: Record; + updatedBy?: string; + status?: string; +}; + const customerColumns = [ 'id', 'email', @@ -33,7 +60,7 @@ const customerColumns = [ ]; -export async function createCustomer({ firstName, lastName, email, meta }) { +export async function createCustomer({ firstName, lastName, email, meta }: CustomerInput): Promise { if(!firstName || !lastName || !email) throw new CustomerServiceError('Missing customer data', 'INVALID'); if(!/.+@.+\..{2,}/.test(email)) throw new CustomerServiceError('Invalid email', 'INVALID'); @@ -48,10 +75,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 +86,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 +99,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 +116,7 @@ export async function getCustomer(id) { return customer; } -export async function updateCustomer(id, updates) { +export async function updateCustomer(id: string, updates: CustomerUpdate): Promise { for(const u in updates) { // Update whitelist if(![ @@ -103,14 +130,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..ff112e5 100644 --- a/lib/services/email.ts +++ b/lib/services/email.ts @@ -12,7 +12,14 @@ 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): string => crypto.createHash('md5').update(string).digest('hex'); + +type EmailSubscriberInput = { + email: string; + firstName: string; + lastName: string; + tags?: string[]; +}; /* eslint-disable max-len */ /** @@ -25,7 +32,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 = [] }: EmailSubscriberInput): 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..7c0182a 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,96 @@ class EventsServiceError extends Error { } } +type EventStatus = 'active' | 'archived'; + +export type Event = { + id: string; + name: string; + date: Date; + status: EventStatus; + created: Date; + updated: Date; + openingSales: Date | null; + salesEnabled: boolean; + maxCapacity: number | null; + budget: number | null; + alcoholRevenue: number | null; + foodRevenue: number | null; + meta: Record; +}; + +type EventInput = { + id?: string; + date: Date; + name: string; + openingSales?: Date; + salesEnabled?: boolean; + maxCapacity?: number; + budget?: number; + alcoholRevenue?: number; + foodRevenue?: number; + status?: EventStatus; + meta?: Record; +}; + +type EventUpdate = { + date?: Date; + name?: string; + openingSales?: Date; + salesEnabled?: boolean; + maxCapacity?: number; + alcoholRevenue?: number; + foodRevenue?: number; + status?: EventStatus; + budget?: number; + meta?: Record; + updatedBy?: string; +}; + +type EventSummary = { + eventId: string; + totalGuests: number; + totalPaidGuests: number; + totalCompedGuests: number; + totalVipGuests: number; + guestsToday: number; + checkedIn: number; +}; + +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 DailyTicketsRow = { + date: Date; + tickets: number; +}; + +type OpeningSalesRow = { + minuteCreated: Date; + tickets: number; +}; + +type EventCheckinsRow = { + minuteCheckedIn: Date; + checkins: number; +}; + const eventColumns = [ 'id', 'name', @@ -34,17 +127,17 @@ const eventColumns = [ 'meta' ]; -const convertNumericTypeToNumbers = e => ({ +const convertNumericTypeToNumbers = (e: Event): 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 }) { +export async function getEvents({ status }: { status?: EventStatus } = {}): Promise { try { - const events = await sql` + const events = await sql` SELECT ${sql(eventColumns)} FROM events ${status ? sql`WHERE status = ${status}` : sql``} @@ -56,10 +149,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 +166,7 @@ export async function getEvent(id) { return event; } -export async function createEvent(newEvent) { +export async function createEvent(newEvent: EventInput): Promise { for(const u in newEvent) { // Fields whitelist if(![ @@ -93,7 +186,7 @@ export async function createEvent(newEvent) { const event = { id: newEvent.id ?? uuidV4(), - status: 'active', + status: 'active' as const, date: newEvent.date, name: newEvent.name, openingSales: newEvent.openingSales, @@ -108,7 +201,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 +212,7 @@ export async function createEvent(newEvent) { } } -export async function updateEvent(id, updates) { +export async function updateEvent(id: string, updates: EventUpdate): Promise { for(const u in updates) { // Update whitelist if(![ @@ -139,9 +232,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 +249,33 @@ export async function updateEvent(id, updates) { return event; } +type EventSettingsProduct = { + 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: 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 +316,9 @@ export async function getEventSettings(id) { return event; } -export async function getEventSummary(id) { +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 +351,9 @@ export async function getEventSummary(id) { } } -export async function getEventExtendedStats(id) { +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 +440,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,9 +466,9 @@ 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}[]>` + const chart = (await sql` SELECT DATE_TRUNC('minute', (o.created AT TIME ZONE 'UTC')) AS minute_created, SUM(oi.quantity) as tickets @@ -381,9 +496,9 @@ 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}[]>` + const chart = (await sql` SELECT date_bin('15 minutes', (g.check_in_time AT TIME ZONE 'UTC'), TIMESTAMP '2010-01-01') AS minute_checked_in, count(g.id) as checkins diff --git a/lib/services/guests.ts b/lib/services/guests.ts index 0cc17f4..14e9654 100644 --- a/lib/services/guests.ts +++ b/lib/services/guests.ts @@ -24,6 +24,57 @@ class GuestsServiceError extends Error { } } +type GuestStatus = 'active' | 'checked_in' | 'archived'; +type CreatedReason = 'purchase' | 'transfer' | 'comp'; +type AdmissionTier = 'general' | 'vip' | 'sponsor' | 'stachepass'; + +export type Guest = { + id: string; + firstName: string; + lastName: string; + admissionTier: AdmissionTier | string; + created: Date; + updated: Date; + updatedBy: string | null; + createdBy: string | null; + createdReason: CreatedReason; + status: GuestStatus; + checkInTime: Date | null; + orderId: string | null; + eventId: string; + meta: Record; +}; + +type GuestInput = { + firstName: string; + lastName: string; + createdReason: CreatedReason; + orderId?: string | null; + createdBy?: string | null; + eventId: string; + admissionTier: AdmissionTier | string; + meta?: Record; +}; + +type GuestUpdate = { + status?: GuestStatus; + firstName?: string; + lastName?: string; + updatedBy?: string; + meta?: Record; + admissionTier?: AdmissionTier | string; + checkInTime?: Date | null | ReturnType; +}; + +type GuestQueryParams = { + limit?: number | string; + eventId?: string; + admissionTier?: AdmissionTier | string; + createdReason?: CreatedReason; + orderBy?: string; + sort?: 'asc' | 'desc'; +}; + const guestColumns = [ 'id', 'first_name', @@ -41,7 +92,7 @@ const guestColumns = [ 'meta' ]; -export async function createGuest({ firstName, lastName, createdReason, orderId = null, createdBy = null, eventId, admissionTier, meta }) { +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 +111,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 +122,9 @@ export async function createGuest({ firstName, lastName, createdReason, orderId } } -export async function getGuests({ limit, eventId, admissionTier, createdReason, orderBy = 'created', sort = 'desc' }) { +export async function getGuests({ limit, eventId, admissionTier, createdReason, orderBy = 'created', sort = 'desc' }: GuestQueryParams = {}): Promise { try { - const guests = await sql` + const guests = await sql` SELECT ${sql(guestColumns)} FROM guests WHERE 1 = 1 @@ -90,10 +141,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 +158,7 @@ export async function getGuest(id) { return guest; } -export async function updateGuest(id, updates) { +export async function updateGuest(id: string, updates: GuestUpdate): 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: { 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 +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..52b9053 100644 --- a/lib/services/orders.ts +++ b/lib/services/orders.ts @@ -56,11 +56,62 @@ const aggregateOrderItems = sql` ) as items `; -const convertAmountToNumber = o => ({...o, ...(typeof o.amount === 'string' ? {amount: Number(o.amount)} : {})}); +type OrderStatus = 'complete' | 'canceled' | 'transferred'; -export async function getOrders({ eventId, productId, status, limit, orderBy = 'created', sort = 'desc' }) { +type OrderItem = { + productId: string; + quantity: number; +}; + +export type Order = { + id: string; + amount: number; + created: Date; + customerId: string; + promoId: string | null; + parentOrderId: string | null; + status: OrderStatus; + meta: Record; + items?: OrderItem[]; + customerEmail?: string; + customerFirstName?: string; + customerLastName?: string; +}; + +type OrderQueryParams = { + eventId?: string; + productId?: string; + status?: OrderStatus; + limit?: number | string; + orderBy?: string; + sort?: 'asc' | 'desc'; +}; + +type OrderRefundRow = { + orderStatus: OrderStatus; + transactionId: string; + transactionType: string; + processorTransactionId: string; + processor: string; + parentTransactionId: string | null; +}; + +type NewTransactionInput = { + id: string; + orderId: string; + processor: string; + parentTransactionId: string | null; + type?: 'refund' | 'void'; + processorTransactionId?: string; + processorCreatedAt?: Date; + amount?: number; +}; + +const convertAmountToNumber = (o: T): T & { amount?: number } => ({...o, ...(typeof o.amount === 'string' ? {amount: Number(o.amount)} : {})}); + +export async function getOrders({ eventId, productId, status, limit, orderBy = 'created', sort = 'desc' }: OrderQueryParams): Promise { try { - let orders; + let orders: Order[]; if(eventId) { orders = await sql` WITH FilteredOrders AS ( @@ -512,10 +563,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} @@ -529,20 +580,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) { +export function validateOrderToken(token: string): string | jwt.JwtPayload { return jwt.verify(token, orderSecret, {issuer: 'mustachebash'}); } -export async function getOrder(id) { - let order; +export async function getOrder(id: string): Promise { + let order: Order | undefined; try { - [order] = (await sql` + [order] = (await sql` SELECT ${sql(orderColumns.map(c => `o.${c}`))}, ${aggregateOrderItems} @@ -561,10 +612,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 +630,10 @@ export async function getOrderTransfers(id) { } // Full order refund -export async function refundOrder(id) { - let order; +export async function refundOrder(id: string): Promise { + let order: OrderRefundRow | undefined; try { - [order] = await sql` + [order] = await sql` SELECT o.status AS order_status, t.id AS transaction_id, @@ -603,7 +654,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: NewTransactionInput = { id: uuidV4(), orderId: id, processor: order.processor, diff --git a/lib/services/products.ts b/lib/services/products.ts index 49c5727..a6eaeba 100644 --- a/lib/services/products.ts +++ b/lib/services/products.ts @@ -43,28 +43,44 @@ const convertPriceToNumber = (p: Record) => ({...p, ...(typeof type ProductType = 'ticket' | 'upgrade' | 'bundle-ticket' | 'accomodation'; type AdmissionTier = 'general' | 'vip' | 'sponsor' | 'stachepass'; +type ProductStatus = 'active' | 'archived'; + export type Product = { id: string; + status?: ProductStatus; price: number; name: string; description: string; type: ProductType; maxQuantity: number | null; eventId: string; - admissionTier: string; + admissionTier: AdmissionTier | string; targetProductId: string; promo: boolean; + 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 ProductUpdate = { + name?: string; + price?: number; + description?: string; + status?: ProductStatus; + maxQuantity?: number | null; + meta?: Record; + updatedBy?: string; +}; + +export async function createProduct({ price, name, description, type, maxQuantity, eventId, admissionTier, targetProductId, promo, meta }: Omit): 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: string; price: number; name: string; description: string; type: ProductType; maxQuantity: number | null; meta: Record } = { id: uuidV4(), price, name, @@ -113,9 +129,9 @@ export async function createProduct({ price, name, description, type, maxQuantit } } -export async function getProducts({eventId, type}: {eventId?: string; type?: string} = {}) { +export async function getProducts({eventId, type}: {eventId?: string; type?: string} = {}): Promise { try { - const products = await sql` + const products = await sql` SELECT ${sql(productColumns)} FROM products WHERE true @@ -123,20 +139,20 @@ export async function getProducts({eventId, type}: {eventId?: string; type?: str ${type ? sql`AND type = ${type}` : sql``} `; - return products.map(convertPriceToNumber); + return products.map(convertPriceToNumber) as Product[]; } catch(e) { throw new ProductsServiceError('Could not query products', 'UNKNOWN', e); } } -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} - `).map(convertPriceToNumber); + `).map(convertPriceToNumber) as Product[]; } catch(e) { throw new ProductsServiceError('Could not query products', 'UNKNOWN', e); } @@ -146,7 +162,7 @@ export async function getProduct(id: string) { return product; } -export async function updateProduct(id: string, updates: Record) { +export async function updateProduct(id: string, updates: ProductUpdate): Promise { for(const u in updates) { // Update whitelist if(![ @@ -162,14 +178,14 @@ 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} RETURNING ${sql(productColumns)} - `).map(convertPriceToNumber); + `).map(convertPriceToNumber) as Product[]; } catch(e) { throw new ProductsServiceError('Could not update product', 'UNKNOWN', e); } diff --git a/lib/services/promos.ts b/lib/services/promos.ts index 62c6104..43a851e 100644 --- a/lib/services/promos.ts +++ b/lib/services/promos.ts @@ -22,10 +22,13 @@ class PromoServiceError extends Error { } type PromoType = 'single-use' | 'coupon'; +type PromoStatus = 'active' | 'claimed' | 'disabled'; + export type Promo = { id: string; created: Date; createdBy: string; + updatedBy?: string | null; price?: number; percentDiscount?: number; flatDiscount?: number; @@ -33,11 +36,19 @@ export type Promo = { productQuantity?: number; recipientName?: string; maxUses?: number; - status: string; + status: PromoStatus; type: PromoType; meta: Record; }; +type PromoUpdate = { + recipientName?: string; + price?: number; + status?: PromoStatus; + meta?: Record; + updatedBy?: string; +}; + const promoColumns = [ 'id', 'created', @@ -75,7 +86,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 +129,9 @@ export async function createPromo({ price, flatDiscount, percentDiscount, maxUse } } -export async function getPromos({ eventId }: {eventId?: string;} = {}) { +export async function getPromos({ eventId }: {eventId?: string;} = {}): Promise { try { - let promos; + let promos: Promo[]; if(eventId) { promos = await sql` SELECT ${sql(promoColumns.map(c => `p.${c}`))} @@ -142,8 +153,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 +170,7 @@ export async function getPromo(id: string) { return promo; } -export async function updatePromo(id: string, updates: Record) { +export async function updatePromo(id: string, updates: PromoUpdate): Promise { for(const u in updates) { // Update whitelist if(![ @@ -173,7 +184,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..4c89e7a 100644 --- a/lib/services/tickets.ts +++ b/lib/services/tickets.ts @@ -23,6 +23,138 @@ export class TicketsServiceError extends Error { } } +type GuestStatus = 'active' | 'checked_in' | 'archived'; +type EventStatus = 'active' | 'archived'; + +type OrderTicketRow = { + id: string; + admissionTier: string; + eventId: string; + eventName: string; + eventDate: Date; + ticketSeed: string; + status: GuestStatus; + firstName: string; + lastName: string; +}; + +type Ticket = { + id: string; + admissionTier: string; + eventId: string; + eventName: string; + eventDate: Date; + status: GuestStatus; + firstName: string; + lastName: string; + qrPayload: string; +}; + +type CustomerTicketRow = { + orderCreated: Date; + customerId: string; + guestId: string; + guestStatus: GuestStatus; + 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 CustomerTicket = { + id: string; + customerId: string; + orderId: string; + orderCreated: Date; + admissionTier: string; + status: GuestStatus; + checkInTime: Date | null; + eventId: string; + eventName: string; + eventDate: Date; + upgradeProductId: string | null; + upgradePrice: number | null; + upgradeName: string | null; + qrPayload: string; +}; + +type AccommodationRow = { + orderCreated: Date; + orderId: string; + customerId: string; + productName: string; + eventId: string; + eventName: string; + eventDate: Date; +}; + +type Accommodation = { + customerId: string; + orderId: string; + orderCreated: Date; + productName: string; + eventId: string; + eventName: string; + eventDate: Date; +}; + +type Transferee = { + email: string; + firstName: string; + lastName: string; +}; + +type TransferResult = { + transferee: { + id: string; + firstName: string; + lastName: string; + email: string; + }; + order: { + id: string; + parentOrderId: string; + status: string; + amount: number; + customerId: string; + }; +}; + +type InspectedGuest = { + id: string; + firstName: string; + lastName: string; + status: GuestStatus; + orderId: string; + createdReason: string; + admissionTier: string; + checkInTime: Date | null; + eventId: string; + eventName: string; + eventDate: Date; + eventStatus: EventStatus; +}; + +type OrderRow = { + id: string; + customerId: string; + status: string; + parentOrderId: string | null; +}; + +type GuestRow = { + id: string; + status: GuestStatus; + eventId: string; + admissionTier: 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,10 +163,10 @@ function generateQRPayload(ticketSeed: string) { return ticketSeed; } -export async function getOrderTickets(orderId: string) { - let guests; +export async function getOrderTickets(orderId: string): Promise { + let guests: OrderTicketRow[]; try { - guests = await sql` + guests = await sql` SELECT g.id, g.admission_tier, @@ -55,7 +187,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 +207,10 @@ export async function getOrderTickets(orderId: string) { return tickets; } -export async function getCustomerActiveTicketsByOrderId(orderId: string) { - let rows; +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 +252,7 @@ export async function getCustomerActiveTicketsByOrderId(orderId: string) { `; // Inject the QR Codes - const tickets = []; + const tickets: CustomerTicket[] = []; for (const row of rows) { const qrPayload = generateQRPayload(row.guestTicketSeed); @@ -151,10 +283,10 @@ export async function getCustomerActiveTicketsByOrderId(orderId: string) { } } -export async function getCustomerActiveAccommodationsByOrderId(orderId: string) { - let rows; +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 +313,7 @@ export async function getCustomerActiveAccommodationsByOrderId(orderId: string) `; // Inject the QR Codes - const accomodations = []; + const accomodations: Accommodation[] = []; for (const row of rows) { accomodations.push({ customerId: row.customerId, @@ -224,24 +356,20 @@ export async function transferTickets( transferee, guestIds }: { - transferee: { - email: string; - firstName: string; - lastName: string; - }; + transferee: Transferee; guestIds: string[]; } -) { +): Promise { if(!transferee) throw new TicketsServiceError('No transferee specified', 'INVALID'); if(!guestIds?.length) throw new TicketsServiceError('No tickets specified', 'INVALID'); - let order; + let order: OrderRow | 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 +377,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: GuestRow[]; try { - originalGuests = await sql` + originalGuests = await sql` SELECT * FROM guests WHERE order_id = ${orderId} @@ -357,11 +485,11 @@ export async function transferTickets( }; } -export async function inspectTicket(ticketToken: string) { - let guest; +export async function inspectTicket(ticketToken: string): Promise { + let guest: InspectedGuest | 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 +517,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: InspectedGuest | 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..f6007e3 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,30 @@ class TransactionsServiceError extends Error { } } +type TransactionType = 'sale' | 'refund' | 'void'; +type Processor = 'braintree'; + +export type Transaction = { + id: string; + amount: number; + created: Date; + type: TransactionType; + orderId: string; + processorTransactionId: string; + processorCreatedAt: Date; + processor: Processor; + parentTransactionId: string | null; + meta: Record; +}; + +type TransactionQueryParams = { + type?: TransactionType; + orderId?: string; + orderBy?: string; + limit?: number | string; + sort?: 'asc' | 'desc'; +}; + const transactionColumns = [ 'id', 'amount', @@ -38,11 +65,11 @@ const transactionColumns = [ 'meta' ]; -const convertAmountToNumber = o => ({...o, ...(typeof o.amount === 'string' ? {amount: Number(o.amount)} : {})}); +const convertAmountToNumber = (o: T): T & { amount?: number } => ({...o, ...(typeof o.amount === 'string' ? {amount: Number(o.amount)} : {})}); -export async function getTransactions({ type, orderId, orderBy = 'created', limit, sort = 'desc' }) { +export async function getTransactions({ type, orderId, orderBy = 'created', limit, sort = 'desc' }: TransactionQueryParams = {}): Promise { try { - const transactions = await sql` + const transactions = await sql` SELECT ${sql(transactionColumns)} FROM transactions WHERE 1 = 1 @@ -52,20 +79,20 @@ export async function getTransactions({ type, orderId, orderBy = 'created', limi ${(limit && Number(limit)) ? sql`LIMIT ${limit}` : sql``} `; - return transactions.map(convertAmountToNumber); + return transactions.map(convertAmountToNumber) as Transaction[]; } catch(e) { throw new TransactionsServiceError('Could not query transactions', 'UNKNOWN', e); } } -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} - `).map(convertAmountToNumber); + `).map(convertAmountToNumber) as Transaction[]; } catch(e) { throw new TransactionsServiceError('Could not query transaction', 'UNKNOWN', e); } @@ -75,10 +102,10 @@ export async function getTransaction(id) { return transaction; } -export async function getTransactionProcessorDetails(id) { - let transaction; +export async function getTransactionProcessorDetails(id: string): Promise { + let transaction: { processorTransactionId: string } | undefined; try { - [transaction] = await sql` + [transaction] = await sql<{ processorTransactionId: string }[]>` SELECT processor_transaction_id FROM transactions WHERE id = ${id}