diff --git a/public/docs/feedback-request.html b/public/docs/feedback-request.html new file mode 100644 index 0000000..521332c --- /dev/null +++ b/public/docs/feedback-request.html @@ -0,0 +1,127 @@ + + + + + + + + + + + +
+ Quick check-in on your OpenSquawk invite — we would love your feedback. +
+ + + +
+ + + + + + + +
+ + diff --git a/server/api/service/cron/waitlist-drip.get.ts b/server/api/service/cron/waitlist-drip.get.ts new file mode 100644 index 0000000..0601d39 --- /dev/null +++ b/server/api/service/cron/waitlist-drip.get.ts @@ -0,0 +1,114 @@ +import { defineEventHandler } from 'h3' +import { InvitationCode } from '../../../models/InvitationCode' +import { WaitlistEntry } from '../../../models/WaitlistEntry' +import { sendMail } from '../../../utils/notifications' +import { + generateInvitationCode, + renderFeedbackEmail, + renderFeedbackText, + renderInvitationEmail, + renderInvitationText, +} from '../../../utils/invitations' + +const DAY_MS = 1000 * 60 * 60 * 24 +const INVITATION_DELAY_DAYS = 5 +const FEEDBACK_DELAY_DAYS = 14 + +export default defineEventHandler(async () => { + const now = new Date() + const invitationCutoff = new Date(now.getTime() - INVITATION_DELAY_DAYS * DAY_MS) + const feedbackCutoff = new Date(now.getTime() - FEEDBACK_DELAY_DAYS * DAY_MS) + + const invitationCandidates = await WaitlistEntry.find({ + joinedAt: { $lte: invitationCutoff }, + $and: [ + { $or: [{ invitationSentAt: { $exists: false } }, { invitationSentAt: null }] }, + { $or: [{ activatedAt: { $exists: false } }, { activatedAt: null }] }, + ], + }).exec() + + let invitationsSent = 0 + + for (const entry of invitationCandidates) { + const email = entry.email?.trim() + if (!email) { + continue + } + + let invitation = entry.invitationCode ? await InvitationCode.findById(entry.invitationCode) : null + + if (invitation?.usedAt) { + continue + } + + const sentAt = new Date() + + if (!invitation) { + const code = generateInvitationCode() + invitation = await InvitationCode.create({ + code, + createdAt: sentAt, + channel: 'admin', + label: `Waitlist: ${email}`, + }) + entry.invitationCode = invitation._id + } + + entry.invitationSentAt = sentAt + 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, + }) + + invitationsSent += 1 + } + + const feedbackCandidates = await WaitlistEntry.find({ + invitationSentAt: { $lte: feedbackCutoff }, + $or: [ + { feedbackRequestedAt: { $exists: false } }, + { feedbackRequestedAt: null }, + ], + }).exec() + + let feedbackRequests = 0 + + for (const entry of feedbackCandidates) { + const email = entry.email?.trim() + if (!email) { + continue + } + + const requestedAt = new Date() + + const text = renderFeedbackText() + const html = await renderFeedbackEmail() + + await sendMail({ + to: email, + subject: 'How is your OpenSquawk experience?', + text, + html, + }) + + entry.feedbackRequestedAt = requestedAt + await entry.save() + + feedbackRequests += 1 + } + + console.log(`[waitlist-drip] invitations sent: ${invitationsSent}`) + console.log(`[waitlist-drip] feedback requests sent: ${feedbackRequests}`) + + return { + invitationsSent, + feedbackRequests, + } +}) diff --git a/server/models/WaitlistEntry.ts b/server/models/WaitlistEntry.ts index 5e1ff55..2758fc4 100644 --- a/server/models/WaitlistEntry.ts +++ b/server/models/WaitlistEntry.ts @@ -15,6 +15,7 @@ export interface WaitlistEntryDocument extends mongoose.Document { updatesOptedInAt?: Date invitationCode?: mongoose.Types.ObjectId invitationSentAt?: Date + feedbackRequestedAt?: Date } const waitlistSchema = new mongoose.Schema({ @@ -30,6 +31,7 @@ const waitlistSchema = new mongoose.Schema({ updatesOptedInAt: { type: Date }, invitationCode: { type: Schema.Types.ObjectId, ref: 'InvitationCode' }, invitationSentAt: { type: Date }, + feedbackRequestedAt: { type: Date }, }) export const WaitlistEntry = diff --git a/server/utils/invitations.ts b/server/utils/invitations.ts index 9c06f01..fc323a7 100644 --- a/server/utils/invitations.ts +++ b/server/utils/invitations.ts @@ -2,33 +2,45 @@ import { randomBytes } from 'node:crypto' import { readFile } from 'node:fs/promises' import { join } from 'node:path' -let cachedHtmlTemplate: string | null = null +type CachedTemplates = { + invitations: string | null + feedback: string | null +} + +const cachedTemplates: CachedTemplates = { + invitations: null, + feedback: null, +} export function generateInvitationCode() { return randomBytes(4).toString('hex').toUpperCase() } -async function loadInvitationTemplate() { - if (cachedHtmlTemplate) { - return cachedHtmlTemplate +async function loadTemplate(kind: keyof CachedTemplates, filename: string, fallback: string) { + if (cachedTemplates[kind]) { + return cachedTemplates[kind] as string } - const templatePath = join(process.cwd(), 'public', 'docs', 'invites.html') + const templatePath = join(process.cwd(), 'public', 'docs', filename) try { const file = await readFile(templatePath, 'utf8') - cachedHtmlTemplate = file + cachedTemplates[kind] = 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 + console.warn(`Could not load ${kind} email template, falling back to plain HTML.`, error) + cachedTemplates[kind] = fallback return fallback } } export async function renderInvitationEmail(code: string) { - const template = await loadInvitationTemplate() + const template = await loadTemplate( + 'invitations', + 'invites.html', + `

Your OpenSquawk invite code: {{INVITE_CODE}}

` + ) + return template.replace(/{{INVITE_CODE}}/g, code) } @@ -48,3 +60,28 @@ export function renderInvitationText(code: string) { return lines.join('\n') } + +export async function renderFeedbackEmail() { + const template = await loadTemplate( + 'feedback', + 'feedback-request.html', + `

We'd love your feedback: opensquawk.de/feedback

` + ) + + return template +} + +export function renderFeedbackText() { + const lines = [ + 'Hi there,', + '', + "It's been a little while since we sent your invite, and we would love to hear your thoughts.", + '', + 'Share your feedback here:', + 'https://opensquawk.de/feedback', + '', + 'Thank you for helping us improve OpenSquawk!', + ] + + return lines.join('\n') +}