import { getRequestHeader, getRequestIP } from 'h3' import Member from '../../models/member.js' import { connectDB } from '../../utils/mongoose.js' import { createHelcimCustomer } from '../../utils/helcim.js' import { sendMagicLink } from '../../utils/magicLink.js' import { setPaymentBridgeCookie } from '../../utils/auth.js' import { rateLimit } from '../../utils/rateLimit.js' export default defineEventHandler(async (event) => { try { const origin = getRequestHeader(event, 'origin') const allowed = process.env.BASE_URL if (!origin || (allowed && origin !== allowed)) { throw createError({ statusCode: 403, statusMessage: 'Invalid origin' }) } const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown' if (!rateLimit(`signup:ip:${ip}`, { max: 5, windowMs: 3600_000 })) { throw createError({ statusCode: 429, statusMessage: 'Too many signup attempts' }) } await connectDB() const body = await validateBody(event, helcimCustomerSchema) if (!rateLimit(`signup:email:${body.email}`, { max: 3, windowMs: 3600_000 })) { throw createError({ statusCode: 429, statusMessage: 'Too many signup attempts for this email' }) } // Check if member already exists. Lowercase the lookup so guest docs // created via the public ticket-purchase path (which lowercases on insert) // are actually found by mixed-case submissions. const normalizedEmail = body.email.toLowerCase() const existingMember = await Member.findOne({ email: normalizedEmail }) if (existingMember && existingMember.status !== 'guest') { throw createError({ statusCode: 409, statusMessage: 'A member with this email already exists' }) } // Create customer in Helcim (guest docs have no helcimCustomerId yet). const customerData = await createHelcimCustomer({ customerType: 'PERSON', contactName: body.name, email: body.email }) // If the lookup matched a guest doc, upgrade in place to preserve _id, // memberNumber (if any), emailHistory, and the event-registration // references that point at this _id. Use findByIdAndUpdate with // runValidators:false per the project's member-save-risks pattern. let member if (existingMember) { member = await Member.findByIdAndUpdate( existingMember._id, { $set: { name: body.name, circle: body.circle, contributionAmount: body.contributionAmount, helcimCustomerId: customerData.id, status: 'pending_payment', 'agreement.acceptedAt': new Date() } }, { new: true, runValidators: false } ) } else { member = await Member.create({ email: normalizedEmail, name: body.name, circle: body.circle, contributionAmount: body.contributionAmount, helcimCustomerId: customerData.id, status: 'pending_payment', agreement: { acceptedAt: new Date() } }) } await sendMagicLink(normalizedEmail, { subject: 'Verify your Ghost Guild signup', intro: 'Verify your email to finish your Ghost Guild signup:', member }) // Paid-tier signups need to complete Helcim checkout in the same tab // before the magic link can be clicked. Issue a short-lived, payment-only // bridge cookie so /api/helcim/initialize-payment accepts the request. if (body.contributionAmount > 0) { setPaymentBridgeCookie(event, member) } return { success: true, customerId: customerData.id, customerCode: customerData.customerCode, verificationEmailSent: true, member: { id: member._id, email: normalizedEmail, name: member.name, circle: member.circle, contributionAmount: member.contributionAmount, status: member.status } } } catch (error) { if (error.statusCode) throw error console.error('Error creating Helcim customer:', error) throw createError({ statusCode: 500, statusMessage: 'An unexpected error occurred' }) } })