ghostguild-org/server/api/series/[id]/tickets/purchase.post.js
Jennie Robinson Faber 15329e3e84 refactor(events): gate member benefits on hasMemberAccess
Extracts hasMemberAccess(member) in tickets.js and uses it across event
registration, ticket purchase, and series purchase flows so guest, suspended,
and cancelled records no longer count as members while pending_payment still
does.
2026-04-18 17:06:17 +01:00

183 lines
5.6 KiB
JavaScript

import Series from "../../../../models/series.js";
import Event from "../../../../models/event.js";
import Member from "../../../../models/member.js";
import {
validateSeriesTicketPurchase,
calculateSeriesTicketPrice,
reserveSeriesTicket,
releaseSeriesTicket,
completeSeriesTicketPurchase,
registerForAllSeriesEvents,
hasMemberAccess,
} from "../../../../utils/tickets.js";
import { sendSeriesPassConfirmation } from "../../../../utils/resend.js";
export default defineEventHandler(async (event) => {
try {
const seriesId = getRouterParam(event, "id");
const body = await validateBody(event, seriesTicketPurchaseSchema);
const { name, email, paymentId } = body;
// Fetch series
// Build query conditions based on whether seriesId looks like ObjectId or string
const isObjectId = /^[0-9a-fA-F]{24}$/.test(seriesId);
const seriesQuery = isObjectId
? { $or: [{ _id: seriesId }, { id: seriesId }, { slug: seriesId }] }
: { $or: [{ id: seriesId }, { slug: seriesId }] };
const series = await Series.findOne(seriesQuery);
if (!series) {
throw createError({
statusCode: 404,
statusMessage: "Series not found",
});
}
// Check membership — prefer JWT auth for accurate member pricing.
// Only members with access (active or pending_payment) get member-tier
// pricing; guest, suspended, and cancelled are treated as non-members.
let member = null;
try {
member = await requireAuth(event);
} catch {
// Not authenticated — fall through to email lookup
}
if (!member) {
member = await Member.findOne({ email: email.toLowerCase() });
}
// Resolve canonical email: use authenticated member's email if available
const canonicalEmail = member ? member.email : email.toLowerCase();
const accessMember = hasMemberAccess(member) ? member : null;
// Validate purchase
const validation = validateSeriesTicketPurchase(series, {
email: canonicalEmail,
name,
member: accessMember,
});
if (!validation.valid) {
throw createError({
statusCode: 400,
statusMessage: validation.reason,
});
}
const { ticketInfo } = validation;
// Validate submitted ticket type matches entitlement (prevents price mismatch)
if (body.ticketType && body.ticketType !== ticketInfo.ticketType) {
throw createError({
statusCode: 422,
statusMessage: `Ticket type mismatch: you are entitled to "${ticketInfo.ticketType}" but submitted "${body.ticketType}"`,
})
}
// For paid tickets, require payment ID
if (!ticketInfo.isFree && !paymentId) {
throw createError({
statusCode: 400,
statusMessage: "Payment is required for this series pass",
});
}
// Create series registration
const registration = {
memberId: member?._id,
name,
email: canonicalEmail,
membershipLevel: accessMember?.circle || "non-member",
isMember: !!accessMember,
ticketType: ticketInfo.ticketType,
ticketPrice: ticketInfo.price,
paymentStatus: ticketInfo.isFree ? "not_required" : "completed",
paymentId: paymentId || null,
amountPaid: ticketInfo.price,
registeredAt: new Date(),
eventRegistrations: [],
};
series.registrations.push(registration);
await completeSeriesTicketPurchase(series, ticketInfo.ticketType);
// Get the newly created registration
const newRegistration =
series.registrations[series.registrations.length - 1];
// Fetch all events in this series
const seriesEvents = await Event.find({
"series.id": series.id,
isCancelled: false,
}).sort({ "series.position": 1, startDate: 1 });
// Register user for all events
const eventRegistrations = await registerForAllSeriesEvents(
series,
seriesEvents,
newRegistration,
);
// Send confirmation email
try {
await sendSeriesPassConfirmation({
to: canonicalEmail,
name,
series: {
title: series.title,
description: series.description,
type: series.type,
},
ticket: {
type: ticketInfo.ticketType,
price: ticketInfo.price,
currency: ticketInfo.currency,
isFree: ticketInfo.isFree,
},
events: seriesEvents.map((e) => ({
title: e.title,
startDate: e.startDate,
endDate: e.endDate,
location: e.location,
})),
paymentId,
});
if (member) {
logActivity(member._id, 'email_sent', {
emailType: 'series_pass',
subject: `Series pass: ${series.title}`
})
}
} catch (emailError) {
console.error(
"Failed to send series pass confirmation email:",
emailError,
);
// Don't fail the registration if email fails
}
return {
success: true,
message: "Series pass purchased successfully",
registration: {
id: newRegistration._id,
ticketType: newRegistration.ticketType,
amountPaid: newRegistration.amountPaid,
eventsRegistered: eventRegistrations.filter((r) => r.success).length,
totalEvents: seriesEvents.length,
},
events: eventRegistrations.map((r) => ({
eventId: r.eventId,
success: r.success,
reason: r.reason,
})),
};
} catch (error) {
console.error("Error purchasing series pass:", error);
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || "Failed to purchase series pass",
});
}
});