Add waitlist invitation sending from admin

This commit is contained in:
Remi
2025-10-07 12:29:21 +02:00
committed by itsrubberduck
parent fd642ebad1
commit 0bcaf85c23
9 changed files with 303 additions and 14 deletions

View File

@@ -0,0 +1,80 @@
import { createError, defineEventHandler, readBody } from 'h3'
import { requireAdmin } from '../../../../utils/auth'
import { WaitlistEntry } from '../../../../models/WaitlistEntry'
import { InvitationCode } from '../../../../models/InvitationCode'
import { sendMail } from '../../../../utils/notifications'
import { generateInvitationCode, renderInvitationEmail, renderInvitationText } from '../../../../utils/invitations'
interface SendInviteBody {
resend?: boolean
}
export default defineEventHandler(async (event) => {
const admin = await requireAdmin(event)
const id = event.context.params?.id
if (!id) {
throw createError({ statusCode: 400, statusMessage: 'Missing waitlist entry ID' })
}
await readBody<SendInviteBody>(event).catch(() => ({}) as SendInviteBody)
const entry = await WaitlistEntry.findById(id).exec()
if (!entry) {
throw createError({ statusCode: 404, statusMessage: 'Waitlist entry not found' })
}
const email = entry.email?.trim()
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'Waitlist entry has no email address' })
}
let invitation = entry.invitationCode
? await InvitationCode.findById(entry.invitationCode)
: null
if (invitation?.usedAt) {
throw createError({ statusCode: 400, statusMessage: 'Invitation already used — user is registered' })
}
const now = new Date()
if (!invitation) {
const code = generateInvitationCode()
invitation = await InvitationCode.create({
code,
createdAt: now,
channel: 'admin',
label: `Waitlist: ${email}`,
createdBy: admin._id,
})
entry.invitationCode = invitation._id
}
entry.invitationSentAt = now
await entry.save()
const html = await renderInvitationEmail(invitation.code)
const text = renderInvitationText(invitation.code)
await sendMail({
to: email,
subject: 'Your OpenSquawk invite code',
text,
html,
})
return {
success: true,
invitation: {
id: String(invitation._id),
code: invitation.code,
channel: invitation.channel,
label: invitation.label || undefined,
createdAt: invitation.createdAt?.toISOString() ?? now.toISOString(),
expiresAt: invitation.expiresAt ? invitation.expiresAt.toISOString() : undefined,
sentAt: entry.invitationSentAt?.toISOString() ?? now.toISOString(),
usedAt: invitation.usedAt ? invitation.usedAt.toISOString() : undefined,
},
}
})

View File

