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,7 +1,8 @@
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { REGISTRATION_OPENS_AT } from "@/lib/opening"; import { REGISTRATION_OPENS_AT } from "@/lib/opening";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen"; import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
import { client } from "@/utils/orpc";
function pad(n: number): string { function pad(n: number): string {
return String(n).padStart(2, "0"); return String(n).padStart(2, "0");
@@ -15,10 +16,10 @@ interface UnitBoxProps {
function UnitBox({ value, label }: UnitBoxProps) { function UnitBox({ value, label }: UnitBoxProps) {
return ( return (
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<div className="flex items-center justify-center rounded-sm bg-white/10 px-4 py-3 font-['Intro',sans-serif] text-5xl text-white tabular-nums md:text-6xl lg:text-7xl"> <div className="flex w-14 items-center justify-center rounded-sm bg-white/10 px-2 py-2 font-['Intro',sans-serif] text-3xl text-white tabular-nums sm:w-auto sm:px-4 sm:py-3 sm:text-5xl md:text-6xl lg:text-7xl">
{value} {value}
</div> </div>
<span className="font-['Intro',sans-serif] text-white/50 text-xs uppercase tracking-widest"> <span className="font-['Intro',sans-serif] text-[9px] text-white/50 uppercase tracking-widest sm:text-xs">
{label} {label}
</span> </span>
</div> </div>
@@ -53,6 +54,181 @@ function fireConfetti() {
}, 400); }, 400);
} }
// Reminder opt-in form — sits at the very bottom of the banner
function ReminderForm() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<
"idle" | "loading" | "subscribed" | "already_subscribed" | "error"
>("idle");
const [errorMessage, setErrorMessage] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email || status === "loading") return;
setStatus("loading");
try {
const result = await client.subscribeReminder({ email });
if (!result.ok && result.reason === "already_open") {
setStatus("idle");
return;
}
setStatus("subscribed");
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : "Er ging iets mis.");
setStatus("error");
}
}
const reminderTime = REGISTRATION_OPENS_AT.toLocaleTimeString("nl-BE", {
hour: "2-digit",
minute: "2-digit",
});
return (
<div
className="w-full"
style={{
animation: "reminderFadeIn 0.6s ease both",
animationDelay: "0.3s",
}}
>
<style>{`
@keyframes reminderFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes reminderCheck {
from { opacity: 0; transform: scale(0.7); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
{/* Hairline divider */}
<div
className="mx-auto mb-6 h-px w-24"
style={{
background:
"linear-gradient(to right, transparent, rgba(255,255,255,0.15), transparent)",
}}
/>
{status === "subscribed" || status === "already_subscribed" ? (
<div
className="flex flex-col items-center gap-2"
style={{
animation: "reminderCheck 0.4s cubic-bezier(0.34,1.56,0.64,1) both",
}}
>
{/* Checkmark glyph */}
<div
className="flex h-8 w-8 items-center justify-center rounded-full"
style={{
background: "rgba(255,255,255,0.12)",
border: "1px solid rgba(255,255,255,0.2)",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
aria-label="Vinkje"
role="img"
>
<path
d="M2.5 7L5.5 10L11.5 4"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<p
className="font-['Intro',sans-serif] text-sm tracking-wide"
style={{ color: "rgba(255,255,255,0.65)" }}
>
Herinnering ingepland voor {reminderTime}
</p>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<p
className="font-['Intro',sans-serif] text-xs uppercase tracking-widest"
style={{ color: "rgba(255,255,255,0.35)", letterSpacing: "0.14em" }}
>
Herinnering ontvangen?
</p>
{/* Fused pill input + button */}
<form
onSubmit={handleSubmit}
className="flex w-full max-w-xs overflow-hidden"
style={{
borderRadius: "3px",
border: "1px solid rgba(255,255,255,0.18)",
background: "rgba(255,255,255,0.07)",
backdropFilter: "blur(6px)",
}}
>
<input
type="email"
required
placeholder="jouw@email.be"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={status === "loading"}
className="min-w-0 flex-1 bg-transparent px-4 py-2.5 text-sm text-white outline-none disabled:opacity-50"
style={{
fontFamily: "'Intro', sans-serif",
fontSize: "0.8rem",
letterSpacing: "0.02em",
}}
/>
{/* Vertical separator */}
<div
style={{
width: "1px",
background: "rgba(255,255,255,0.15)",
flexShrink: 0,
}}
/>
<button
type="submit"
disabled={status === "loading" || !email}
className="shrink-0 px-4 py-2.5 font-semibold text-xs transition-all disabled:opacity-40"
style={{
fontFamily: "'Intro', sans-serif",
letterSpacing: "0.06em",
color:
status === "loading"
? "rgba(255,255,255,0.5)"
: "rgba(255,255,255,0.9)",
background: "transparent",
cursor:
status === "loading" || !email ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
}}
>
{status === "loading" ? "…" : "Stuur mij"}
</button>
</form>
{status === "error" && (
<p
className="font-['Intro',sans-serif] text-xs"
style={{ color: "rgba(255,140,140,0.8)" }}
>
{errorMessage}
</p>
)}
</div>
)}
</div>
);
}
/** /**
* Shown in place of the registration form while registration is not yet open. * Shown in place of the registration form while registration is not yet open.
* Fires confetti the moment the gate opens (without a page reload). * Fires confetti the moment the gate opens (without a page reload).
@@ -83,37 +259,40 @@ export function CountdownBanner() {
}); });
return ( return (
<div className="flex flex-col items-center gap-8 py-4 text-center"> <div className="flex flex-col items-center gap-6 py-4 text-center sm:gap-8">
<div> <div>
<p className="font-['Intro',sans-serif] text-lg text-white/70 md:text-xl"> <p className="font-['Intro',sans-serif] text-base text-white/70 sm:text-lg md:text-xl">
Inschrijvingen openen op Inschrijvingen openen op
</p> </p>
<p className="mt-1 font-['Intro',sans-serif] text-white text-xl capitalize md:text-2xl"> <p className="mt-1 font-['Intro',sans-serif] text-lg text-white capitalize sm:text-xl md:text-2xl">
{openDate} om {openTime} {openDate} om {openTime}
</p> </p>
</div> </div>
{/* Countdown boxes */} {/* Countdown — single row forced via grid, scales down on small screens */}
<div className="flex flex-wrap items-center justify-center gap-3 md:gap-6"> <div className="grid grid-cols-[auto_auto_auto_auto_auto_auto_auto] items-center gap-1.5 sm:flex sm:gap-4 md:gap-6">
<UnitBox value={String(days)} label="dagen" /> <UnitBox value={String(days)} label="dagen" />
<span className="mb-6 font-['Intro',sans-serif] text-4xl text-white/40"> <span className="mb-6 font-['Intro',sans-serif] text-2xl text-white/40 sm:text-4xl">
: :
</span> </span>
<UnitBox value={pad(hours)} label="uren" /> <UnitBox value={pad(hours)} label="uren" />
<span className="mb-6 font-['Intro',sans-serif] text-4xl text-white/40"> <span className="mb-6 font-['Intro',sans-serif] text-2xl text-white/40 sm:text-4xl">
: :
</span> </span>
<UnitBox value={pad(minutes)} label="minuten" /> <UnitBox value={pad(minutes)} label="minuten" />
<span className="mb-6 font-['Intro',sans-serif] text-4xl text-white/40"> <span className="mb-6 font-['Intro',sans-serif] text-2xl text-white/40 sm:text-4xl">
: :
</span> </span>
<UnitBox value={pad(seconds)} label="seconden" /> <UnitBox value={pad(seconds)} label="seconden" />
</div> </div>
<p className="max-w-md text-sm text-white/50"> <p className="max-w-md px-4 text-sm text-white/50">
Kom snel terug! Zodra de inschrijvingen openen kun je je hier Kom snel terug! Zodra de inschrijvingen openen kun je je hier
registreren als toeschouwer of als artiest. registreren als toeschouwer of als artiest.
</p> </p>
{/* Email reminder opt-in — always last */}
<ReminderForm />
</div> </div>
); );
} }

View File

@@ -21,6 +21,7 @@ import { Route as ManageTokenRouteImport } from './routes/manage.$token'
import { Route as AdminDrinkkaartRouteImport } from './routes/admin/drinkkaart' import { Route as AdminDrinkkaartRouteImport } from './routes/admin/drinkkaart'
import { Route as ApiWebhookMollieRouteImport } from './routes/api/webhook/mollie' import { Route as ApiWebhookMollieRouteImport } from './routes/api/webhook/mollie'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$' import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$'
import { Route as ApiCronRemindersRouteImport } from './routes/api/cron/reminders'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
const TermsRoute = TermsRouteImport.update({ const TermsRoute = TermsRouteImport.update({
@@ -83,6 +84,11 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
path: '/api/rpc/$', path: '/api/rpc/$',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiCronRemindersRoute = ApiCronRemindersRouteImport.update({
id: '/api/cron/reminders',
path: '/api/cron/reminders',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$', id: '/api/auth/$',
path: '/api/auth/$', path: '/api/auth/$',
@@ -101,6 +107,7 @@ export interface FileRoutesByFullPath {
'/manage/$token': typeof ManageTokenRoute '/manage/$token': typeof ManageTokenRoute
'/admin/': typeof AdminIndexRoute '/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute '/api/webhook/mollie': typeof ApiWebhookMollieRoute
} }
@@ -116,6 +123,7 @@ export interface FileRoutesByTo {
'/manage/$token': typeof ManageTokenRoute '/manage/$token': typeof ManageTokenRoute
'/admin': typeof AdminIndexRoute '/admin': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute '/api/webhook/mollie': typeof ApiWebhookMollieRoute
} }
@@ -132,6 +140,7 @@ export interface FileRoutesById {
'/manage/$token': typeof ManageTokenRoute '/manage/$token': typeof ManageTokenRoute
'/admin/': typeof AdminIndexRoute '/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute '/api/webhook/mollie': typeof ApiWebhookMollieRoute
} }
@@ -149,6 +158,7 @@ export interface FileRouteTypes {
| '/manage/$token' | '/manage/$token'
| '/admin/' | '/admin/'
| '/api/auth/$' | '/api/auth/$'
| '/api/cron/reminders'
| '/api/rpc/$' | '/api/rpc/$'
| '/api/webhook/mollie' | '/api/webhook/mollie'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@@ -164,6 +174,7 @@ export interface FileRouteTypes {
| '/manage/$token' | '/manage/$token'
| '/admin' | '/admin'
| '/api/auth/$' | '/api/auth/$'
| '/api/cron/reminders'
| '/api/rpc/$' | '/api/rpc/$'
| '/api/webhook/mollie' | '/api/webhook/mollie'
id: id:
@@ -179,6 +190,7 @@ export interface FileRouteTypes {
| '/manage/$token' | '/manage/$token'
| '/admin/' | '/admin/'
| '/api/auth/$' | '/api/auth/$'
| '/api/cron/reminders'
| '/api/rpc/$' | '/api/rpc/$'
| '/api/webhook/mollie' | '/api/webhook/mollie'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -195,6 +207,7 @@ export interface RootRouteChildren {
ManageTokenRoute: typeof ManageTokenRoute ManageTokenRoute: typeof ManageTokenRoute
AdminIndexRoute: typeof AdminIndexRoute AdminIndexRoute: typeof AdminIndexRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiCronRemindersRoute: typeof ApiCronRemindersRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute ApiRpcSplatRoute: typeof ApiRpcSplatRoute
ApiWebhookMollieRoute: typeof ApiWebhookMollieRoute ApiWebhookMollieRoute: typeof ApiWebhookMollieRoute
} }
@@ -285,6 +298,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiRpcSplatRouteImport preLoaderRoute: typeof ApiRpcSplatRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/cron/reminders': {
id: '/api/cron/reminders'
path: '/api/cron/reminders'
fullPath: '/api/cron/reminders'
preLoaderRoute: typeof ApiCronRemindersRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/$': { '/api/auth/$': {
id: '/api/auth/$' id: '/api/auth/$'
path: '/api/auth/$' path: '/api/auth/$'
@@ -307,6 +327,7 @@ const rootRouteChildren: RootRouteChildren = {
ManageTokenRoute: ManageTokenRoute, ManageTokenRoute: ManageTokenRoute,
AdminIndexRoute: AdminIndexRoute, AdminIndexRoute: AdminIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiCronRemindersRoute: ApiCronRemindersRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute, ApiRpcSplatRoute: ApiRpcSplatRoute,
ApiWebhookMollieRoute: ApiWebhookMollieRoute, ApiWebhookMollieRoute: ApiWebhookMollieRoute,
} }

View File

@@ -0,0 +1,40 @@
import { runSendReminders } from "@kk/api/routers/index";
import { env } from "@kk/env/server";
import { createFileRoute } from "@tanstack/react-router";
async function handleCronReminders({ request }: { request: Request }) {
const secret = env.CRON_SECRET;
if (!secret) {
return new Response("CRON_SECRET not configured", { status: 500 });
}
// Accept the secret via Authorization header (Bearer) or JSON body
const authHeader = request.headers.get("Authorization");
let providedSecret: string | null = null;
if (authHeader?.startsWith("Bearer ")) {
providedSecret = authHeader.slice(7);
} else {
try {
const body = await request.json();
providedSecret = (body as { secret?: string }).secret ?? null;
} catch {
// ignore parse errors
}
}
if (!providedSecret || providedSecret !== secret) {
return new Response("Unauthorized", { status: 401 });
}
const result = await runSendReminders();
return Response.json(result);
}
export const Route = createFileRoute("/api/cron/reminders")({
server: {
handlers: {
POST: handleCronReminders,
},
},
});

View File

@@ -348,3 +348,99 @@ export async function sendCancellationEmail(params: {
html: cancellationHtml({ firstName: params.firstName }), html: cancellationHtml({ firstName: params.firstName }),
}); });
} }
function reminderHtml(params: { firstName?: string | null }) {
const greeting = params.firstName ? `Hoi ${params.firstName},` : "Hoi!";
// Registration opens at 2026-03-16T19:00:00+01:00
const openDateStr = "maandag 16 maart 2026 om 19:00";
const registrationUrl = `${baseUrl}/#registration`;
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nog 1 uur — inschrijvingen openen straks!</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<!-- Header -->
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Nog 1 uur!</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
${greeting}
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Over ongeveer <strong style="color:#ffffff;">1 uur</strong> openen de inschrijvingen voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong>.
</p>
<!-- Time box -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 8px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Inschrijvingen openen</p>
<p style="margin:0;font-size:18px;color:#ffffff;font-weight:600;">${openDateStr}</p>
</td>
</tr>
</table>
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Wees er snel bij — de plaatsen zijn beperkt!
</p>
<!-- CTA button -->
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${registrationUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Schrijf je in
</a>
</td>
</tr>
</table>
<p style="margin:16px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
Of ga naar: <span style="color:rgba(255,255,255,0.6);">${registrationUrl}</span>
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;">
Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd.<br/>
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a><br/>
Kunstenkamp — Een initiatief voor en door kunstenaars.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export async function sendReminderEmail(params: {
to: string;
firstName?: string | null;
}) {
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping reminder email");
return;
}
await transport.sendMail({
from,
to: params.to,
subject: "Nog 1 uur — inschrijvingen openen straks!",
html: reminderHtml({ firstName: params.firstName }),
});
}

View File

@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { db } from "@kk/db"; 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 { user } from "@kk/db/schema/auth";
import { drinkkaart, drinkkaartTopup } from "@kk/db/schema/drinkkaart"; import { drinkkaart, drinkkaartTopup } from "@kk/db/schema/drinkkaart";
import { env } from "@kk/env/server"; import { env } from "@kk/env/server";
@@ -10,12 +10,19 @@ import { z } from "zod";
import { import {
sendCancellationEmail, sendCancellationEmail,
sendConfirmationEmail, sendConfirmationEmail,
sendReminderEmail,
sendUpdateEmail, sendUpdateEmail,
} from "../email"; } from "../email";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index"; import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
import { generateQrSecret } from "../lib/drinkkaart-utils"; import { generateQrSecret } from "../lib/drinkkaart-utils";
import { drinkkaartRouter } from "./drinkkaart"; 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 // Shared helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -729,6 +736,97 @@ export const appRouter = {
return { success: true, message: "Admin toegang geweigerd" }; 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 // Checkout
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -835,3 +933,47 @@ export const appRouter = {
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;
export type AppRouterClient = RouterClient<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 };
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,9 @@
-- Add reminder table for email reminders 1 hour before registration opens
CREATE TABLE `reminder` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`sent_at` integer,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `reminder_email_idx` ON `reminder` (`email`);

View File

@@ -29,6 +29,34 @@
"when": 1772530000000, "when": 1772530000000,
"tag": "0003_add_guests", "tag": "0003_add_guests",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1772536320000,
"tag": "0004_drinkkaart",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1772596240000,
"tag": "0005_registration_drinkkaart_credit",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1772672880000,
"tag": "0006_mollie_migration",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1772931300000,
"tag": "0007_reminder",
"breakpoints": true
} }
] ]
} }

