feat: reminder email opt-in 1 hour before registration opens

This commit is contained in:
2026-03-10 14:55:03 +01:00
parent d180a33d6e
commit 7eabe88d30
15 changed files with 558 additions and 16 deletions

View File

@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { db } from "@kk/db";
import { adminRequest, registration } from "@kk/db/schema";
import { adminRequest, registration, reminder } from "@kk/db/schema";
import { user } from "@kk/db/schema/auth";
import { drinkkaart, drinkkaartTopup } from "@kk/db/schema/drinkkaart";
import { env } from "@kk/env/server";
@@ -10,12 +10,19 @@ import { z } from "zod";
import {
sendCancellationEmail,
sendConfirmationEmail,
sendReminderEmail,
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
const REGISTRATION_OPENS_AT = new Date("2026-03-10T17:00:00+01:00");
const REMINDER_WINDOW_START = new Date(
REGISTRATION_OPENS_AT.getTime() - 60 * 60 * 1000,
);
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
@@ -729,6 +736,97 @@ export const appRouter = {
return { success: true, message: "Admin toegang geweigerd" };
}),
// ---------------------------------------------------------------------------
// Reminder opt-in
// ---------------------------------------------------------------------------
/**
* 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.
*/
subscribeReminder: publicProcedure
.input(z.object({ email: z.string().email() }))
.handler(async ({ input }) => {
const now = Date.now();
// Registration is already open — no point subscribing
if (now >= REGISTRATION_OPENS_AT.getTime()) {
return { ok: false, reason: "already_open" as const };
}
// Check for an existing subscription (silent dedup)
const existing = await db
.select({ id: reminder.id })
.from(reminder)
.where(eq(reminder.email, input.email))
.limit(1)
.then((rows) => rows[0]);
if (existing) {
return { ok: true };
}
await db.insert(reminder).values({
id: randomUUID(),
email: input.email,
});
return { ok: true };
}),
/**
* Send pending reminder emails to all opted-in addresses.
* Protected by a shared secret (`CRON_SECRET`) passed as an Authorization
* 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).
*/
sendReminders: publicProcedure
.input(z.object({ secret: z.string() }))
.handler(async ({ input }) => {
// Validate the shared secret
if (!env.CRON_SECRET || input.secret !== env.CRON_SECRET) {
throw new Error("Unauthorized");
}
const now = Date.now();
const windowStart = REMINDER_WINDOW_START.getTime();
const windowEnd = REGISTRATION_OPENS_AT.getTime();
// Only send during the reminder window
if (now < windowStart || now >= windowEnd) {
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);
}
}
return { sent, skipped: false, errors };
}),
// ---------------------------------------------------------------------------
// Checkout
// ---------------------------------------------------------------------------
@@ -835,3 +933,47 @@ export const appRouter = {
export type AppRouter = typeof appRouter;
export type AppRouterClient = RouterClient<typeof appRouter>;
/**
* Standalone function that sends pending reminder emails.
* Exported so that the Cloudflare Cron HTTP handler can call it directly
* without needing drizzle-orm as a direct dependency of apps/web.
*/
export async function runSendReminders(): Promise<{
sent: number;
skipped: boolean;
reason?: string;
errors: string[];
}> {
const now = Date.now();
const windowStart = REMINDER_WINDOW_START.getTime();
const windowEnd = REGISTRATION_OPENS_AT.getTime();
if (now < windowStart || now >= windowEnd) {
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);
}
}
return { sent, skipped: false, errors };
}