feat: pre-registrant management and invitation system

Admin interface to review, filter, and batch-invite the 95 pre-registrants
from Baby Ghosts. Accept-invitation page pre-fills their data and collects
circle, pronouns, motivation, contribution tier, and agreement before
creating their member record.
This commit is contained in:
Jennie Robinson Faber 2026-04-06 14:46:11 +01:00
parent bab53cec9e
commit 501be10bfe
15 changed files with 1896 additions and 1 deletions

View file

@ -0,0 +1,15 @@
import PreRegistration from '../../../models/preRegistration.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const id = getRouterParam(event, 'id')
await connectDB()
const preRegistrant = await PreRegistration.findById(id).lean()
if (!preRegistrant) {
throw createError({ statusCode: 404, statusMessage: 'Pre-registrant not found' })
}
return preRegistrant
})

View file

@ -0,0 +1,26 @@
import PreRegistration from '../../../models/preRegistration.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const id = getRouterParam(event, 'id')
const body = await validateBody(event, preRegistrantStatusUpdateSchema)
await connectDB()
const existing = await PreRegistration.findById(id)
if (!existing) {
throw createError({ statusCode: 404, statusMessage: 'Pre-registrant not found' })
}
// Only allow status changes to pending/selected (invite/accept are handled by other endpoints)
if (existing.status === 'accepted') {
throw createError({ statusCode: 400, statusMessage: 'Cannot modify an accepted pre-registrant' })
}
const update = {}
if (body.status !== undefined) update.status = body.status
if (body.adminNotes !== undefined) update.adminNotes = body.adminNotes
const updated = await PreRegistration.findByIdAndUpdate(id, update, { new: true }).lean()
return updated
})

View file

@ -0,0 +1,16 @@
import PreRegistration from '../../../models/preRegistration.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const { ids, status } = await validateBody(event, preRegistrantBulkStatusSchema)
await connectDB()
// Only update pre-registrants that aren't already accepted
const result = await PreRegistration.updateMany(
{ _id: { $in: ids }, status: { $nin: ['accepted'] } },
{ $set: { status } }
)
return { modified: result.modifiedCount, total: ids.length }
})

View file

@ -0,0 +1,13 @@
import PreRegistration from '../../../models/preRegistration.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
await connectDB()
const preRegistrants = await PreRegistration.find()
.sort({ createdAt: -1 })
.lean()
return preRegistrants
})

View file

@ -0,0 +1,98 @@
import jwt from 'jsonwebtoken'
import { randomUUID } from 'crypto'
import { Resend } from 'resend'
import PreRegistration from '../../../models/preRegistration.js'
import { connectDB } from '../../../utils/mongoose.js'
const resend = new Resend(process.env.RESEND_API_KEY)
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const { preRegistrantIds, emailTemplate } = await validateBody(event, preRegistrantInviteSchema)
await connectDB()
const baseUrl = process.env.BASE_URL
if (!baseUrl) {
throw createError({ statusCode: 500, statusMessage: 'BASE_URL environment variable is not set' })
}
const config = useRuntimeConfig(event)
const preRegs = await PreRegistration.find({ _id: { $in: preRegistrantIds } })
if (preRegs.length === 0) {
throw createError({ statusCode: 404, statusMessage: 'No pre-registrants found for the provided IDs' })
}
const results = []
for (const preReg of preRegs) {
// Only send to selected pre-registrants (skip already invited/accepted/expired)
if (preReg.status !== 'selected' && preReg.status !== 'pending') {
results.push({
preRegistrantId: preReg._id,
email: preReg.email,
success: false,
error: `Skipped: status is ${preReg.status}`,
})
continue
}
try {
const jti = randomUUID()
const token = jwt.sign(
{ preRegistrationId: preReg._id.toString(), jti, type: 'prereg-invite' },
config.jwtSecret,
{ expiresIn: '48h' },
)
// Token in fragment — never hits server logs
const acceptLink = `${baseUrl}/accept-invite#${token}`
const emailText = emailTemplate
.replace(/\{name\}/g, preReg.name || 'there')
.replace(/\{acceptLink\}/g, acceptLink)
// Build HTML version
const acceptButton = `<a href="${acceptLink}" style="display:inline-block;padding:12px 24px;background-color:#d4a017;color:#1a1a1a;text-decoration:none;border-radius:6px;font-weight:bold;">Accept Your Invitation</a>`
const emailHtml = emailTemplate
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\{name\}/g, preReg.name || 'there')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1">$1</a>')
.replace(/\n/g, '<br>')
.replace(/\{acceptLink\}/g, acceptButton)
const { error: emailError } = await resend.emails.send({
from: 'Ghost Guild <welcome@babyghosts.org>',
to: [preReg.email],
subject: "You're invited to Ghost Guild",
text: emailText,
html: emailHtml,
})
if (emailError) {
results.push({ preRegistrantId: preReg._id, email: preReg.email, success: false, error: emailError.message })
continue
}
await PreRegistration.findByIdAndUpdate(preReg._id, {
$set: {
magicLinkJti: jti,
magicLinkJtiUsed: false,
status: 'invited',
inviteEmailSentAt: new Date(),
},
})
results.push({ preRegistrantId: preReg._id, email: preReg.email, success: true })
} catch (err) {
results.push({ preRegistrantId: preReg._id, email: preReg.email, success: false, error: err.message })
}
}
const sent = results.filter(r => r.success).length
const failed = results.filter(r => !r.success).length
return { sent, failed, results }
})

