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:
parent
bab53cec9e
commit
501be10bfe
15 changed files with 1896 additions and 1 deletions
15
server/api/admin/pre-registrants/[id].get.js
Normal file
15
server/api/admin/pre-registrants/[id].get.js
Normal 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
|
||||
})
|
||||
26
server/api/admin/pre-registrants/[id].put.js
Normal file
26
server/api/admin/pre-registrants/[id].put.js
Normal 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
|
||||
})
|
||||
16
server/api/admin/pre-registrants/bulk-status.patch.js
Normal file
16
server/api/admin/pre-registrants/bulk-status.patch.js
Normal 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 }
|
||||
})
|
||||
13
server/api/admin/pre-registrants/index.get.js
Normal file
13
server/api/admin/pre-registrants/index.get.js
Normal 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
|
||||
})
|
||||
98
server/api/admin/pre-registrants/invite.post.js
Normal file
98
server/api/admin/pre-registrants/invite.post.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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 }
|
||||
})
|
||||
19
server/api/admin/pre-registrants/stats.get.js
Normal file
19
server/api/admin/pre-registrants/stats.get.js
Normal 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
|
||||
})
|
||||
108
server/api/invite/accept.post.js
Normal file
108
server/api/invite/accept.post.js
Normal 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,
|
||||
}
|
||||
}
|
||||
})
|
||||
46
server/api/invite/verify.post.js
Normal file
46
server/api/invite/verify.post.js
Normal 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,
|
||||
}
|
||||
})
|
||||
|
|
@ -92,6 +92,19 @@ async function run() {
|
|||
}
|
||||
}
|
||||
|
||||
// Normalize: ensure all docs have status field for Mongoose model compatibility
|
||||
if (!DRY_RUN) {
|
||||
const normalized = await dest.updateMany(
|
||||
{ status: { $exists: false } },
|
||||
{ $set: { status: "pending" } },
|
||||
);
|
||||
if (normalized.modifiedCount > 0) {
|
||||
console.log(
|
||||
`\nNormalized ${normalized.modifiedCount} record(s) with missing status field.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n=== Summary ===");
|
||||
console.log(` Inserted : ${inserted}`);
|
||||
console.log(` Skipped : ${skipped}`);
|
||||
|
|
|
|||
32
server/models/preRegistration.js
Normal file
32
server/models/preRegistration.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import mongoose from "mongoose";
|
||||
|
||||
const preRegistrationSchema = new mongoose.Schema(
|
||||
{
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
lowercase: true,
|
||||
trim: true,
|
||||
},
|
||||
name: String,
|
||||
city: String,
|
||||
role: String, // professional role (e.g. "3D artist") — admin-only reference
|
||||
newsletterOptIn: Boolean,
|
||||
status: {
|
||||
type: String,
|
||||
enum: ["pending", "selected", "invited", "accepted", "expired"],
|
||||
default: "pending",
|
||||
},
|
||||
inviteEmailSentAt: Date,
|
||||
acceptedAt: Date,
|
||||
memberId: { type: mongoose.Schema.Types.ObjectId, ref: "Member" },
|
||||
magicLinkJti: String,
|
||||
magicLinkJtiUsed: { type: Boolean, default: false },
|
||||
adminNotes: String,
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export default mongoose.models.PreRegistration ||
|
||||
mongoose.model("PreRegistration", preRegistrationSchema);
|
||||
|
|
@ -351,6 +351,39 @@ export const memberInviteSchema = z.object({
|
|||
emailTemplate: z.string().min(1).max(10000)
|
||||
})
|
||||
|
||||
// --- Pre-registrant schemas ---
|
||||
|
||||
export const preRegistrantStatusUpdateSchema = z.object({
|
||||
status: z.enum(['pending', 'selected']),
|
||||
adminNotes: z.string().max(2000).optional()
|
||||
})
|
||||
|
||||
export const preRegistrantBulkStatusSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(100),
|
||||
status: z.enum(['pending', 'selected'])
|
||||
})
|
||||
|
||||
export const preRegistrantInviteSchema = z.object({
|
||||
preRegistrantIds: z.array(z.string().min(1)).min(1).max(20),
|
||||
emailTemplate: z.string().min(1).max(10000)
|
||||
})
|
||||
|
||||
export const inviteVerifySchema = z.object({
|
||||
token: z.string().min(1)
|
||||
})
|
||||
|
||||
export const inviteAcceptSchema = z.object({
|
||||
preRegistrationId: z.string().min(1),
|
||||
name: z.string().min(1).max(200),
|
||||
pronouns: z.string().max(100).optional(),
|
||||
location: z.string().max(200).optional(),
|
||||
circle: z.enum(['community', 'founder', 'practitioner']),
|
||||
motivation: z.string().max(5000).optional(),
|
||||
contributionTier: z.enum(['0', '5', '15', '30', '50']),
|
||||
agreedToTerms: z.literal(true),
|
||||
token: z.string().min(1)
|
||||
})
|
||||
|
||||
// --- Tag schemas ---
|
||||
|
||||
export const tagSuggestionSchema = z.object({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue