Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions lib/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand All @@ -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=<customer "access" token>
apiRouter
Expand All @@ -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);
Expand All @@ -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<string>(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
Expand Down
2 changes: 2 additions & 0 deletions lib/services/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a comment about upgrades but has not been fully implemented yet due to low demand

// 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');
Expand Down
6 changes: 4 additions & 2 deletions lib/services/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
22 changes: 21 additions & 1 deletion lib/services/tickets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are needed to show the upgrade button, but disabled for now

// upgradePrice: row.upgradePrice ? Number(row.upgradePrice) : null,
// upgradeName: row.upgradeName,
qrPayload
});
}
Expand Down
Loading