personal waitlist link

This commit is contained in:
itsrubberduck
2026-02-17 19:04:52 +01:00
parent 9f7a34241a
commit 73b3d19e33
10 changed files with 326 additions and 15 deletions

View File

@@ -15,6 +15,8 @@ type WaitlistListItem = {
wantsProductUpdates: boolean
updatesOptedInAt?: string
invitationSentAt?: string
referralJoins: number
referredBy?: string
invitation?: {
id: string
code: string
@@ -53,6 +55,8 @@ function mapWaitlistEntry(doc: any): WaitlistListItem {
wantsProductUpdates: Boolean(doc.wantsProductUpdates),
updatesOptedInAt: normalizeDate(doc.updatesOptedInAt),
invitationSentAt: sentAt,
referralJoins: Number(doc.referralJoins || 0),
referredBy: doc.referredBy ? String(doc.referredBy) : undefined,
invitation:
invitationDoc && invitationDoc.code
? {
@@ -150,4 +154,3 @@ export default defineEventHandler(async (event) => {
},
}
})

View File

@@ -43,7 +43,6 @@ export default defineEventHandler(async () => {
}
const sentAt = new Date()
if (!invitation) {
const code = generateInvitationCode()
invitation = await InvitationCode.create({

View File

@@ -24,15 +24,18 @@ export default defineEventHandler(async () => {
const weekAgo = new Date(now.getTime() - 7 * DAY_MS)
const monthAgo = new Date(now.getTime() - 30 * DAY_MS)
const [count, latestEntry, recent7Days, recent30Days] = await Promise.all([
const [count, latestEntry, recent7Days, recent30Days, referralJoins, referralShareClicksResult] = await Promise.all([
WaitlistEntry.countDocuments(),
WaitlistEntry.findOne().sort({ joinedAt: -1 }).select({ joinedAt: 1 }).lean(),
WaitlistEntry.countDocuments({ joinedAt: { $gte: weekAgo } }),
WaitlistEntry.countDocuments({ joinedAt: { $gte: monthAgo } }),
WaitlistEntry.countDocuments({ referredBy: { $exists: true, $ne: null } }),
WaitlistEntry.aggregate([{ $group: { _id: null, total: { $sum: '$referralShareClicks' } } }]),
])
const displayCount = computeDisplayCount(count, now)
const boost = Math.max(0, displayCount - count)
const referralShareClicks = Number(referralShareClicksResult?.[0]?.total || 0)
return {
count,
@@ -40,8 +43,9 @@ export default defineEventHandler(async () => {
syntheticBoost: boost,
recent7Days,
recent30Days,
referralJoins,
referralShareClicks,
lastJoinedAt: latestEntry?.joinedAt ? latestEntry.joinedAt.toISOString() : null,
generatedAt: now.toISOString(),
}
})

View File

@@ -1,7 +1,11 @@
import { readBody, createError } from 'h3'
import { WaitlistEntry } from '../../models/WaitlistEntry'
import { readBody, createError, getRequestURL, type H3Event } from 'h3'
import { WaitlistEntry, type WaitlistEntryDocument } from '../../models/WaitlistEntry'
import { sendAdminNotification } from '../../utils/notifications'
import { registerUpdateSubscriber } from '../../utils/subscribers'
import {
generateWaitlistReferralToken,
normalizeWaitlistReferralToken,
} from '../../utils/waitlistReferrals'
interface WaitlistRequestBody {
email?: string
@@ -11,9 +15,46 @@ interface WaitlistRequestBody {
consentTerms?: boolean
source?: string
wantsProductUpdates?: boolean
referralToken?: string
}
type NotificationDataEntry = [string, ...unknown[]]
const MAX_REFERRAL_TOKEN_ATTEMPTS = 8
function isSameWaitlistEntry(a?: WaitlistEntryDocument | null, b?: WaitlistEntryDocument | null) {
if (!a || !b) return false
return String((a as any)._id) === String((b as any)._id)
}
async function createUniqueReferralToken() {
for (let attempt = 0; attempt < MAX_REFERRAL_TOKEN_ATTEMPTS; attempt += 1) {
const token = generateWaitlistReferralToken()
const exists = await WaitlistEntry.exists({ referralToken: token })
if (!exists) {
return token
}
}
throw createError({
statusCode: 503,
statusMessage: 'Could not issue referral token. Please try again.',
})
}
function buildReferralUrl(event: H3Event, token: string) {
const requestUrl = getRequestURL(event)
const params = new URLSearchParams({ ref: token })
return `${requestUrl.origin}/?${params.toString()}#cta`
}
async function applyReferralAttribution(referrer?: WaitlistEntryDocument | null) {
if (!referrer) {
return
}
referrer.referralJoins = Math.max(0, Number(referrer.referralJoins || 0)) + 1
await referrer.save()
}
export default defineEventHandler(async (event) => {
const body = await readBody<WaitlistRequestBody>(event)
@@ -22,6 +63,7 @@ export default defineEventHandler(async (event) => {
const notes = body.notes?.trim()
const source = body.source?.trim() || 'landing'
const wantsProductUpdates = Boolean(body.wantsProductUpdates)
const normalizedReferralToken = normalizeWaitlistReferralToken(body.referralToken)
const fromAddress = email ? (name ? `${name} <${email}>` : email) : undefined
if (!email) {
@@ -33,14 +75,33 @@ export default defineEventHandler(async (event) => {
}
const now = new Date()
const referrer = normalizedReferralToken
? await WaitlistEntry.findOne({ referralToken: normalizedReferralToken })
: null
const referralSource = referrer ? `${source}-referral` : source
const existing = await WaitlistEntry.findOne({ email })
if (existing) {
const previouslyWantedUpdates = Boolean(existing.wantsProductUpdates)
const canAttributeReferral =
!existing.referredBy &&
Boolean(referrer) &&
!isSameWaitlistEntry(existing, referrer) &&
referrer?.email !== email
if (!existing.referralToken) {
existing.referralToken = await createUniqueReferralToken()
}
const existingReferralToken = existing.referralToken as string
if (canAttributeReferral && referrer) {
existing.referredBy = referrer._id as any
}
existing.name = name || existing.name
existing.notes = notes || existing.notes
existing.source = source
existing.source = referralSource
existing.consentPrivacy = true
existing.consentTerms = true
if (wantsProductUpdates && !previouslyWantedUpdates) {
@@ -49,11 +110,15 @@ export default defineEventHandler(async (event) => {
}
await existing.save()
if (canAttributeReferral) {
await applyReferralAttribution(referrer)
}
if (wantsProductUpdates) {
const updateResult = await registerUpdateSubscriber({
email,
name,
source: `${source}-waitlist`,
source: `${referralSource}-waitlist`,
consentPrivacy: true,
consentMarketing: true,
})
@@ -68,7 +133,7 @@ export default defineEventHandler(async (event) => {
if (notes) {
dataEntries.push(['Notes', notes])
}
dataEntries.push(['Source', source])
dataEntries.push(['Source', referralSource])
dataEntries.push(['Opt-in', 'Product updates'])
await sendAdminNotification({
@@ -84,26 +149,39 @@ export default defineEventHandler(async (event) => {
success: true,
alreadyJoined: true,
joinedAt: existing.joinedAt,
referralToken: existingReferralToken,
referralUrl: buildReferralUrl(event, existingReferralToken),
referralAttributed: canAttributeReferral,
referralJoins: Number(existing.referralJoins || 0),
}
}
const referralToken = await createUniqueReferralToken()
const canAttributeReferral = Boolean(referrer) && referrer?.email !== email
const entry = await WaitlistEntry.create({
email,
name,
notes,
source,
source: referralSource,
consentPrivacy: true,
consentTerms: true,
joinedAt: now,
wantsProductUpdates,
updatesOptedInAt: wantsProductUpdates ? now : undefined,
referralToken,
referredBy: canAttributeReferral && referrer ? (referrer._id as any) : undefined,
})
if (canAttributeReferral) {
await applyReferralAttribution(referrer)
}
if (wantsProductUpdates) {
await registerUpdateSubscriber({
email,
name,
source: `${source}-waitlist`,
source: `${referralSource}-waitlist`,
consentPrivacy: true,
consentMarketing: true,
})
@@ -118,8 +196,11 @@ export default defineEventHandler(async (event) => {
if (notes) {
dataEntries.push(['Notes', notes])
}
dataEntries.push(['Source', source])
dataEntries.push(['Source', referralSource])
dataEntries.push(['Opt-in', wantsProductUpdates ? 'Product updates' : 'Waitlist only'])
if (canAttributeReferral && referrer) {
dataEntries.push(['Referral', normalizedReferralToken as string])
}
await sendAdminNotification({
event: 'New waitlist signup',
@@ -132,5 +213,9 @@ export default defineEventHandler(async (event) => {
success: true,
alreadyJoined: false,
joinedAt: entry.joinedAt,
referralToken,
referralUrl: buildReferralUrl(event, referralToken),
referralAttributed: canAttributeReferral,
referralJoins: Number(entry.referralJoins || 0),
}
})

View File

@@ -0,0 +1,40 @@
import { createError, readBody } from 'h3'
import { WaitlistEntry } from '../../../models/WaitlistEntry'
import { normalizeWaitlistReferralToken } from '../../../utils/waitlistReferrals'
interface ReferralEventRequestBody {
event?: string
referralToken?: string
method?: string
}
export default defineEventHandler(async (event) => {
const body = await readBody<ReferralEventRequestBody>(event)
const eventName = body.event?.trim()
if (eventName !== 'share_clicked') {
throw createError({ statusCode: 400, statusMessage: 'Unsupported referral event' })
}
const referralToken = normalizeWaitlistReferralToken(body.referralToken)
if (!referralToken) {
throw createError({ statusCode: 400, statusMessage: 'Valid referral token is required' })
}
const entry = await WaitlistEntry.findOne({ referralToken })
if (!entry) {
throw createError({ statusCode: 404, statusMessage: 'Referral owner not found' })
}
entry.referralShareClicks = Math.max(0, Number(entry.referralShareClicks || 0)) + 1
entry.lastReferralShareAt = new Date()
entry.markModified('referralShareClicks')
await entry.save()
return {
success: true,
event: eventName,
method: body.method?.trim() || 'unknown',
}
})

View File

@@ -16,6 +16,11 @@ export interface WaitlistEntryDocument extends mongoose.Document {
invitationCode?: mongoose.Types.ObjectId
invitationSentAt?: Date
feedbackRequestedAt?: Date
referralToken?: string
referredBy?: mongoose.Types.ObjectId
referralJoins: number
referralShareClicks: number
lastReferralShareAt?: Date
}
const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
@@ -32,9 +37,13 @@ const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
invitationCode: { type: Schema.Types.ObjectId, ref: 'InvitationCode' },
invitationSentAt: { type: Date },
feedbackRequestedAt: { type: Date },
referralToken: { type: String, uppercase: true, trim: true, unique: true, sparse: true, index: true },
referredBy: { type: Schema.Types.ObjectId, ref: 'WaitlistEntry' },
referralJoins: { type: Number, default: 0 },
referralShareClicks: { type: Number, default: 0 },
lastReferralShareAt: { type: Date },
})
export const WaitlistEntry =
(mongoose.models.WaitlistEntry as mongoose.Model<WaitlistEntryDocument> | undefined) ||
mongoose.model<WaitlistEntryDocument>('WaitlistEntry', waitlistSchema)

View File

@@ -0,0 +1,20 @@
import { randomBytes } from 'node:crypto'
export const WAITLIST_REFERRAL_TOKEN_PATTERN = /^[A-F0-9]{8}$/
export function generateWaitlistReferralToken() {
return randomBytes(4).toString('hex').toUpperCase()
}
export function normalizeWaitlistReferralToken(value?: unknown) {
if (typeof value !== 'string') {
return null
}
const token = value.trim().toUpperCase()
if (!WAITLIST_REFERRAL_TOKEN_PATTERN.test(token)) {
return null
}
return token
}