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..37e8eb4 --- /dev/null +++ b/server/api/service/cron/waitlist-drip.get.ts @@ -0,0 +1,123 @@ +import { defineEventHandler } from 'h3' +import { InvitationCode } from '../../../models/InvitationCode' +import { WaitlistEntry } from '../../../models/WaitlistEntry' +import { sendMail } from '../../../utils/notifications' +import { generateInvitationCode, 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 = [ + '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!', + ].join('\n') + + const html = [ + '

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

', + '

Thank you for helping us improve OpenSquawk!

', + ].join('') + + 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 =