ghostguild-org/server/api/helcim/customer.post.js
Jennie Robinson Faber da5e7efcb7 fix(launch-flow): auto-link /join signups to existing PreRegistration
When a /join submitter's email matches a pending/selected/invited
PreRegistration, mark the pre-reg as accepted and link memberId to the
new Member. Prevents the same person from appearing as both an active
member and an unaccepted pre-registrant. Silent — no email, no UI.

Adds the PreRegistration mock to helcim-customer and free-signup-flow
test suites, since both invoke the customer handler at runtime.
2026-04-30 14:43:02 +01:00

146 lines
5.1 KiB
JavaScript

import { getRequestHeader, getRequestIP } from 'h3'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
import { createHelcimCustomer } from '../../utils/helcim.js'
import PreRegistration from '../../models/preRegistration.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,
helcimCustomerCode: customerData.customerCode,
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,
helcimCustomerCode: customerData.customerCode,
status: 'pending_payment',
agreement: { acceptedAt: new Date() }
})
}
// If this email matches a pending pre-registrant, mark the PreRegistration
// as accepted and link it to the new Member. Silent — keeps /join and
// /admin/pre-registrants from showing the same person twice.
try {
const preReg = await PreRegistration.findOne({ email: normalizedEmail })
if (
preReg &&
!preReg.memberId &&
['pending', 'selected', 'invited'].includes(preReg.status)
) {
await PreRegistration.findByIdAndUpdate(
preReg._id,
{
$set: {
status: 'accepted',
acceptedAt: new Date(),
memberId: member._id,
},
},
{ runValidators: false }
)
}
} catch (linkError) {
console.error('Failed to link PreRegistration to new member:', linkError)
}
await sendMagicLink(normalizedEmail, {
subject: 'Verify your Ghost Guild signup',
intro: 'Verify your email to finish your Ghost Guild signup:',
member
})
// Signup completes (paid checkout or free activation) before the magic
// link is clicked, so issue a short-lived, payment-only bridge cookie
// that lets /api/helcim/initialize-payment and /api/helcim/subscription
// identify the member without a verified auth session.
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'
})
}
})