@@ -2,6 +2,7 @@ import { defineEventHandler, getQuery } from 'h3'
import type { FilterQuery } from 'mongoose'
import { requireAdmin } from '../../../utils/auth'
import { WaitlistEntry, type WaitlistEntryDocument } from '../../../models/WaitlistEntry'
import type { InvitationCodeDocument } from '../../../models/InvitationCode'
type WaitlistListItem = {
id: string
@@ -13,23 +14,58 @@ type WaitlistListItem = {
activatedAt?: string
wantsProductUpdates: boolean
updatesOptedInAt?: string
invitationSentAt?: string
invitation?: {
id: string
code: string
channel: string
label?: string
createdAt: string
expiresAt?: string
usedAt?: string
sentAt?: string
}
}
function escapeRegExp(input: string) {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function normalizeDate(value?: Date | string | null) {
if (!value) return undefined
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return undefined
return date.toISOString()
}
function mapWaitlistEntry(doc: any): WaitlistListItem {
const invitationDoc = doc.invitationCode as InvitationCodeDocument | undefined
const sentAt = normalizeDate(doc.invitationSentAt)
return {
id: String(doc._id),
email: doc.email,
name: doc.name || undefined,
notes: doc.notes || undefined,
source: doc.source || undefined,
joinedAt: doc.joinedAt ? new Date(doc.joinedAt).toISOString() : new Date().toISOString(),
activatedAt: doc.activatedAt ? new Date(doc.activatedAt).toISOString() : undefined,
joinedAt: normalizeDate(doc.joinedAt) || new Date().toISOString(),
activatedAt: normalizeDate(doc.activatedAt),
wantsProductUpdates: Boolean(doc.wantsProductUpdates),
updatesOptedInAt: doc.updatesOptedInAt ? new Date(doc.updatesOptedInAt).toISOString() : undefined,
updatesOptedInAt: normalizeDate(doc.updatesOptedInAt),
invitationSentAt: sentAt,
invitation:
invitationDoc && invitationDoc.code
? {
id: String(invitationDoc._id),
code: invitationDoc.code,
channel: invitationDoc.channel,
label: invitationDoc.label || undefined,
createdAt: normalizeDate(invitationDoc.createdAt) || new Date().toISOString(),
expiresAt: normalizeDate(invitationDoc.expiresAt),
usedAt: normalizeDate(invitationDoc.usedAt),
sentAt,
}
: undefined,
}
}
@@ -89,6 +125,7 @@ export default defineEventHandler(async (event) => {
.sort({ joinedAt: -1 })
.skip(skip)
.limit(pageSize)
.populate('invitationCode')
.lean(),
WaitlistEntry.countDocuments(),
WaitlistEntry.countDocuments({ wantsProductUpdates: true }),

View File

@@ -70,7 +70,10 @@ export default defineEventHandler(async (event) => {
invitation.usedAt = now
await invitation.save()
await WaitlistEntry.findOneAndUpdate({ email }, { activatedAt: now }).catch(() => undefined)
await WaitlistEntry.findOneAndUpdate(
{ email },
{ activatedAt: now, invitationCode: invitation._id },
).catch(() => undefined)
const tokens = await issueAuthTokens(event, user)

View File

@@ -1,6 +1,6 @@
import { randomBytes } from 'node:crypto'
import { createError, readBody } from 'h3'
import { InvitationCode } from '../../../models/InvitationCode'
import { generateInvitationCode } from '../../../utils/invitations'
const CREATION_DEADLINE = new Date(process.env.BOOTSTRAP_INVITE_DEADLINE ?? '2025-10-01T00:00:00Z')
@@ -15,7 +15,7 @@ export default defineEventHandler(async (event) => {
}
const body = await readBody<{ label?: string }>(event).catch(() => ({ label: undefined }))
const code = randomBytes(4).toString('hex').toUpperCase()
const code = generateInvitationCode()
const expiresAt = CREATION_DEADLINE
await InvitationCode.create({

View File

@@ -1,7 +1,8 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createError, readBody } from 'h3'
import { useRuntimeConfig } from '#imports'
import { InvitationCode } from '../../../models/InvitationCode'
import { generateInvitationCode } from '../../../utils/invitations'
interface ManualInviteRequestBody {
password?: string
@@ -15,10 +16,6 @@ interface ManualInviteResponse {
label: string | null
}
function generateCode() {
return randomBytes(4).toString('hex').toUpperCase()
}
function safeComparePassword(provided: string, expected: string) {
const key = 'opensquawk-manual-invite'
const providedDigest = createHmac('sha256', key).update(provided).digest()
@@ -42,7 +39,7 @@ export default defineEventHandler<ManualInviteResponse>(async (event) => {
}
const now = new Date()
const code = generateCode()
const code = generateInvitationCode()
const expiresAt = new Date(now.getTime() + 1000 * 60 * 60 * 24 * 30)
const label = body.label?.trim() || undefined

View File

@@ -1,5 +1,7 @@
import mongoose from 'mongoose'
const { Schema } = mongoose
export interface WaitlistEntryDocument extends mongoose.Document {
email: string
name?: string
@@ -11,6 +13,8 @@ export interface WaitlistEntryDocument extends mongoose.Document {
activatedAt?: Date
wantsProductUpdates?: boolean
updatesOptedInAt?: Date
invitationCode?: mongoose.Types.ObjectId
invitationSentAt?: Date
}
const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
@@ -24,6 +28,8 @@ const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
activatedAt: { type: Date },
wantsProductUpdates: { type: Boolean, default: false },
updatesOptedInAt: { type: Date },
invitationCode: { type: Schema.Types.ObjectId, ref: 'InvitationCode' },
invitationSentAt: { type: Date },
})
export const WaitlistEntry =

View File

@@ -0,0 +1,50 @@
import { randomBytes } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
let cachedHtmlTemplate: string | null = null
export function generateInvitationCode() {
return randomBytes(4).toString('hex').toUpperCase()
}
async function loadInvitationTemplate() {
if (cachedHtmlTemplate) {
return cachedHtmlTemplate
}
const templatePath = join(process.cwd(), 'public', 'docs', 'invites.html')
try {
const file = await readFile(templatePath, 'utf8')
cachedHtmlTemplate = file
return file
} catch (error) {
console.warn('Could not load invitation email template, falling back to plain HTML.', error)
const fallback = `<!DOCTYPE html><html><body><p>Your OpenSquawk invite code: <strong>{{INVITE_CODE}}</strong></p></body></html>`
cachedHtmlTemplate = fallback
return fallback
}
}
export async function renderInvitationEmail(code: string) {
const template = await loadInvitationTemplate()
return template.replace(/{{INVITE_CODE}}/g, code)
}
export function renderInvitationText(code: string) {
const lines = [
'Welcome to OpenSquawk!',
'',
'Thanks for joining the waitlist — here is your personal invite code:',
code,
'',
'Use it to register your account:',
'https://opensquawk.de/login?mode=register',
'',
'Blue skies,',
'Your OpenSquawk crew',
]
return lines.join('\n')
}

View File

@@ -3,7 +3,8 @@ const ADMIN_EMAIL_FALLBACK = 'info@opensquawk.de'
interface MailOptions {
to: string
subject: string
text: string
text?: string
html?: string
from?: string
}
@@ -81,6 +82,7 @@ async function sendViaSmtp(payload: MailPayload) {
to: payload.to,
subject: payload.subject,
text: payload.text,
html: payload.html,
})
return true
} catch (error) {
@@ -90,6 +92,10 @@ async function sendViaSmtp(payload: MailPayload) {
}
export async function sendMail(options: MailOptions) {
if (!options.text && !options.html) {
throw new Error('Email payload requires text or html content')
}
const payload: MailPayload = {
...options,
from: resolveFrom(options.from),
@@ -97,7 +103,8 @@ export async function sendMail(options: MailOptions) {
const success = await sendViaSmtp(payload)
if (!success) {
console.info(`[mail:fallback] ${options.subject}\nRecipient: ${options.to}\n${options.text}`)
const preview = options.text || options.html || ''
console.info(`[mail:fallback] ${options.subject}\nRecipient: ${options.to}\n${preview}`)
}
return success
}