From 040783f679f6abd801e17b846289a8358b47a3ff Mon Sep 17 00:00:00 2001
From: Remi <73385395+itsrubberduck@users.noreply.github.com>
Date: Thu, 20 Nov 2025 21:26:55 +0100
Subject: [PATCH] Style feedback drip email like invites
---
public/docs/feedback-request.html | 127 +++++++++++++++++++
server/api/service/cron/waitlist-drip.get.ts | 114 +++++++++++++++++
server/models/WaitlistEntry.ts | 2 +
server/utils/invitations.ts | 57 +++++++--
4 files changed, 290 insertions(+), 10 deletions(-)
create mode 100644 public/docs/feedback-request.html
create mode 100644 server/api/service/cron/waitlist-drip.get.ts
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.
+
+
+
+
+
+
+
+
+ |
+ Feedback · Classroom
+
+ OpenSquawk
+ Live AI ATC · in development
+ |
+ |
+
+
+ |
+
+
+
+
+ How is your OpenSquawk experience?
+ It’s been a little while since we sent your invite, and we would love to hear how things are going.
+
+ Your feedback helps us improve the simulator experience and plan the next steps for Live ATC.
+
+
+
+ Prefer email? Just reply with your thoughts — every note helps.
+ |
+
+ |
+
+ |
+
+ |
+
+ |
+
+
+
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')
+}