diff --git a/lib/routes/index.ts b/lib/routes/index.ts index efe57d4..f631268 100644 --- a/lib/routes/index.ts +++ b/lib/routes/index.ts @@ -7,8 +7,8 @@ import { isRecordLike } from '../utils/type-guards.js'; import { authorizeUser, requiresPermission } from '../middleware/auth.js'; import { authenticateGoogleUser, refreshAccessToken } from '../services/auth.js'; import { getEventSettings } from '../services/events.js'; -import { validateOrderToken } from '../services/orders.js'; -import { checkInWithTicket, getCustomerActiveTicketsByOrderId, inspectTicket } from '../services/tickets.js'; +import { generateOrderToken, validateOrderToken } from '../services/orders.js'; +import { checkInWithTicket, getCustomerActiveTicketsByOrderId, inspectTicket, transferTickets } from '../services/tickets.js'; import customersRouter from './customers.js'; import ordersRouter from './orders.js'; import transactionsRouter from './transactions.js'; @@ -19,6 +19,7 @@ import promosRouter from './promos.js'; import guestsRouter from './guests.js'; import usersRouter from './users.js'; import { getCustomer } from '../services/customers.js'; +import { sendTransfereeConfirmation, upsertEmailSubscriber } from '../services/email.js'; const apiRouter = new Router(); @@ -32,6 +33,8 @@ apiRouter.use(promosRouter.routes()); apiRouter.use(guestsRouter.routes()); apiRouter.use(usersRouter.routes()); +const EMAIL_LIST = '90392ecd5e', + EMAIL_TAG = 'Mustache Bash 2025 Attendee'; // TODO: add route access to get all current `customer` orders // /v1/me/orders?token= apiRouter @@ -55,6 +58,10 @@ apiRouter throw ctx.throw(e); } + if(!tickets.length) { + return ctx.status = 204; + } + let customer; try { customer = await getCustomer(tickets[0].customerId); @@ -72,6 +79,54 @@ apiRouter }, tickets }; + }) + .post('/mytickets/transfers', async ctx => { + if(!ctx.request.body) throw ctx.throw(400); + + if(!ctx.request.body.orderToken || typeof ctx.request.body.orderToken !== 'string') throw ctx.throw(400); + + // Use the order token as authorization + try { + validateOrderToken(ctx.request.body.orderToken); + } catch(e) { + throw ctx.throw(e); + } + + const selectedTickets = ctx.request.body.tickets, + transferee = ctx.request.body.transferee, + orderIds = new Set(selectedTickets.map(ticket => ticket.orderId)); + + const newOrderTokens = [], + parentOrderIds = []; + for (const orderId of orderIds.values()) { + const guestIds = selectedTickets.filter(ticket => ticket.orderId === orderId).map(ticket => ticket.id); + try { + const { order } = await transferTickets(orderId, {transferee, guestIds}), + { id, parentOrderId } = order; + + parentOrderIds.push(parentOrderId); + try { + newOrderTokens.push(await generateOrderToken(id)); + } catch(e) { + ctx.state.log.error(e, 'Error creating order token'); + } + } catch(e) { + if(e.code === 'INVALID') throw ctx.throw(400, e, {expose: false}); + if(e.code === 'NOT_FOUND') throw ctx.throw(404); + + throw ctx.throw(e); + } + } + + // Send a transfer email + const { email, firstName, lastName } = transferee; + // The first order token and parent order id are fine for this + sendTransfereeConfirmation(firstName, lastName, email, parentOrderIds[0], newOrderTokens[0]); + // Add them to the mailing list and tag as an attendee + upsertEmailSubscriber(EMAIL_LIST, {email, firstName, lastName, tags: [EMAIL_TAG]}); + + ctx.status = 201; + return ctx.body = {}; }); apiRouter diff --git a/lib/services/orders.ts b/lib/services/orders.ts index 237a0ba..2c375a8 100644 --- a/lib/services/orders.ts +++ b/lib/services/orders.ts @@ -207,6 +207,8 @@ export async function createOrder({ paymentMethodNonce, cart = [], customer = {} if(typeof promo.maxUses === 'number' && promo.maxUses <= promoUses) throw new OrdersServiceError('Promo code no longer available', 'GONE'); } + // 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; if(targetGuestId) { if(products.length > 1 || products[0].type !== 'upgrade') throw new OrdersServiceError('Missing/incorrect Upgrade Product', 'INVALID'); diff --git a/lib/services/products.ts b/lib/services/products.ts index 4443c73..2c7b8bf 100644 --- a/lib/services/products.ts +++ b/lib/services/products.ts @@ -105,12 +105,14 @@ export async function createProduct({ price, name, description, type, eventId, a } } -export async function getProducts({eventId}: {eventId?: string} = {}) { +export async function getProducts({eventId, type}: {eventId?: string; type?: string} = {}) { try { const products = await sql` SELECT ${sql(productColumns)} FROM products - ${eventId ? sql`WHERE event_id = ${eventId}` : sql``} + WHERE true + ${eventId ? sql`AND event_id = ${eventId}` : sql``} + ${type ? sql`AND type = ${type}` : sql``} `; return products.map(convertPriceToNumber); diff --git a/lib/services/tickets.ts b/lib/services/tickets.ts index 8e18be5..3a32561 100644 --- a/lib/services/tickets.ts +++ b/lib/services/tickets.ts @@ -90,12 +90,26 @@ export async function getCustomerActiveTicketsByOrderId(orderId: string) { g.order_id as guest_order_id, e.id as event_id, e.name as event_name, - e.date AT TIME ZONE 'UTC' event_date + e.date AT TIME ZONE 'UTC' event_date, + up.id as upgrade_product_id, + up.price as upgrade_price, + up.name as upgrade_name FROM orders o LEFT JOIN guests as g on g.order_id = o.id LEFT JOIN events as e on g.event_id = e.id + LEFT JOIN order_items as oi + on oi.order_id = o.id + and (select event_id from products where id = oi.product_id) = g.event_id + LEFT JOIN products as p + on oi.product_id = p.id + and p.admission_tier = g.admission_tier + and g.event_id = p.event_id + LEFT JOIN products as up + on oi.product_id = up.target_product_id + and up.status = 'active' + and up.type = 'upgrade' WHERE o.customer_id = ( SELECT customer_id FROM orders @@ -121,6 +135,12 @@ export async function getCustomerActiveTicketsByOrderId(orderId: string) { eventId: row.eventId, eventName: row.eventName, eventDate: row.eventDate, + upgradeProductId: null, + upgradePrice: null, + upgradeName: null, + // upgradeProductId: row.upgradeProductId, + // upgradePrice: row.upgradePrice ? Number(row.upgradePrice) : null, + // upgradeName: row.upgradeName, qrPayload }); }