feat: simplify mails and add reminders

This commit is contained in:
2026-03-11 13:39:27 +01:00
parent 0c8e827cda
commit 5e5efcaf6f
5 changed files with 535 additions and 491 deletions

View File

@@ -22,18 +22,26 @@ import {
sendCancellationEmail,
sendConfirmationEmail,
sendPaymentReminderEmail,
sendReminder24hEmail,
sendReminderEmail,
sendSubscriptionConfirmationEmail,
sendUpdateEmail,
} from "../email";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
import { generateQrSecret } from "../lib/drinkkaart-utils";
import { drinkkaartRouter } from "./drinkkaart";
// Registration opens at this date — reminders fire 1 hour before
// Registration opens at this date — reminders fire 24 hours and 1 hour before
const REGISTRATION_OPENS_AT = new Date("2026-03-16T19:00:00+01:00");
const REMINDER_WINDOW_START = new Date(
const REMINDER_1H_WINDOW_START = new Date(
REGISTRATION_OPENS_AT.getTime() - 60 * 60 * 1000,
);
const REMINDER_24H_WINDOW_START = new Date(
REGISTRATION_OPENS_AT.getTime() - 25 * 60 * 60 * 1000,
);
const REMINDER_24H_WINDOW_END = new Date(
REGISTRATION_OPENS_AT.getTime() - 23 * 60 * 60 * 1000,
);
// ---------------------------------------------------------------------------
// Shared helpers
@@ -827,9 +835,10 @@ export const appRouter = {
// ---------------------------------------------------------------------------
/**
* Subscribe an email address to receive a reminder 1 hour before registration
* opens. Silently deduplicates — if the email is already subscribed, returns
* ok:true without error. Returns ok:false if registration is already open.
* Subscribe an email address to receive reminders before registration opens:
* one 24 hours before and one 1 hour before. Silently deduplicates — if the
* email is already subscribed, returns ok:true without error. Returns
* ok:false if registration is already open.
*/
subscribeReminder: publicProcedure
.input(z.object({ email: z.string().email() }))
@@ -858,6 +867,11 @@ export const appRouter = {
email: input.email,
});
// Fire-and-forget — don't let a mail failure block the response
sendSubscriptionConfirmationEmail({ to: input.email }).catch((err) =>
console.error("Failed to send subscription confirmation email:", err),
);
return { ok: true };
}),
@@ -867,8 +881,9 @@ export const appRouter = {
* header. Should be called by a Cloudflare Cron Trigger at the top of
* every hour, or directly via `POST /api/cron/reminders`.
*
* Only sends when the current time is within the 1-hour reminder window
* (between REGISTRATION_OPENS_AT - 1h and REGISTRATION_OPENS_AT).
* Fires two waves:
* - 24 hours before: window [REGISTRATION_OPENS_AT - 25h, REGISTRATION_OPENS_AT - 23h)
* - 1 hour before: window [REGISTRATION_OPENS_AT - 1h, REGISTRATION_OPENS_AT)
*/
sendReminders: publicProcedure
.input(z.object({ secret: z.string() }))
@@ -879,34 +894,61 @@ export const appRouter = {
}
const now = Date.now();
const windowStart = REMINDER_WINDOW_START.getTime();
const windowEnd = REGISTRATION_OPENS_AT.getTime();
const in24hWindow =
now >= REMINDER_24H_WINDOW_START.getTime() &&
now < REMINDER_24H_WINDOW_END.getTime();
const in1hWindow =
now >= REMINDER_1H_WINDOW_START.getTime() &&
now < REGISTRATION_OPENS_AT.getTime();
// Only send during the reminder window
if (now < windowStart || now >= windowEnd) {
if (!in24hWindow && !in1hWindow) {
return { sent: 0, skipped: true, reason: "outside_window" as const };
}
// Fetch all unsent reminders
const pending = await db
.select()
.from(reminder)
.where(isNull(reminder.sentAt));
let sent = 0;
const errors: string[] = [];
for (const row of pending) {
try {
await sendReminderEmail({ to: row.email });
await db
.update(reminder)
.set({ sentAt: new Date() })
.where(eq(reminder.id, row.id));
sent++;
} catch (err) {
errors.push(`${row.email}: ${String(err)}`);
console.error(`Failed to send reminder to ${row.email}:`, err);
if (in24hWindow) {
// Fetch reminders where the 24h email has not been sent yet
const pending = await db
.select()
.from(reminder)
.where(isNull(reminder.sent24hAt));
for (const row of pending) {
try {
await sendReminder24hEmail({ to: row.email });
await db
.update(reminder)
.set({ sent24hAt: new Date() })
.where(eq(reminder.id, row.id));
sent++;
} catch (err) {
errors.push(`24h ${row.email}: ${String(err)}`);
console.error(`Failed to send 24h reminder to ${row.email}:`, err);
}
}
}
if (in1hWindow) {
// Fetch reminders where the 1h email has not been sent yet
const pending = await db
.select()
.from(reminder)
.where(isNull(reminder.sentAt));
for (const row of pending) {
try {
await sendReminderEmail({ to: row.email });
await db
.update(reminder)
.set({ sentAt: new Date() })
.where(eq(reminder.id, row.id));
sent++;
} catch (err) {
errors.push(`1h ${row.email}: ${String(err)}`);
console.error(`Failed to send 1h reminder to ${row.email}:`, err);
}
}
}
@@ -1032,32 +1074,59 @@ export async function runSendReminders(): Promise<{
errors: string[];
}> {
const now = Date.now();
const windowStart = REMINDER_WINDOW_START.getTime();
const windowEnd = REGISTRATION_OPENS_AT.getTime();
const in24hWindow =
now >= REMINDER_24H_WINDOW_START.getTime() &&
now < REMINDER_24H_WINDOW_END.getTime();
const in1hWindow =
now >= REMINDER_1H_WINDOW_START.getTime() &&
now < REGISTRATION_OPENS_AT.getTime();
if (now < windowStart || now >= windowEnd) {
if (!in24hWindow && !in1hWindow) {
return { sent: 0, skipped: true, reason: "outside_window", errors: [] };
}
const pending = await db
.select()
.from(reminder)
.where(isNull(reminder.sentAt));
let sent = 0;
const errors: string[] = [];
for (const row of pending) {
try {
await sendReminderEmail({ to: row.email });
await db
.update(reminder)
.set({ sentAt: new Date() })
.where(eq(reminder.id, row.id));
sent++;
} catch (err) {
errors.push(`${row.email}: ${String(err)}`);
console.error(`Failed to send reminder to ${row.email}:`, err);
if (in24hWindow) {
const pending = await db
.select()
.from(reminder)
.where(isNull(reminder.sent24hAt));
for (const row of pending) {
try {
await sendReminder24hEmail({ to: row.email });
await db
.update(reminder)
.set({ sent24hAt: new Date() })
.where(eq(reminder.id, row.id));
sent++;
} catch (err) {
errors.push(`24h ${row.email}: ${String(err)}`);
console.error(`Failed to send 24h reminder to ${row.email}:`, err);
}
}
}
if (in1hWindow) {
const pending = await db
.select()
.from(reminder)
.where(isNull(reminder.sentAt));
for (const row of pending) {
try {
await sendReminderEmail({ to: row.email });
await db
.update(reminder)
.set({ sentAt: new Date() })
.where(eq(reminder.id, row.id));
sent++;
} catch (err) {
errors.push(`1h ${row.email}: ${String(err)}`);
console.error(`Failed to send 1h reminder to ${row.email}:`, err);
}
}
}