-
+
Inschrijvingen openen op
-
+
{openDate} om {openTime}
- {/* Countdown boxes */}
-
+ {/* Countdown — single row forced via grid, scales down on small screens */}
+
-
+
:
-
+
:
-
+
:
-
+
Kom snel terug! Zodra de inschrijvingen openen kun je je hier
registreren als toeschouwer of als artiest.
+
+ {/* Email reminder opt-in — always last */}
+
);
}
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 1ca7a8a..84f9305 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -21,6 +21,7 @@ import { Route as ManageTokenRouteImport } from './routes/manage.$token'
import { Route as AdminDrinkkaartRouteImport } from './routes/admin/drinkkaart'
import { Route as ApiWebhookMollieRouteImport } from './routes/api/webhook/mollie'
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/$'
const TermsRoute = TermsRouteImport.update({
@@ -83,6 +84,11 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
path: '/api/rpc/$',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiCronRemindersRoute = ApiCronRemindersRouteImport.update({
+ id: '/api/cron/reminders',
+ path: '/api/cron/reminders',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
@@ -101,6 +107,7 @@ export interface FileRoutesByFullPath {
'/manage/$token': typeof ManageTokenRoute
'/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
+ '/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
@@ -116,6 +123,7 @@ export interface FileRoutesByTo {
'/manage/$token': typeof ManageTokenRoute
'/admin': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
+ '/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
@@ -132,6 +140,7 @@ export interface FileRoutesById {
'/manage/$token': typeof ManageTokenRoute
'/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
+ '/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
@@ -149,6 +158,7 @@ export interface FileRouteTypes {
| '/manage/$token'
| '/admin/'
| '/api/auth/$'
+ | '/api/cron/reminders'
| '/api/rpc/$'
| '/api/webhook/mollie'
fileRoutesByTo: FileRoutesByTo
@@ -164,6 +174,7 @@ export interface FileRouteTypes {
| '/manage/$token'
| '/admin'
| '/api/auth/$'
+ | '/api/cron/reminders'
| '/api/rpc/$'
| '/api/webhook/mollie'
id:
@@ -179,6 +190,7 @@ export interface FileRouteTypes {
| '/manage/$token'
| '/admin/'
| '/api/auth/$'
+ | '/api/cron/reminders'
| '/api/rpc/$'
| '/api/webhook/mollie'
fileRoutesById: FileRoutesById
@@ -195,6 +207,7 @@ export interface RootRouteChildren {
ManageTokenRoute: typeof ManageTokenRoute
AdminIndexRoute: typeof AdminIndexRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
+ ApiCronRemindersRoute: typeof ApiCronRemindersRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
ApiWebhookMollieRoute: typeof ApiWebhookMollieRoute
}
@@ -285,6 +298,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiRpcSplatRouteImport
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/$': {
id: '/api/auth/$'
path: '/api/auth/$'
@@ -307,6 +327,7 @@ const rootRouteChildren: RootRouteChildren = {
ManageTokenRoute: ManageTokenRoute,
AdminIndexRoute: AdminIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
+ ApiCronRemindersRoute: ApiCronRemindersRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute,
ApiWebhookMollieRoute: ApiWebhookMollieRoute,
}
diff --git a/apps/web/src/routes/api/cron/reminders.ts b/apps/web/src/routes/api/cron/reminders.ts
new file mode 100644
index 0000000..4ee4db4
--- /dev/null
+++ b/apps/web/src/routes/api/cron/reminders.ts
@@ -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,
+ },
+ },
+});
diff --git a/packages/api/src/email.ts b/packages/api/src/email.ts
index 997ec0c..2e96546 100644
--- a/packages/api/src/email.ts
+++ b/packages/api/src/email.ts
@@ -348,3 +348,99 @@ export async function sendCancellationEmail(params: {
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 `
+
+
+
+
+
Nog 1 uur — inschrijvingen openen straks!
+
+
+
+
+
+
+
+
+ |
+ Kunstenkamp
+ Nog 1 uur!
+ |
+
+
+
+ |
+
+ ${greeting}
+
+
+ Over ongeveer 1 uur openen de inschrijvingen voor Open Mic Night — vrijdag 18 april 2026.
+
+
+
+
+ |
+ Inschrijvingen openen
+ ${openDateStr}
+ |
+
+
+
+ Wees er snel bij — de plaatsen zijn beperkt!
+
+
+
+
+ Of ga naar: ${registrationUrl}
+
+ |
+
+
+
+ |
+
+ Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd.
+ Vragen? Mail ons op info@kunstenkamp.be
+ Kunstenkamp — Een initiatief voor en door kunstenaars.
+
+ |
+
+
+ |
+
+
+
+`;
+}
+
+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 }),
+ });
+}
diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts
index 777f895..a672c26 100644
--- a/packages/api/src/routers/index.ts
+++ b/packages/api/src/routers/index.ts
@@ -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
;
+
+/**
+ * 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 };
+}
diff --git a/packages/db/local.db b/packages/db/local.db
deleted file mode 100644
index 0a06b00..0000000
Binary files a/packages/db/local.db and /dev/null differ
diff --git a/packages/db/local.db-shm b/packages/db/local.db-shm
deleted file mode 100644
index 5984cc6..0000000
Binary files a/packages/db/local.db-shm and /dev/null differ
diff --git a/packages/db/local.db-wal b/packages/db/local.db-wal
deleted file mode 100644
index 460988c..0000000
Binary files a/packages/db/local.db-wal and /dev/null differ
diff --git a/packages/db/src/migrations/0007_reminder.sql b/packages/db/src/migrations/0007_reminder.sql
new file mode 100644
index 0000000..af94d29
--- /dev/null
+++ b/packages/db/src/migrations/0007_reminder.sql
@@ -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`);
diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json
index fa2d5be..0a3ed89 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -29,6 +29,34 @@
"when": 1772530000000,
"tag": "0003_add_guests",
"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
}
]
}
diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts
index e6373c7..11148ef 100644
--- a/packages/db/src/schema/index.ts
+++ b/packages/db/src/schema/index.ts
@@ -2,3 +2,4 @@ export * from "./admin-requests";
export * from "./auth";
export * from "./drinkkaart";
export * from "./registrations";
+export * from "./reminders";
diff --git a/packages/db/src/schema/reminders.ts b/packages/db/src/schema/reminders.ts
new file mode 100644
index 0000000..90ca18d
--- /dev/null
+++ b/packages/db/src/schema/reminders.ts
@@ -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;
diff --git a/packages/env/src/server.ts b/packages/env/src/server.ts
index 41a3d43..c85881d 100644
--- a/packages/env/src/server.ts
+++ b/packages/env/src/server.ts
@@ -26,6 +26,7 @@ export const env = createEnv({
.enum(["development", "production", "test"])
.default("development"),
MOLLIE_API_KEY: z.string().min(1).optional(),
+ CRON_SECRET: z.string().min(1).optional(),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,
diff --git a/packages/infra/alchemy.run.ts b/packages/infra/alchemy.run.ts
index 87bab0c..6611a3b 100644
--- a/packages/infra/alchemy.run.ts
+++ b/packages/infra/alchemy.run.ts
@@ -32,7 +32,11 @@ export const web = await TanStackStart("web", {
SMTP_FROM: getEnvVar("SMTP_FROM"),
// Payments (Mollie)
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"],
});
diff --git a/turbo.json b/turbo.json
index d85764a..f32e48c 100644
--- a/turbo.json
+++ b/turbo.json
@@ -18,14 +18,17 @@
"persistent": true
},
"db:push": {
- "cache": false
+ "cache": false,
+ "interactive": true
},
"db:generate": {
- "cache": false
+ "cache": false,
+ "interactive": true
},
"db:migrate": {
"cache": false,
- "persistent": true
+ "persistent": true,
+ "interactive": true
},
"db:studio": {
"cache": false,