diff --git a/lib/services/auth.ts b/lib/services/auth.ts index 8d6583a..82ab3cf 100644 --- a/lib/services/auth.ts +++ b/lib/services/auth.ts @@ -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,7 @@ function validateRefreshToken(refreshToken: string) { return tokenPayload as RefreshToken; } -export async function authenticateGoogleUser(token: string, {userAgent, ip}: {userAgent: string, ip: string}) { +export async function authenticateGoogleUser(token: string, {userAgent, ip}: {userAgent: string; ip: string;}): Promise<{accessToken: string; refreshToken: string}> { if(!token) throw new AuthServiceError('Missing token', 'UNAUTHORIZED'); let payload: TokenPayload | undefined, googleUserId: string | null; @@ -192,7 +192,7 @@ export async function authenticateGoogleUser(token: string, {userAgent, ip}: {us return {accessToken, refreshToken}; } -export async function refreshAccessToken(refreshToken: string, {userAgent, ip}: {userAgent: string, ip: 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 +200,7 @@ export async function refreshAccessToken(refreshToken: string, {userAgent, ip}: throw new AuthServiceError('Invalid refresh token', 'UNAUTHORIZED'); } - let user; + let user: User; try { [user] = await sql` SELECT id, display_name, role, sub_claim @@ -211,9 +211,9 @@ export async function refreshAccessToken(refreshToken: string, {userAgent, ip}: throw new AuthServiceError('Failed to query for user', 'DB_ERROR', e); } - let refreshTokenData; + let refreshTokenData: {userId: string} | undefined; try { - [refreshTokenData] = await sql` + [refreshTokenData] = await sql<{userId: string}[]>` SELECT user_id FROM refresh_tokens WHERE id = ${jti} @@ -240,7 +240,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', @@ -255,10 +255,17 @@ export function checkScope(userRole: string, scopeRequired: string) { return ~userLevel && userLevel <= roles.indexOf(scopeRequired); } -export async function getUsers() { - let users; +type UserRecord = User & { + status: string; + created: Date; + updated: Date; + username: string; +}; + +export async function getUsers(): Promise { + let users: UserRecord[]; try { - users = await sql` + users = await sql` SELECT id, username, display_name, role, status, created, updated FROM users `; @@ -269,10 +276,10 @@ export async function getUsers() { return users; } -export async function getUser(id: string) { - let user; +export async function getUser(id: string): Promise { + let user: UserRecord; 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..d68387f 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,12 +32,30 @@ 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 CustomerInput = Pick & { + meta?: Record; +}; + +type CustomerUpdate = Partial> & { + updatedBy?: string; +}; + +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'); - const customer = { + const customer: Omit = { id: uuidV4(), firstName, lastName, @@ -48,7 +66,7 @@ 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)} `); @@ -59,9 +77,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,10 +90,10 @@ export async function getCustomers() { } } -export async function getCustomer(id) { - let customer; +export async function getCustomer(id: string): Promise { + let customer: Customer; try { - [customer] = (await sql` + [customer] = (await sql` SELECT ${sql(customerColumns)} FROM customers WHERE id = ${id} @@ -89,7 +107,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,9 +121,9 @@ 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; try { - [customer] = (await sql` + [customer] = (await sql` UPDATE customers SET ${sql(updates)}, updated = now() WHERE id = ${id} @@ -119,4 +137,3 @@ export async function updateCustomer(id, updates) { return customer; } - diff --git a/lib/services/email.ts b/lib/services/email.ts index d3867e0..7060ba2 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) => crypto.createHash('md5').update(string).digest('hex'); + +type EmailSubscriber = { + 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 = [] }: EmailSubscriber): 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 + '> ', diff --git a/lib/services/events.ts b/lib/services/events.ts index b7c4ffb..3db0004 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,7 +37,117 @@ const eventColumns = [ 'meta' ]; -const convertNumericTypeToNumbers = e => ({ +type EventStatus = 'active' | 'archived' | 'draft' | string; +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 = Partial> & Pick; +type EventUpdate = Partial> & { updatedBy?: string }; + +type EventSummary = { + eventId: string; + totalGuests: number; + totalPaidGuests: number; + totalCompedGuests: number; + totalVipGuests: number; + guestsToday: number; + checkedIn: number; +}; +type EventSummaryRow = { + eventId: string; + totalGuests: number | string; + totalPaidGuests: number | string; + totalCompedGuests: number | string; + totalVipGuests: number | string; + guestsToday: number | string; + checkedIn: number | string; +}; + +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[]; +}; + +type EventExtendedStats = { + eventId: string; + eventBudget: number; + eventMaxCapacity: number | null; + alcoholRevenue: number | null; + foodRevenue: number | null; + salesTiers: {name: string; quantity: number; price: number}[]; + totalRevenue: number; + totalPromoRevenue: number; + revenueToday: number; + promoRevenueToday: number; +}; +type EventExtendedStatsRow = Omit & { + eventBudget: number | string; + alcoholRevenue: number | string | null; + foodRevenue: number | string | null; + revenueToday: number | string; + promoRevenueToday: number | string; + totalRevenue: number | string; + totalPromoRevenue: number | string; +}; + +type EventTicketsByDay = { + date: string; + tickets: number; +}; +type EventTicketsRow = { + date: string; + tickets: number | string; +}; + +type EventOpeningSalesPoint = { + minuteCreated: string; + tickets: number; +}; +type EventOpeningSalesRow = { + minuteCreated: string; + tickets: number | string; +}; + +type EventCheckinsPoint = { + minuteCheckedIn: string; + checkins: number; +}; +type EventCheckinsRow = { + minuteCheckedIn: string; + checkins: number | string; +}; + +const convertNumericTypeToNumbers = (e: Event): Event => ({ ...e, ...(typeof e.alcoholRevenue === 'string' ? {alcoholRevenue: Number(e.alcoholRevenue)} : {}), ...(typeof e.budget === 'string' ? {budget: Number(e.budget)} : {}), @@ -42,9 +155,9 @@ const convertNumericTypeToNumbers = e => ({ }); -export async function getEvents({ status }) { +export async function getEvents({ status }: {status?: string;}): Promise { try { - const events = await sql` + const events = await sql` SELECT ${sql(eventColumns)} FROM events ${status ? sql`WHERE status = ${status}` : sql``} @@ -56,10 +169,10 @@ export async function getEvents({ status }) { } } -export async function getEvent(id) { - let event; +export async function getEvent(id: string): Promise { + let event: Event; try { - [event] = (await sql` + [event] = (await sql` SELECT ${sql(eventColumns)} FROM events WHERE id = ${id} @@ -73,7 +186,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(![ @@ -91,7 +204,7 @@ export async function createEvent(newEvent) { ].includes(u)) throw new EventsServiceError('Invalid event data', 'INVALID'); } - const event = { + const event: Omit = { id: newEvent.id ?? uuidV4(), status: 'active', date: newEvent.date, @@ -108,7 +221,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 +232,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 +252,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; try { - [event] = (await sql` + [event] = (await sql` UPDATE events SET ${sql(updates)}, updated = now() WHERE id = ${id} @@ -157,10 +270,10 @@ export async function updateEvent(id, updates) { } // !!! THIS GETS EXPOSED PUBLICLY -export async function getEventSettings(id) { - let event; +export async function getEventSettings(id: string): Promise { + let event: EventSettings; try { - [event] = await sql` + [event] = await sql` SELECT e.id, e.name, @@ -201,9 +314,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, @@ -220,7 +333,7 @@ export async function getEventSummary(id) { ON g.event_id = e.id WHERE e.id = ${id} GROUP BY e.id - `).map(s => ({ + `).map(s => ({ ...s, totalGuests: Number(s.totalGuests), totalPaidGuests: Number(s.totalPaidGuests), @@ -236,9 +349,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, @@ -299,7 +412,7 @@ export async function getEventExtendedStats(id) { ON e.id = oa.event_id WHERE e.id = ${id} GROUP BY e.id, revenue_today, promo_revenue_today - `).map(({ + `).map(({ revenueToday, promoRevenueToday, totalRevenue, @@ -325,9 +438,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 @@ -340,7 +453,7 @@ export async function getEventDailyTickets(id: string) { AND o.status != 'canceled' GROUP BY date ORDER BY 1 ASC; - `).map(row => ({ + `).map(row => ({ ...row, tickets: Number(row.tickets) })); @@ -351,9 +464,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 @@ -369,7 +482,7 @@ export async function getOpeningSales(id: string) { AND (o.created AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')::DATE = (e.opening_sales AT TIME ZONE 'UTC' AT TIME ZONE 'America/Los_Angeles')::DATE GROUP BY minute_created ORDER BY 1 ASC; - `).map(row => ({ + `).map(row => ({ ...row, tickets: Number(row.tickets) })); @@ -381,9 +494,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 @@ -392,7 +505,7 @@ export async function getEventCheckins(id: string) { AND g.status = 'checked_in' GROUP BY minute_checked_in ORDER BY 1 ASC; - `).map(row => ({ + `).map(row => ({ ...row, checkins: Number(row.checkins) })); diff --git a/lib/services/guests.ts b/lib/services/guests.ts index 0cc17f4..d433648 100644 --- a/lib/services/guests.ts +++ b/lib/services/guests.ts @@ -41,7 +41,50 @@ 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; + ticketSeed?: string; +}; + +type GuestInput = { + firstName: string; + lastName: string; + createdReason: string; + orderId?: string | null; + createdBy?: string | null; + eventId: string; + admissionTier: string; + meta?: Record; +}; + +type GuestFilters = { + limit?: number; + eventId?: string; + admissionTier?: string; + createdReason?: string; + orderBy?: string; + sort?: 'asc' | 'desc'; +}; + +type GuestUpdate = Partial> & { + updatedBy?: string; + checkInTime?: Date | null | unknown; +}; + +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 +103,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 +114,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' }: GuestFilters): Promise { try { - const guests = await sql` + const guests = await sql` SELECT ${sql(guestColumns)} FROM guests WHERE 1 = 1 @@ -90,10 +133,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; try { - [guest] = await sql` + [guest] = await sql` SELECT ${sql(guestColumns)} FROM guests WHERE id = ${id} @@ -107,7 +150,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 +172,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 +201,9 @@ export async function updateGuest(id, updates) { } } - let updatedGuest; + let updatedGuest: Guest; try { - [updatedGuest] = await sql` + [updatedGuest] = await sql` UPDATE guests SET ${sql(updates)}, updated = now() WHERE id = ${id} @@ -175,10 +218,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; 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..c03215f 100644 --- a/lib/services/orders.ts +++ b/lib/services/orders.ts @@ -8,6 +8,7 @@ import { v4 as uuidV4 } from 'uuid'; import log from '../utils/log.js'; import { sql } from '../utils/db.js'; import { createGuest, updateGuest } from '../services/guests.js'; +import type { Guest } from '../services/guests.js'; import type { Product } from '../services/products.js'; import type { Promo } from '../services/promos.js'; import { braintree as btConfig, jwt as jwtConfig } from '../config.js'; @@ -56,13 +57,85 @@ const aggregateOrderItems = sql` ) as items `; -const convertAmountToNumber = o => ({...o, ...(typeof o.amount === 'string' ? {amount: Number(o.amount)} : {})}); +type OrderStatus = 'complete' | 'canceled' | 'pending' | string; +type OrderItemSummary = { + productId: string; + quantity: number; +}; + +export type Order = { + id: string; + amount: number; + created?: number | Date; + customerId: string; + promoId?: string | null; + parentOrderId?: string | null; + status: OrderStatus; + meta?: Record; +}; + +type OrderWithItems = Order & { + items: OrderItemSummary[]; + customerEmail?: string | null; + customerFirstName?: string | null; + customerLastName?: string | null; +}; + +type OrderBaseRow = Omit & {amount: number | string}; + +type OrderRow = Omit & {amount: number | string}; + +type OrderFilters = { + eventId?: string; + productId?: string; + status?: string; + limit?: number; + orderBy?: string; + sort?: 'asc' | 'desc'; +}; + +type OrderItem = { + productId: string; + quantity: number; + orderId: string; +}; + +type CartItem = Pick; + +type TransactionInsert = { + id: string; + amount: number | null; + processor: string; + processorTransactionId: string; + processorCreatedAt: Date | string; + type: string; + orderId: string; + parentTransactionId?: string; +}; + +type CustomerRecord = { + id: string; + firstName: string; + lastName: string; + email: string; +}; + +type RefundableOrder = { + orderStatus: string; + transactionId: string; + transactionType: string; + processorTransactionId: string; + processor: string; + parentTransactionId: string | null; +}; + +const convertAmountToNumber = (o: T) => ({...o, ...(typeof o.amount === 'string' ? {amount: Number(o.amount)} : {})}); -export async function getOrders({ eventId, productId, status, limit, orderBy = 'created', sort = 'desc' }) { +export async function getOrders({ eventId, productId, status, limit, orderBy = 'created', sort = 'desc' }: OrderFilters): Promise { try { - let orders; + let orders: OrderRow[]; if(eventId) { - orders = await sql` + orders = await sql` WITH FilteredOrders AS ( SELECT o.id FROM orders as o @@ -91,7 +164,7 @@ export async function getOrders({ eventId, productId, status, limit, orderBy = ' ${(limit && Number(limit)) ? sql`LIMIT ${limit}` : sql``} `; } else if(productId) { - orders = await sql` + orders = await sql` WITH FilteredOrders AS ( SELECT o.id FROM orders as o @@ -113,7 +186,7 @@ export async function getOrders({ eventId, productId, status, limit, orderBy = ' ${(limit && Number(limit)) ? sql`LIMIT ${limit}` : sql``} `; } else { - orders = await sql` + orders = await sql` SELECT ${sql(orderColumns.map(c => `o.${c}`))}, ${aggregateOrderItems} @@ -129,7 +202,7 @@ export async function getOrders({ eventId, productId, status, limit, orderBy = ' } // https://github.com/porsager/postgres#numbers-bigint-numeric - return orders.map(convertAmountToNumber); + return orders.map(convertAmountToNumber); } catch(e) { throw new OrdersServiceError('Could not query orders', 'UNKNOWN', e); } @@ -138,10 +211,7 @@ export async function getOrders({ eventId, productId, status, limit, orderBy = ' type OrderInput = { paymentMethodNonce: string; - cart: { - productId: string; - quantity: number; - }[]; + cart: CartItem[]; customer: { firstName: string; @@ -153,7 +223,7 @@ type OrderInput = { targetGuestId?: string; }; -export async function createOrder({ paymentMethodNonce, cart = [], customer = {}, promoId, targetGuestId }: OrderInput) { +export async function createOrder({ paymentMethodNonce, cart = [], customer, promoId, targetGuestId }: OrderInput): Promise<{order: Order; transaction: TransactionInsert; customer: CustomerRecord}> { // First do some validation if (!cart.length || !paymentMethodNonce || !customer.firstName || !customer.lastName || !customer.email) throw new OrdersServiceError('Invalid payment parameters', 'INVALID'); @@ -183,7 +253,7 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} ...(typeof p.price === 'string' ? {price: Number(p.price)} : {}) })); - let promo: Promo; + let promo: Promo | undefined; if(promoId) { [promo] = (await sql` SELECT * @@ -209,11 +279,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: Guest | 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 +297,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 productsToArchive: {id: string; eventId?: string; nextTierProductId: string | null}[] = []; const orderDetails = cart.map(i => { - const product = products.find(p => p.id === i.productId), + const product = products.find(p => p.id === i.productId) as (Product & {totalSold: number}), bundledProduct = bundledProducts.find(p => p.targetProductId === i.productId), remaining = typeof product.maxQuantity === 'number' && product.maxQuantity > 0 ? product.maxQuantity - product.totalSold : null; @@ -339,22 +409,22 @@ 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: CustomerRecord; + [dbCustomer] = await sql` SELECT * FROM customers WHERE email = ${normalizedEmail} `; if(!dbCustomer) { - const newCustomer = { + const newCustomer: CustomerRecord = { id: uuidV4(), firstName: customer.firstName.trim(), lastName: customer.lastName.trim(), email: normalizedEmail }; - [dbCustomer] = await sql` + [dbCustomer] = await sql` INSERT INTO customers ${sql(newCustomer)} RETURNING * `; @@ -388,14 +458,14 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} btAmount = Number(braintreeTransaction.amount); // Package the order, order_item, and transaction objects - const order = { + const order: Order = { id: orderId, customerId: dbCustomer.id, status: 'complete', amount }; - const orderItems = cart.map(i => ({ + const orderItems: OrderItem[] = cart.map(i => ({ productId: i.productId, quantity: i.quantity, orderId @@ -412,7 +482,7 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} } }); - const transaction = { + const transaction: TransactionInsert = { id: uuidV4(), amount: !Number.isNaN(btAmount) ? btAmount : null, processor: 'braintree', @@ -512,14 +582,14 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} }; } -export async function generateOrderToken(id) { - let order; +export async function generateOrderToken(id: string): Promise { + let order: Order; try { - [order] = (await sql` + [order] = (await sql` SELECT ${sql(orderColumns)} FROM orders WHERE id = ${id} - `).map(convertAmountToNumber); + `).map(convertAmountToNumber); } catch(e) { throw new OrdersServiceError('Could not query orders', 'UNKNOWN', e); } @@ -529,20 +599,20 @@ export async function generateOrderToken(id) { return jwt.sign({ iss: 'mustachebash', aud: 'tickets', - iat: Math.round(order.created / 1000), + iat: Math.round(Number(order.created) / 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: OrderWithItems; try { - [order] = (await sql` + [order] = (await sql` SELECT ${sql(orderColumns.map(c => `o.${c}`))}, ${aggregateOrderItems} @@ -551,7 +621,7 @@ export async function getOrder(id) { ON o.id = i.order_id WHERE id = ${id} GROUP BY o.id - `).map(convertAmountToNumber); + `).map(convertAmountToNumber); } catch(e) { throw new OrdersServiceError('Could not query orders', 'UNKNOWN', e); } @@ -561,16 +631,16 @@ 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 WHERE o.parent_order_id = ${id} GROUP BY o.id - `).map(convertAmountToNumber); + `).map(convertAmountToNumber); } catch(e) { throw new OrdersServiceError('Could not query orders', 'UNKNOWN', e); } @@ -579,10 +649,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: RefundableOrder; try { - [order] = await sql` + [order] = await sql` SELECT o.status AS order_status, t.id AS transaction_id, @@ -603,7 +673,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: Partial & {processor: string; orderId: string; id: string; parentTransactionId?: string} = { id: uuidV4(), orderId: id, processor: order.processor, diff --git a/lib/services/products.ts b/lib/services/products.ts index 49c5727..48b092c 100644 --- a/lib/services/products.ts +++ b/lib/services/products.ts @@ -39,7 +39,7 @@ const productColumns = [ 'meta' ]; -const convertPriceToNumber = (p: Record) => ({...p, ...(typeof p.price === 'string' ? {price: Number(p.price)} : {})}); +const convertPriceToNumber = (p: T) => ({...p, ...(typeof p.price === 'string' ? {price: Number(p.price)} : {})}); type ProductType = 'ticket' | 'upgrade' | 'bundle-ticket' | 'accomodation'; type AdmissionTier = 'general' | 'vip' | 'sponsor' | 'stachepass'; @@ -49,15 +49,22 @@ export type Product = { name: string; description: string; type: ProductType; + status?: string; maxQuantity: number | null; - eventId: string; - admissionTier: string; - targetProductId: string; + eventId?: string; + admissionTier?: AdmissionTier; + targetProductId?: string; promo: boolean; meta: Record; + created?: Date; + updated?: Date; + updatedBy?: string | null; }; -export async function createProduct({ price, name, description, type, maxQuantity, eventId, admissionTier, targetProductId, promo, meta }: Omit) { +type ProductInput = Omit & { status?: string }; +type ProductUpdate = Partial> & {updatedBy?: string}; + +export async function createProduct({ price, name, description, type, maxQuantity, eventId, admissionTier, targetProductId, promo, meta, status }: ProductInput): 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'); @@ -76,6 +83,10 @@ export async function createProduct({ price, name, description, type, maxQuantit } }; + if(status) { + product.status = status; + } + if(type === 'ticket') { product.eventId = eventId; product.admissionTier = admissionTier; @@ -102,7 +113,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 +124,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 @@ -129,10 +140,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; try { - [product] = (await sql` + [product] = (await sql` SELECT ${sql(productColumns)} FROM products WHERE id = ${id} @@ -146,7 +157,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,9 +173,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; 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..a884452 100644 --- a/lib/services/promos.ts +++ b/lib/services/promos.ts @@ -25,6 +25,8 @@ type PromoType = 'single-use' | 'coupon'; export type Promo = { id: string; created: Date; + updated?: Date; + updatedBy?: string | null; createdBy: string; price?: number; percentDiscount?: number; @@ -75,7 +77,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,7 +120,7 @@ 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; if(eventId) { @@ -142,8 +144,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; try { [promo] = (await sql` SELECT ${sql(promoColumns)} @@ -159,7 +161,7 @@ export async function getPromo(id: string) { return promo; } -export async function updatePromo(id: string, updates: Record) { +export async function updatePromo(id: string, updates: Record): Promise { for(const u in updates) { // Update whitelist if(![ diff --git a/lib/services/tickets.ts b/lib/services/tickets.ts index ab2a6ae..c03446c 100644 --- a/lib/services/tickets.ts +++ b/lib/services/tickets.ts @@ -31,10 +31,80 @@ function generateQRPayload(ticketSeed: string) { return ticketSeed; } -export async function getOrderTickets(orderId: string) { - let guests; +type TicketRow = { + id: string; + admissionTier: string; + eventId: string; + eventName: string; + eventDate: Date; + ticketSeed: string; + status: string; + firstName: string; + lastName: string; +}; + +export type Ticket = { + id: string; + admissionTier: string; + eventId: string; + eventName: string; + eventDate: Date; + status: string; + firstName: string; + lastName: string; + qrPayload: string; +}; + +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 | string | null; + upgradeName: string | null; +}; + +export type CustomerTicket = { + id: string; + customerId: string; + orderId: string; + orderCreated: Date; + admissionTier: string; + status: string; + checkInTime: Date | null; + eventId: string; + eventName: string; + eventDate: Date; + upgradeProductId: string | null; + upgradePrice: number | null; + upgradeName: string | null; + qrPayload: string; +}; + +type AccommodationRow = { + orderCreated: Date; + orderId: string; + customerId: string; + productName: string; + eventId: string; + eventName: string; + eventDate: Date; +}; + +export type Accommodation = AccommodationRow; + +export async function getOrderTickets(orderId: string): Promise { + let guests: TicketRow[]; try { - guests = await sql` + guests = await sql` SELECT g.id, g.admission_tier, @@ -55,7 +125,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 +145,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 +190,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 +221,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 +251,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, diff --git a/lib/services/transactions.ts b/lib/services/transactions.ts index c31da9e..680e561 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 | null; + created: Date; + type: string; + orderId: string; + processorTransactionId: string; + processorCreatedAt: Date; + processor: string; + parentTransactionId: string | null; + meta: Record; +}; + +type TransactionRow = Omit & {amount: number | string | null}; +type TransactionFilters = { + type?: string; + orderId?: string; + orderBy?: string; + limit?: number; + sort?: 'asc' | 'desc'; +}; + +const convertAmountToNumber = (o: TransactionRow): Transaction => ({ + ...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' }: TransactionFilters): Promise { try { - const transactions = await sql` + const transactions = await sql` SELECT ${sql(transactionColumns)} FROM transactions WHERE 1 = 1 @@ -52,20 +80,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); } 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; try { - [transaction] = (await sql` + [transaction] = (await sql` SELECT ${sql(transactionColumns)} FROM transactions WHERE id = ${id} - `).map(convertAmountToNumber); + `).map(convertAmountToNumber); } catch(e) { throw new TransactionsServiceError('Could not query transaction', 'UNKNOWN', e); } @@ -75,10 +103,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}