View File

@@ -2,3 +2,4 @@ export * from "./admin-requests";
export * from "./auth"; export * from "./auth";
export * from "./drinkkaart"; export * from "./drinkkaart";
export * from "./registrations"; export * from "./registrations";
export * from "./reminders";

View File

@@ -0,0 +1,18 @@
import { sql } from "drizzle-orm";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const reminder = sqliteTable(
"reminder",
{
id: text("id").primaryKey(), // UUID
email: text("email").notNull(),
sentAt: integer("sent_at", { mode: "timestamp_ms" }),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
},
(t) => [index("reminder_email_idx").on(t.email)],
);
export type Reminder = typeof reminder.$inferSelect;
export type NewReminder = typeof reminder.$inferInsert;

View File

@@ -26,6 +26,7 @@ export const env = createEnv({
.enum(["development", "production", "test"]) .enum(["development", "production", "test"])
.default("development"), .default("development"),
MOLLIE_API_KEY: z.string().min(1).optional(), MOLLIE_API_KEY: z.string().min(1).optional(),
CRON_SECRET: z.string().min(1).optional(),
}, },
runtimeEnv: process.env, runtimeEnv: process.env,
emptyStringAsUndefined: true, emptyStringAsUndefined: true,

View File

@@ -32,7 +32,11 @@ export const web = await TanStackStart("web", {
SMTP_FROM: getEnvVar("SMTP_FROM"), SMTP_FROM: getEnvVar("SMTP_FROM"),
// Payments (Mollie) // Payments (Mollie)
MOLLIE_API_KEY: getEnvVar("MOLLIE_API_KEY"), MOLLIE_API_KEY: getEnvVar("MOLLIE_API_KEY"),
// Cron secret for protected scheduled endpoints
CRON_SECRET: getEnvVar("CRON_SECRET"),
}, },
// Fire every hour so the reminder check can run at 18:00 on 2026-03-16
crons: ["0 * * * *"],
domains: ["kunstenkamp.be", "www.kunstenkamp.be"], domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
}); });

View File

@@ -18,14 +18,17 @@
"persistent": true "persistent": true
}, },
"db:push": { "db:push": {
"cache": false "cache": false,
"interactive": true
}, },
"db:generate": { "db:generate": {
"cache": false "cache": false,
"interactive": true
}, },
"db:migrate": { "db:migrate": {
"cache": false, "cache": false,
"persistent": true "persistent": true,
"interactive": true
}, },
"db:studio": { "db:studio": {
"cache": false, "cache": false,