mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-06 01:06:28 +08:00
Add waitlist invitation sending from admin
This commit is contained in:
@@ -481,6 +481,17 @@
|
||||
{{ waitlistError }}
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-else-if="waitlistSuccessMessage"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
density="comfortable"
|
||||
class="bg-emerald-500/10 text-emerald-100"
|
||||
>
|
||||
{{ waitlistSuccessMessage }}
|
||||
</v-alert>
|
||||
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div class="grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<v-text-field
|
||||
@@ -533,6 +544,7 @@
|
||||
<th class="font-semibold">Joined</th>
|
||||
<th class="font-semibold">Opt-in</th>
|
||||
<th class="font-semibold">Status</th>
|
||||
<th class="font-semibold">Invitation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -580,6 +592,54 @@
|
||||
</template>
|
||||
</v-chip>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-if="entry.invitation" class="space-y-1 text-xs">
|
||||
<div class="font-mono text-sm text-white">{{ entry.invitation.code }}</div>
|
||||
<div class="text-white/50">
|
||||
Created {{ formatRelative(entry.invitation.createdAt) }}
|
||||
<span class="text-white/40">· {{ formatDateTime(entry.invitation.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="entry.invitation.sentAt" class="text-white/50">
|
||||
Sent {{ formatRelative(entry.invitation.sentAt) }}
|
||||
<span class="text-white/40">· {{ formatDateTime(entry.invitation.sentAt) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="entry.invitation.usedAt"
|
||||
class="text-[11px] font-medium uppercase tracking-[0.2em] text-green-300"
|
||||
>
|
||||
Code used · {{ formatRelative(entry.invitation.usedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-white/50">No invitation sent yet.</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="cyan"
|
||||
variant="tonal"
|
||||
:loading="isWaitlistSending(entry.id)"
|
||||
:disabled="Boolean(entry.invitation?.usedAt)"
|
||||
@click="sendWaitlistInvitation(entry)"
|
||||
>
|
||||
<template v-if="entry.invitation">
|
||||
<template v-if="entry.invitation.usedAt">Registered</template>
|
||||
<template v-else>Re-send invite</template>
|
||||
</template>
|
||||
<template v-else>Send invite</template>
|
||||
</v-btn>
|
||||
<v-chip
|
||||
v-if="entry.invitation?.usedAt"
|
||||
size="x-small"
|
||||
color="green"
|
||||
variant="tonal"
|
||||
class="text-[11px] uppercase tracking-[0.2em]"
|
||||
>
|
||||
Registered
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
@@ -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<WaitlistEntryItem[]>([])
|
||||
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<WaitlistStatsSummary>({ total: 0, updates: 0, activated: 0, pending: 0 })
|
||||
const waitlistSending = ref<string[]>([])
|
||||
|
||||
const logs = ref<TransmissionEntry[]>([])
|
||||
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<WaitlistResponse>('/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<WaitlistInviteResponse>(
|
||||
`/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<string, any> = {
|
||||
page: logPagination.page,
|
||||
|
||||
80
server/api/admin/waitlist/[id]/invite.post.ts
Normal file
80
server/api/admin/waitlist/[id]/invite.post.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
50
server/utils/invitations.ts
Normal file
50
server/utils/invitations.ts
Normal 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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user