ghostguild-org/server/api/helcim/customer.post.js
Jennie Robinson Faber 51230e5151 refactor(launch): simplify launch-readiness fixes
Follow-up to 208638e. Code review surfaced a few real issues; this
commit addresses them.

- login.post.js now uses the new sendMagicLink util instead of
  duplicating the jti/jwt/Resend/logActivity logic. Reduces 60 lines.
- sendMagicLink accepts an optional pre-loaded Member doc, skipping
  the redundant findOne when the caller already has one. customer.post.js
  passes the just-created/upgraded member, dropping signup from 3
  Mongo round-trips to 1 (lookup is gone; jti burn remains).
- sendMagicLink now lowercases the email defensively so callers don't
  have to remember.
- rateLimit.js: replaced an effectively-dead eviction line with a
  probabilistic sweep (~1% of calls scan and evict keys whose newest
  entry has aged out). Caps unbounded Map growth under random-key
  spraying.
- reconcile-payments.post.js: 401/403/404 from Helcim now bails out
  immediately instead of burning all 3 retry attempts; dry-run
  summary filters via the same RECONCILABLE_STATUSES set as apply
  mode so counts match.
- Deleted WHAT-comments and section banners per CLAUDE.md no-comment
  rule. Kept genuine WHY-comments (validateBeforeSave rationale,
  amount-IGNORED-for-tickets, sendConfirmation deliberately-omitted).

Tests: 758/760 passing (unchanged).
2026-04-25 19:34:16 +01:00

118 lines
4.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 { 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'
})
}
})