106 lines
3.5 KiB
JavaScript
106 lines
3.5 KiB
JavaScript
import jwt from 'jsonwebtoken'
|
|
import { randomUUID } from 'crypto'
|
|
import { Resend } from 'resend'
|
|
import Member from '../../../models/member.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 { memberIds, emailTemplate } = await validateBody(event, memberInviteSchema)
|
|
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 members = await Member.find({ _id: { $in: memberIds } })
|
|
|
|
if (members.length === 0) {
|
|
throw createError({
|
|
statusCode: 404,
|
|
statusMessage: 'No members found for the provided IDs'
|
|
})
|
|
}
|
|
|
|
const results = []
|
|
|
|
for (const member of members) {
|
|
// Skip suspended/cancelled — do not reactivate silently
|
|
if (member.status === 'suspended' || member.status === 'cancelled') {
|
|
results.push({
|
|
memberId: member._id,
|
|
email: member.email,
|
|
success: false,
|
|
error: `Skipped: account is ${member.status}`,
|
|
})
|
|
continue
|
|
}
|
|
|
|
try {
|
|
// Generate single-use invite token (48h), same jti pattern as login.post.js
|
|
const jti = randomUUID()
|
|
const token = jwt.sign(
|
|
{ memberId: member._id, jti },
|
|
config.jwtSecret,
|
|
{ expiresIn: '48h' },
|
|
)
|
|
|
|
// Store jti for single-use enforcement in verify.post.js
|
|
member.magicLinkJti = jti
|
|
member.magicLinkJtiUsed = false
|
|
|
|
// Token in fragment — never hits server logs
|
|
const loginLink = `${baseUrl}/verify#${token}`
|
|
|
|
// Interpolate template variables
|
|
const emailText = emailTemplate
|
|
.replace(/\{name\}/g, member.name)
|
|
.replace(/\{loginLink\}/g, loginLink)
|
|
.replace(/\{circle\}/g, member.circle)
|
|
|
|
// Build HTML version: escape user content, linkify plain URLs, then insert button (unescaped) last
|
|
const loginButton = `<a href="${loginLink}" style="display:inline-block;padding:12px 24px;background-color:#d4a017;color:#1a1a1a;text-decoration:none;border-radius:6px;font-weight:bold;">Sign in to Ghost Guild</a>`
|
|
const emailHtml = emailTemplate
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\{name\}/g, member.name)
|
|
.replace(/\{circle\}/g, member.circle)
|
|
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1">$1</a>')
|
|
.replace(/\n/g, '<br>')
|
|
.replace(/\{loginLink\}/g, loginButton)
|
|
|
|
const { error: emailError } = await resend.emails.send({
|
|
from: 'Ghost Guild <welcome@babyghosts.org>',
|
|
to: [member.email],
|
|
subject: "You're invited to Ghost Guild",
|
|
text: emailText,
|
|
html: emailHtml,
|
|
})
|
|
|
|
if (emailError) {
|
|
results.push({ memberId: member._id, email: member.email, success: false, error: emailError.message })
|
|
continue
|
|
}
|
|
|
|
// Mark member as active and record invite sent
|
|
member.status = 'active'
|
|
member.inviteEmailSent = true
|
|
member.inviteEmailSentAt = new Date()
|
|
await member.save()
|
|
|
|
results.push({ memberId: member._id, email: member.email, success: true })
|
|
} catch (err) {
|
|
results.push({ memberId: member._id, email: member.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 }
|
|
})
|