ghostguild-org/server/api/helcim/customer.post.js
Jennie Robinson Faber 9b79ae6bf4 refactor(auth): rename paymentBridge → signupBridge
After commit 90acc35 issued the cookie for $0 signups too, the "payment"
framing was wrong — there's no payment in a $0 signup. The cookie is
about bridging the gap between signup-form submit and email verify, not
about payment specifically.

Changes:
- setPaymentBridgeCookie  → setSignupBridgeCookie
- getPaymentBridgeMember  → getSignupBridgeMember
- Cookie wire name        payment-bridge → signup-bridge
- JWT scope               payment_bridge → signup_bridge

Touches both /api/helcim/subscription (signup activation) and
/api/helcim/initialize-payment (paid Helcim checkout) which both consume
the cookie. In-flight signup sessions started before this lands will
need to re-submit the form (cookie name mismatch); cutover hasn't
happened yet, so the only impact is local dev sessions.
2026-04-30 15:31:54 +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 { setSignupBridgeCookie } 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 signup-bridge cookie that lets
// /api/helcim/initialize-payment and /api/helcim/subscription identify
// the member without a verified auth session.
setSignupBridgeCookie(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'
})
}
})