ghostguild-org/server/api/events/[id]/tickets/purchase.post.js
Jennie Robinson Faber f34b062f2a fix(events): enforce series-pass, hidden, and deadline gates
Pre-launch P0 fixes surfaced by docs/specs/events-functional-test-matrix.md
(Findings 1, 2, 3).

1. Series-pass bypass (Finding 1 / matrix S1 P3): register.post.js now
   loads the linked Series when tickets.requiresSeriesTicket is set and
   rejects drop-in registration unless series.allowIndividualEventTickets
   is true or the user has a valid pass. Data-integrity 500 if the
   referenced series is missing.

2. Hidden-event leak (Finding 2 / matrix E11): extract loadPublicEvent
   into server/utils/loadEvent.js. All five public event endpoints
   ([id].get, register, tickets/available, tickets/reserve,
   tickets/purchase) now go through the helper, which 404s when
   isVisible === false and the requester is not an admin. Admin detection
   uses a new non-throwing getOptionalMember() in server/utils/auth.js
   (extracted from the pattern already inlined in api/auth/status.get.js).

3. Deadline enforcement + legacy pricing retirement (Finding 3 / matrix
   E8): register.post.js and tickets/reserve.post.js delegate gating to
   validateTicketPurchase (which already covers deadline, cancelled,
   started, members-only, sold-out, and already-registered);
   tickets/available.get.js gets an explicit registrationDeadline check.
   Legacy pricing.paymentRequired 402 branch removed from register.post.js.
2026-04-20 19:03:34 +01:00

169 lines
5.4 KiB
JavaScript

import Member from "../../../../models/member.js";
import { loadPublicEvent } from "../../../../utils/loadEvent.js";
import { validateBody } from "../../../../utils/validateBody.js";
import { ticketPurchaseSchema } from "../../../../utils/schemas.js";
import {
validateTicketPurchase,
completeTicketPurchase,
hasMemberAccess,
} from "../../../../utils/tickets.js";
import { sendEventRegistrationEmail } from "../../../../utils/resend.js";
/**
* POST /api/events/[id]/tickets/purchase
* Purchase a ticket for an event
* Body: { name, email, paymentToken? }
*/
export default defineEventHandler(async (event) => {
try {
const identifier = getRouterParam(event, "id");
const body = await validateBody(event, ticketPurchaseSchema);
const eventData = await loadPublicEvent(event, identifier);
// 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",
contributionAmount: 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.contributionAmount}`
: "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.",
});
}
});