From 501be10bfec865b70de625368fdd1c6749ec13ba Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Mon, 6 Apr 2026 14:46:11 +0100 Subject: [PATCH] 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. --- app/layouts/admin.vue | 17 + app/pages/accept-invite.vue | 617 +++++++++++++ app/pages/admin/pre-registrants/index.vue | 830 ++++++++++++++++++ server/api/admin/pre-registrants/[id].get.js | 15 + server/api/admin/pre-registrants/[id].put.js | 26 + .../pre-registrants/bulk-status.patch.js | 16 + server/api/admin/pre-registrants/index.get.js | 13 + .../api/admin/pre-registrants/invite.post.js | 98 +++ server/api/admin/pre-registrants/stats.get.js | 19 + server/api/invite/accept.post.js | 108 +++ server/api/invite/verify.post.js | 46 + .../import-babyghosts-preregistrations.js | 13 + server/models/preRegistration.js | 32 + server/utils/schemas.js | 33 + tests/server/api/admin-auth-guards.test.js | 14 +- 15 files changed, 1896 insertions(+), 1 deletion(-) create mode 100644 app/pages/accept-invite.vue create mode 100644 app/pages/admin/pre-registrants/index.vue create mode 100644 server/api/admin/pre-registrants/[id].get.js create mode 100644 server/api/admin/pre-registrants/[id].put.js create mode 100644 server/api/admin/pre-registrants/bulk-status.patch.js create mode 100644 server/api/admin/pre-registrants/index.get.js create mode 100644 server/api/admin/pre-registrants/invite.post.js create mode 100644 server/api/admin/pre-registrants/stats.get.js create mode 100644 server/api/invite/accept.post.js create mode 100644 server/api/invite/verify.post.js create mode 100644 server/models/preRegistration.js diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue index 2708e79..2b8da20 100644 --- a/app/layouts/admin.vue +++ b/app/layouts/admin.vue @@ -18,6 +18,14 @@ Dashboard +
  • + + Pre-Registrants + +
  • +
  • + + Pre-Registrants + +
  • +
    + +
    +
    +

    Verifying your invitation...

    +
    + + +
    +

    Invitation Error

    +
    {{ errorMessage }}
    + Go to Ghost Guild +
    + + +
    +

    Accept Your Invitation

    +

    + Welcome to Ghost Guild. Review your info below, choose your circle and contribution, and you're in. +

    + +
    {{ errorMessage }}
    + +
    +
    +
    + + +
    +
    + + +

    Email cannot be changed. Contact us if you need to use a different email.

    +
    +
    + + +
    +
    + + +
    + +
    + +

    Which circle fits where you are right now?

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + +
    + + +
    + +
    + + +

    Every dollar above $0 goes to the Solidarity Fund. Your contribution is never a gate -- it is a gift.

    +
    + +
    + +
    + +
    + +
    +
    +
    +
    + + +
    +

    Payment Information

    +

    + You're signing up for ${{ form.contributionTier }} CAD / month. +

    + +
    {{ errorMessage }}
    + + +

    Click "Complete Payment" below to open the secure payment modal and verify your payment method.

    +
    + +
    + + +
    +
    + + +
    +

    Welcome to Ghost Guild!

    +

    Your membership is active. Redirecting to your dashboard...

    + Go to Dashboard +
    +
    + + + + + diff --git a/app/pages/admin/pre-registrants/index.vue b/app/pages/admin/pre-registrants/index.vue new file mode 100644 index 0000000..b2c3bcb --- /dev/null +++ b/app/pages/admin/pre-registrants/index.vue @@ -0,0 +1,830 @@ + + + + + diff --git a/server/api/admin/pre-registrants/[id].get.js b/server/api/admin/pre-registrants/[id].get.js new file mode 100644 index 0000000..00372b8 --- /dev/null +++ b/server/api/admin/pre-registrants/[id].get.js @@ -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 +}) diff --git a/server/api/admin/pre-registrants/[id].put.js b/server/api/admin/pre-registrants/[id].put.js new file mode 100644 index 0000000..5ee0a23 --- /dev/null +++ b/server/api/admin/pre-registrants/[id].put.js @@ -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 +}) diff --git a/server/api/admin/pre-registrants/bulk-status.patch.js b/server/api/admin/pre-registrants/bulk-status.patch.js new file mode 100644 index 0000000..81e420e --- /dev/null +++ b/server/api/admin/pre-registrants/bulk-status.patch.js @@ -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 } +}) diff --git a/server/api/admin/pre-registrants/index.get.js b/server/api/admin/pre-registrants/index.get.js new file mode 100644 index 0000000..1510a68 --- /dev/null +++ b/server/api/admin/pre-registrants/index.get.js @@ -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 +}) diff --git a/server/api/admin/pre-registrants/invite.post.js b/server/api/admin/pre-registrants/invite.post.js new file mode 100644 index 0000000..1794f5e --- /dev/null +++ b/server/api/admin/pre-registrants/invite.post.js @@ -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 = `Accept Your Invitation` + const emailHtml = emailTemplate + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\{name\}/g, preReg.name || 'there') + .replace(/(https?:\/\/[^\s<]+)/g, '$1') + .replace(/\n/g, '
    ') + .replace(/\{acceptLink\}/g, acceptButton) + + const { error: emailError } = await resend.emails.send({ + from: 'Ghost Guild ', + 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 } +}) diff --git a/server/api/admin/pre-registrants/stats.get.js b/server/api/admin/pre-registrants/stats.get.js new file mode 100644 index 0000000..3d40354 --- /dev/null +++ b/server/api/admin/pre-registrants/stats.get.js @@ -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 +}) diff --git a/server/api/invite/accept.post.js b/server/api/invite/accept.post.js new file mode 100644 index 0000000..5da1047 --- /dev/null +++ b/server/api/invite/accept.post.js @@ -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, + } + } +}) diff --git a/server/api/invite/verify.post.js b/server/api/invite/verify.post.js new file mode 100644 index 0000000..8d75c72 --- /dev/null +++ b/server/api/invite/verify.post.js @@ -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, + } +}) diff --git a/server/migrations/import-babyghosts-preregistrations.js b/server/migrations/import-babyghosts-preregistrations.js index f2709ea..8b79395 100644 --- a/server/migrations/import-babyghosts-preregistrations.js +++ b/server/migrations/import-babyghosts-preregistrations.js @@ -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}`); diff --git a/server/models/preRegistration.js b/server/models/preRegistration.js new file mode 100644 index 0000000..7812e48 --- /dev/null +++ b/server/models/preRegistration.js @@ -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); diff --git a/server/utils/schemas.js b/server/utils/schemas.js index dc545e3..7105ff2 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -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({ diff --git a/tests/server/api/admin-auth-guards.test.js b/tests/server/api/admin-auth-guards.test.js index 84074b6..c5105bf 100644 --- a/tests/server/api/admin-auth-guards.test.js +++ b/tests/server/api/admin-auth-guards.test.js @@ -31,6 +31,14 @@ const adminRoutes = { 'series/[id].delete.js', 'series/[id].put.js', 'series/tickets.put.js' + ], + 'admin/pre-registrants/': [ + 'pre-registrants/index.get.js', + 'pre-registrants/[id].get.js', + 'pre-registrants/[id].put.js', + 'pre-registrants/bulk-status.patch.js', + 'pre-registrants/invite.post.js', + 'pre-registrants/stats.get.js' ] } @@ -50,7 +58,11 @@ const businessLogicPatterns = [ 'Event.countDocuments', 'Series.find', 'Series.findOne', - 'Series.findById' + 'Series.findById', + 'PreRegistration.find', + 'PreRegistration.findById', + 'PreRegistration.aggregate', + 'PreRegistration.updateMany' ] describe('Admin endpoint auth guards', () => {