ghostguild-org/server/api/events/[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

192 lines
5.9 KiB
JavaScript

import Event from "../../../../models/event.js";
import Member from "../../../../models/member.js";
import { connectDB } from "../../../../utils/mongoose.js";
import {
validateTicketPurchase,
calculateTicketPrice,
completeTicketPurchase,
hasMemberAccess,
} from "../../../../utils/tickets.js";
import { sendEventRegistrationEmail } from "../../../../utils/resend.js";
import mongoose from "mongoose";
/**
* POST /api/events/[id]/tickets/purchase
* Purchase a ticket for an event
* Body: { name, email, paymentToken? }
*/
export default defineEventHandler(async (event) => {
try {
await connectDB();
const identifier = getRouterParam(event, "id");
const body = await validateBody(event, ticketPurchaseSchema);
if (!identifier) {
throw createError({
statusCode: 400,
statusMessage: "Event identifier is required",
});
}
// Fetch the event
let eventData;
if (mongoose.Types.ObjectId.isValid(identifier)) {
eventData = await Event.findById(identifier);
}
if (!eventData) {
eventData = await Event.findOne({ slug: identifier });
}
if (!eventData) {
throw createError({
statusCode: 404,
statusMessage: "Event not found",
});
}
// Check if user is a member. Only members with access (active or
// pending_payment) count for pricing/validation; guest, suspended,
// and cancelled members are treated as non-members.
let member = await Member.findOne({ email: body.email.toLowerCase() });
let accountCreated = false;
// Validate ticket purchase
const validation = validateTicketPurchase(eventData, {
email: body.email,
name: body.name,
member: hasMemberAccess(member) ? member : null,
});
if (!validation.valid) {
throw createError({
statusCode: 400,
statusMessage: validation.reason,
data: {
waitlistAvailable: validation.waitlistAvailable,
},
});
}
const { ticketInfo } = validation;
const requiresPayment = ticketInfo.price > 0;
// Handle payment if required
let transactionId = null;
if (requiresPayment) {
// For HelcimPay.js with purchase type, the transaction is already completed
// We just need to verify we received the transaction ID
if (!body.transactionId) {
throw createError({
statusCode: 400,
statusMessage:
"Transaction ID is required. Payment must be completed first.",
});
}
transactionId = body.transactionId;
// Optional: Verify the transaction with Helcim API
// This adds extra security to ensure the transaction is legitimate
// For now, we trust the transaction ID from HelcimPay.js
}
// If no Member yet and the user consented, atomically create a guest Member.
// findOneAndUpdate with $setOnInsert handles concurrent registrations on the
// same email (email has a unique index).
if (!member && body.createAccount) {
member = await Member.findOneAndUpdate(
{ email: body.email.toLowerCase() },
{
$setOnInsert: {
email: body.email.toLowerCase(),
name: body.name,
circle: "community",
contributionTier: "0",
status: "guest",
},
},
{ upsert: true, new: true, setDefaultsOnInsert: true }
);
accountCreated = true;
}
// Create registration
const memberHasAccess = hasMemberAccess(member);
const registration = {
memberId: member ? member._id : null,
name: body.name,
email: body.email.toLowerCase(),
membershipLevel: memberHasAccess
? `${member.circle}-${member.contributionTier}`
: "non-member",
isMember: memberHasAccess,
ticketType: ticketInfo.ticketType,
ticketPrice: ticketInfo.price,
paymentStatus: requiresPayment ? "completed" : "not_required",
paymentId: transactionId,
amountPaid: ticketInfo.price,
registeredAt: new Date(),
};
// Add registration to event
eventData.registrations.push(registration);
// Complete ticket purchase (updates sold/reserved counts)
await completeTicketPurchase(eventData, ticketInfo.ticketType);
// Save event with registration; skip validators to avoid tripping on
// legacy location data unrelated to this write.
await eventData.save({ validateBeforeSave: false });
// Decide on auto-login: safe for new accounts and existing guests, not for
// real members (stranger could hijack by typing email into a public form).
let signedIn = false;
let requiresSignIn = false;
if (member && (accountCreated || member.status === "guest")) {
setAuthCookie(event, member);
signedIn = true;
} else if (member) {
requiresSignIn = true;
}
// Send confirmation email
try {
await sendEventRegistrationEmail(registration, eventData, { requiresSignIn });
} catch (emailError) {
console.error("Failed to send confirmation email:", emailError);
// Don't fail the registration if email fails
}
return {
success: true,
message: "Ticket purchased successfully!",
registration: {
id: eventData.registrations[eventData.registrations.length - 1]._id,
name: registration.name,
email: registration.email,
ticketType: registration.ticketType,
amountPaid: registration.amountPaid,
},
accountCreated,
signedIn,
payment: transactionId
? {
transactionId: transactionId,
amount: ticketInfo.price,
currency: ticketInfo.currency,
}
: null,
};
} catch (error) {
console.error("Error purchasing ticket:", error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to purchase ticket. Please try again.",
});
}
});