Send reminder email 1 hour before registration opens #3
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Overview
Users watching the countdown banner should be able to opt in to receive an email reminder 1 hour before registration opens (currently
2026-03-16T19:00:00+01:00, so reminder fires at 18:00).User story
Implementation plan
1. Database —
remindertableAdd a new Drizzle table in
packages/db/src/schema/:Generate and apply a Drizzle migration (
packages/db/src/migrations/).2. API — oRPC procedures (
packages/api/src/routers/index.ts)Add two public (unauthenticated) procedures:
subscribeReminder({ email })— inserts a row if the email is not already subscribed and registration is not yet open; returns{ ok: true }.sendReminders()— admin-only (or protected by a shared secret via anAuthorizationheader) procedure that queries all rows wheresentAt IS NULL, sends the reminder email to each, and markssentAt. Called by the Cloudflare Cron Trigger (see §4).3. Email template (
packages/api/src/email.ts)Add
sendReminderEmail({ to, firstName? }):"Nog 1 uur — inschrijvingen openen straks!"#214e51style):https://kunstenkamp.be/#registration.4. Scheduled delivery — Cloudflare Cron Trigger
Since the app runs on Cloudflare Workers (no persistent background process), use a Cloudflare Cron Trigger:
[triggers] crons = ["0 * * * *"]entry (every hour) inwrangler.toml/ Alchemy infra config (packages/infra/).scheduledhandler (or a dedicated route), check ifDate.now()is within the reminder window (betweenREGISTRATION_OPENS_AT - 1handREGISTRATION_OPENS_AT), then callsendReminders().POST /api/cron/remindersroute protected by aCRON_SECRETenv var and invoke it from the cron.5. UI — opt-in form in
CountdownBannerExtend
apps/web/src/components/homepage/CountdownBanner.tsx:subscribeReminderoRPC procedure.6. Env vars
No new secrets required unless the cron route is HTTP-based, in which case add:
to
packages/env/src/server.ts(optionalz.string().optional()).Acceptance criteria
sentAtguard).Files to change
packages/db/src/schema/reminders.tsremindertablepackages/db/src/schema/index.tspackages/db/src/migrations/000X_reminder.sqlpackages/api/src/email.tssendReminderEmail()packages/api/src/routers/index.tssubscribeReminder+sendRemindersprocedurespackages/env/src/server.tsCRON_SECRETpackages/infra/apps/web/src/components/homepage/CountdownBanner.tsxImplementatie voltooid ✓
Alle acceptatiecriteria zijn geïmplementeerd. Hier een overzicht per onderdeel:
DB —
remindertabelpackages/db/src/schema/reminders.ts— nieuw schema:id,email,sentAt,createdAt+ index opemailpackages/db/src/migrations/0007_reminder.sql— handmatig geschreven migratie (CREATE TABLE + CREATE INDEX)packages/db/src/schema/index.ts— export toegevoegdAPI — oRPC procedures
packages/api/src/routers/index.ts:subscribeReminder({ email })— publiek, stille deduplicatie, weigert als registratie al open issendReminders({ secret })— beveiligd metCRON_SECRET, stuurt alleen in het 1-uur venster, markeertsentAtrunSendReminders()— losse export voor de cron HTTP-handler (geen drizzle-orm dep nodig in apps/web)E-mail
packages/api/src/email.ts:reminderHtml()— Nederlandse template,#214e51teal stijl, CTA naar/#registrationsendReminderEmail({ to })— onderwerp: "Nog 1 uur — inschrijvingen openen straks!"Cron HTTP-handler
apps/web/src/routes/api/cron/reminders.ts—POST /api/cron/remindersAuthorization: Bearerheader of JSON bodyrunSendReminders()aan uit@kk/apiapps/web/src/routeTree.gen.ts— route toegevoegd aan de router treeCloudflare Cron Trigger (Alchemy)
packages/infra/alchemy.run.ts:crons: ["0 * * * *"]toegevoegd aanTanStackStart()CRON_SECRETbinding toegevoegdEnv
packages/env/src/server.ts—CRON_SECRET: z.string().min(1).optional()toegevoegdUI — opt-in formulier
apps/web/src/components/homepage/CountdownBanner.tsx:ReminderFormcomponent met e-mailinput + "Herinner mij" knopTypeScript
mollie.ts(ontbrekendedrizzle-ormdep in apps/web) was pre-existent.