View file

@ -0,0 +1,19 @@
import PreRegistration from '../../../models/preRegistration.js'
import { connectDB } from '../../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await requireAdmin(event)
await connectDB()
const counts = await PreRegistration.aggregate([
{ $group: { _id: '$status', count: { $sum: 1 } } }
])
const stats = { total: 0, pending: 0, selected: 0, invited: 0, accepted: 0, expired: 0 }
for (const { _id, count } of counts) {
if (_id in stats) stats[_id] = count
stats.total += count
}
return stats
})

View file

@ -0,0 +1,108 @@
import jwt from 'jsonwebtoken'
import PreRegistration from '../../models/preRegistration.js'
import Member from '../../models/member.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
const body = await validateBody(event, inviteAcceptSchema)
const config = useRuntimeConfig(event)
await connectDB()
// Re-verify the token is still valid (not expired)
let decoded
try {
decoded = jwt.verify(body.token, config.jwtSecret)
} catch {
throw createError({ statusCode: 401, statusMessage: 'Invalid or expired invitation link' })
}
if (decoded.type !== 'prereg-invite' || decoded.preRegistrationId !== body.preRegistrationId) {
throw createError({ statusCode: 401, statusMessage: 'Invalid or expired invitation link' })
}
const preReg = await PreRegistration.findById(body.preRegistrationId)
if (!preReg) {
throw createError({ statusCode: 404, statusMessage: 'Pre-registration not found' })
}
if (preReg.status === 'accepted') {
throw createError({ statusCode: 400, statusMessage: 'This invitation has already been accepted' })
}
// Check no existing member with this email
const existingMember = await Member.findOne({ email: preReg.email })
if (existingMember) {
throw createError({ statusCode: 409, statusMessage: 'A member with this email already exists' })
}
// Create the member
const member = await Member.create({
email: preReg.email,
name: body.name,
pronouns: body.pronouns || undefined,
location: body.location || undefined,
circle: body.circle,
contributionTier: body.contributionTier,
bio: body.motivation || undefined,
status: body.contributionTier === '0' ? 'active' : 'pending_payment',
})
// Update pre-registration
await PreRegistration.findByIdAndUpdate(preReg._id, {
$set: {
status: 'accepted',
acceptedAt: new Date(),
memberId: member._id,
}
})
logActivity(member._id, 'member_joined', {
source: 'pre-registration',
preRegistrationId: preReg._id,
})
// For free tier, issue session and redirect to welcome
if (body.contributionTier === '0') {
const sessionToken = jwt.sign(
{ memberId: member._id, email: member.email, tv: member.tokenVersion },
config.jwtSecret,
{ expiresIn: '7d' },
)
setCookie(event, 'auth-token', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
return {
success: true,
requiresPayment: false,
redirectUrl: '/welcome',
member: {
id: member._id,
email: member.email,
name: member.name,
circle: member.circle,
contributionTier: member.contributionTier,
status: member.status,
}
}
}
// For paid tiers, return member info so frontend can proceed to Helcim payment
return {
success: true,
requiresPayment: true,
member: {
id: member._id,
email: member.email,
name: member.name,
circle: member.circle,
contributionTier: member.contributionTier,
status: member.status,
}
}
})

View file

@ -0,0 +1,46 @@
import jwt from 'jsonwebtoken'
import PreRegistration from '../../models/preRegistration.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
const { token } = await validateBody(event, inviteVerifySchema)
const config = useRuntimeConfig(event)
await connectDB()
let decoded
try {
decoded = jwt.verify(token, config.jwtSecret)
} catch {
throw createError({ statusCode: 401, statusMessage: 'Invalid or expired invitation link' })
}
if (decoded.type !== 'prereg-invite') {
throw createError({ statusCode: 401, statusMessage: 'Invalid or expired invitation link' })
}
const preReg = await PreRegistration.findById(decoded.preRegistrationId)
if (!preReg) {
throw createError({ statusCode: 401, statusMessage: 'Invalid or expired invitation link' })
}
if (preReg.status === 'accepted') {
throw createError({ statusCode: 400, statusMessage: 'This invitation has already been accepted' })
}
// Single-use enforcement
if (!decoded.jti || decoded.jti !== preReg.magicLinkJti || preReg.magicLinkJtiUsed) {
throw createError({ statusCode: 401, statusMessage: 'Invalid or expired invitation link' })
}
// Burn the token
await PreRegistration.findByIdAndUpdate(preReg._id, {
$set: { magicLinkJtiUsed: true }
})
return {
preRegistrationId: preReg._id,
name: preReg.name,
email: preReg.email,
city: preReg.city,
}
})