diff --git a/app/pages/admin/index.vue b/app/pages/admin/index.vue index 2ccbe6f..2bc01de 100644 --- a/app/pages/admin/index.vue +++ b/app/pages/admin/index.vue @@ -481,6 +481,17 @@ {{ waitlistError }} + + {{ waitlistSuccessMessage }} + +
Joined Opt-in Status + Invitation @@ -580,6 +592,54 @@ + +
+
+
{{ entry.invitation.code }}
+
+ Created {{ formatRelative(entry.invitation.createdAt) }} + · {{ formatDateTime(entry.invitation.createdAt) }} +
+
+ Sent {{ formatRelative(entry.invitation.sentAt) }} + · {{ formatDateTime(entry.invitation.sentAt) }} +
+
+ Code used · {{ formatRelative(entry.invitation.usedAt) }} +
+
+
No invitation sent yet.
+ +
+ + + + + + Registered + +
+
+ @@ -1278,6 +1338,17 @@ interface LogsResponse { pagination: { total: number; page: number; pageSize: number; pages: number } } +interface WaitlistInvitationInfo { + id: string + code: string + channel: string + label?: string + createdAt: string + expiresAt?: string + sentAt?: string + usedAt?: string +} + interface WaitlistEntryItem { id: string email: string @@ -1288,6 +1359,8 @@ interface WaitlistEntryItem { activatedAt?: string wantsProductUpdates: boolean updatesOptedInAt?: string + invitationSentAt?: string + invitation?: WaitlistInvitationInfo } interface WaitlistStatsSummary { @@ -1303,6 +1376,11 @@ interface WaitlistResponse { stats: WaitlistStatsSummary } +interface WaitlistInviteResponse { + success: boolean + invitation: WaitlistInvitationInfo +} + interface CreateInviteResponse { success: boolean invitation: { @@ -1372,6 +1450,7 @@ const waitlistEntries = ref([]) const waitlistPagination = reactive({ total: 0, page: 1, pages: 1, pageSize: 15 }) const waitlistLoading = ref(false) const waitlistError = ref('') +const waitlistSuccessMessage = ref('') const waitlistSearch = ref('') const waitlistSubscription = ref<'all' | 'waitlist' | 'updates'>('all') const waitlistStatus = ref<'all' | 'pending' | 'activated'>('all') @@ -1386,6 +1465,7 @@ const waitlistStatusOptions = [ { title: 'Activated', value: 'activated' }, ] const waitlistStats = reactive({ total: 0, updates: 0, activated: 0, pending: 0 }) +const waitlistSending = ref([]) const logs = ref([]) const logPagination = reactive({ total: 0, page: 1, pages: 1, pageSize: 15 }) @@ -1704,6 +1784,7 @@ async function fetchWaitlist(resetPage = false) { } waitlistLoading.value = true waitlistError.value = '' + waitlistSuccessMessage.value = '' try { const response = await api.get('/api/admin/waitlist', { query: computeWaitlistQuery(), @@ -1727,6 +1808,34 @@ function changeWaitlistPage(page: number) { fetchWaitlist() } +function isWaitlistSending(id: string) { + return waitlistSending.value.includes(id) +} + +async function sendWaitlistInvitation(entry: WaitlistEntryItem) { + if (isWaitlistSending(entry.id)) return + + waitlistError.value = '' + waitlistSuccessMessage.value = '' + waitlistSending.value = [...waitlistSending.value, entry.id] + + try { + const response = await api.post( + `/api/admin/waitlist/${entry.id}/invite`, + {}, + ) + + entry.invitation = response.invitation + entry.invitationSentAt = response.invitation.sentAt + waitlistEntries.value = [...waitlistEntries.value] + waitlistSuccessMessage.value = `Invitation ${response.invitation.code} sent to ${entry.email}.` + } catch (error) { + waitlistError.value = extractErrorMessage(error, 'Could not send invitation.') + } finally { + waitlistSending.value = waitlistSending.value.filter((existing) => existing !== entry.id) + } +} + function computeLogQuery() { const query: Record = { page: logPagination.page, diff --git a/server/api/admin/waitlist/[id]/invite.post.ts b/server/api/admin/waitlist/[id]/invite.post.ts new file mode 100644 index 0000000..2be44f9 --- /dev/null +++ b/server/api/admin/waitlist/[id]/invite.post.ts @@ -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(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, + }, + } +}) diff --git a/server/api/admin/waitlist/index.get.ts b/server/api/admin/waitlist/index.get.ts index 26e6329..2b64820 100644 --- a/server/api/admin/waitlist/index.get.ts +++ b/server/api/admin/waitlist/index.get.ts @@ -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 }), diff --git a/server/api/service/auth/register.post.ts b/server/api/service/auth/register.post.ts index 1501692..a027922 100644 --- a/server/api/service/auth/register.post.ts +++ b/server/api/service/auth/register.post.ts @@ -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) diff --git a/server/api/service/invitations/bootstrap.post.ts b/server/api/service/invitations/bootstrap.post.ts index 7da5132..509a06e 100644 --- a/server/api/service/invitations/bootstrap.post.ts +++ b/server/api/service/invitations/bootstrap.post.ts @@ -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({ diff --git a/server/api/service/invitations/manual.post.ts b/server/api/service/invitations/manual.post.ts index 6ecfe35..7620f79 100644 --- a/server/api/service/invitations/manual.post.ts +++ b/server/api/service/invitations/manual.post.ts @@ -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(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 diff --git a/server/models/WaitlistEntry.ts b/server/models/WaitlistEntry.ts index c8dd27b..5e1ff55 100644 --- a/server/models/WaitlistEntry.ts +++ b/server/models/WaitlistEntry.ts @@ -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({ @@ -24,6 +28,8 @@ const waitlistSchema = new mongoose.Schema({ activatedAt: { type: Date }, wantsProductUpdates: { type: Boolean, default: false }, updatesOptedInAt: { type: Date }, + invitationCode: { type: Schema.Types.ObjectId, ref: 'InvitationCode' }, + invitationSentAt: { type: Date }, }) export const WaitlistEntry = diff --git a/server/utils/invitations.ts b/server/utils/invitations.ts new file mode 100644 index 0000000..9c06f01 --- /dev/null +++ b/server/utils/invitations.ts @@ -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 = `

Your OpenSquawk invite code: {{INVITE_CODE}}

` + 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') +} diff --git a/server/utils/notifications.ts b/server/utils/notifications.ts index 6296274..67ded91 100644 --- a/server/utils/notifications.ts +++ b/server/utils/notifications.ts @@ -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 }