Send reminder email 1 hour before registration opens #3

Closed
opened 2026-03-10 12:31:12 +00:00 by zias · 1 comment
Owner

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

As a visitor who sees the countdown banner, I want to leave my email address so that I receive a reminder 1 hour before registration opens, allowing me to be ready the moment slots become available.


Implementation plan

1. Database — reminder table

Add a new Drizzle table in packages/db/src/schema/:

// packages/db/src/schema/reminders.ts
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)]);

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 an Authorization header) procedure that queries all rows where sentAt IS NULL, sends the reminder email to each, and marks sentAt. Called by the Cloudflare Cron Trigger (see §4).

3. Email template (packages/api/src/email.ts)

Add sendReminderEmail({ to, firstName? }):

  • Subject: "Nog 1 uur — inschrijvingen openen straks!"
  • Body (Dutch, matching existing dark-teal #214e51 style):
    • Announce that registration opens in ~1 hour.
    • Show the exact open date/time.
    • Include a direct CTA button linking to https://kunstenkamp.be/#registration.
    • Follow the same inline-styled HTML pattern as the other emails in the file.

firstName is optional since reminder sign-ups don't require a name — fall back to a generic greeting ("Hoi!").

4. Scheduled delivery — Cloudflare Cron Trigger

Since the app runs on Cloudflare Workers (no persistent background process), use a Cloudflare Cron Trigger:

  • Add a [triggers] crons = ["0 * * * *"] entry (every hour) in wrangler.toml / Alchemy infra config (packages/infra/).
  • In the Worker's scheduled handler (or a dedicated route), check if Date.now() is within the reminder window (between REGISTRATION_OPENS_AT - 1h and REGISTRATION_OPENS_AT), then call sendReminders().
  • Alternatively expose a POST /api/cron/reminders route protected by a CRON_SECRET env var and invoke it from the cron.

5. UI — opt-in form in CountdownBanner

Extend apps/web/src/components/homepage/CountdownBanner.tsx:

  • Below the countdown boxes, add a small inline form: email input + "Herinner mij" submit button.
  • On submit, call the subscribeReminder oRPC procedure.
  • Show a success state ("Je ontvangt een herinnering om 18:00!") and an already-subscribed state gracefully.
  • If registration is already open, hide the form entirely (consistent with the rest of the banner).

6. Env vars

No new secrets required unless the cron route is HTTP-based, in which case add:

CRON_SECRET=<random string>

to packages/env/src/server.ts (optional z.string().optional()).


Acceptance criteria

  • Visitor can submit their email on the countdown page before registration opens.
  • Duplicate emails are silently deduplicated (no error shown to user).
  • At 18:00 on 2026-03-16, all opted-in addresses receive the reminder email.
  • Each address receives the email at most once (sentAt guard).
  • After registration opens, the opt-in form is no longer shown.
  • Email follows the existing Dutch, dark-teal branded style.

Files to change

File Change
packages/db/src/schema/reminders.ts New file — reminder table
packages/db/src/schema/index.ts Export new table
packages/db/src/migrations/000X_reminder.sql New migration
packages/api/src/email.ts Add sendReminderEmail()
packages/api/src/routers/index.ts Add subscribeReminder + sendReminders procedures
packages/env/src/server.ts Optionally add CRON_SECRET
packages/infra/ Add Cloudflare Cron Trigger config
apps/web/src/components/homepage/CountdownBanner.tsx Add opt-in form
## 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 > As a visitor who sees the countdown banner, I want to leave my email address so that I receive a reminder 1 hour before registration opens, allowing me to be ready the moment slots become available. --- ## Implementation plan ### 1. Database — `reminder` table Add a new Drizzle table in `packages/db/src/schema/`: ```ts // packages/db/src/schema/reminders.ts 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)]); ``` 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 an `Authorization` header) procedure that queries all rows where `sentAt IS NULL`, sends the reminder email to each, and marks `sentAt`. Called by the Cloudflare Cron Trigger (see §4). ### 3. Email template (`packages/api/src/email.ts`) Add `sendReminderEmail({ to, firstName? })`: - Subject: `"Nog 1 uur — inschrijvingen openen straks!"` - Body (Dutch, matching existing dark-teal `#214e51` style): - Announce that registration opens in ~1 hour. - Show the exact open date/time. - Include a direct CTA button linking to `https://kunstenkamp.be/#registration`. - Follow the same inline-styled HTML pattern as the other emails in the file. > `firstName` is optional since reminder sign-ups don't require a name — fall back to a generic greeting ("Hoi!"). ### 4. Scheduled delivery — Cloudflare Cron Trigger Since the app runs on Cloudflare Workers (no persistent background process), use a **Cloudflare Cron Trigger**: - Add a `[triggers] crons = ["0 * * * *"]` entry (every hour) in `wrangler.toml` / Alchemy infra config (`packages/infra/`). - In the Worker's `scheduled` handler (or a dedicated route), check if `Date.now()` is within the reminder window (between `REGISTRATION_OPENS_AT - 1h` and `REGISTRATION_OPENS_AT`), then call `sendReminders()`. - Alternatively expose a `POST /api/cron/reminders` route protected by a `CRON_SECRET` env var and invoke it from the cron. ### 5. UI — opt-in form in `CountdownBanner` Extend `apps/web/src/components/homepage/CountdownBanner.tsx`: - Below the countdown boxes, add a small inline form: email input + "Herinner mij" submit button. - On submit, call the `subscribeReminder` oRPC procedure. - Show a success state ("Je ontvangt een herinnering om 18:00!") and an already-subscribed state gracefully. - If registration is already open, hide the form entirely (consistent with the rest of the banner). ### 6. Env vars No new secrets required unless the cron route is HTTP-based, in which case add: ``` CRON_SECRET=<random string> ``` to `packages/env/src/server.ts` (optional `z.string().optional()`). --- ## Acceptance criteria - [x] Visitor can submit their email on the countdown page before registration opens. - [x] Duplicate emails are silently deduplicated (no error shown to user). - [x] At **18:00 on 2026-03-16**, all opted-in addresses receive the reminder email. - [x] Each address receives the email at most once (`sentAt` guard). - [x] After registration opens, the opt-in form is no longer shown. - [x] Email follows the existing Dutch, dark-teal branded style. --- ## Files to change | File | Change | |---|---| | `packages/db/src/schema/reminders.ts` | New file — `reminder` table | | `packages/db/src/schema/index.ts` | Export new table | | `packages/db/src/migrations/000X_reminder.sql` | New migration | | `packages/api/src/email.ts` | Add `sendReminderEmail()` | | `packages/api/src/routers/index.ts` | Add `subscribeReminder` + `sendReminders` procedures | | `packages/env/src/server.ts` | Optionally add `CRON_SECRET` | | `packages/infra/` | Add Cloudflare Cron Trigger config | | `apps/web/src/components/homepage/CountdownBanner.tsx` | Add opt-in form |
Author
Owner

Implementatie voltooid ✓

Alle acceptatiecriteria zijn geïmplementeerd. Hier een overzicht per onderdeel:

DB — reminder tabel

  • packages/db/src/schema/reminders.ts — nieuw schema: id, email, sentAt, createdAt + index op email
  • packages/db/src/migrations/0007_reminder.sql — handmatig geschreven migratie (CREATE TABLE + CREATE INDEX)
  • packages/db/src/schema/index.ts — export toegevoegd

API — oRPC procedures

  • packages/api/src/routers/index.ts:
    • subscribeReminder({ email }) — publiek, stille deduplicatie, weigert als registratie al open is
    • sendReminders({ secret }) — beveiligd met CRON_SECRET, stuurt alleen in het 1-uur venster, markeert sentAt
    • runSendReminders() — 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, #214e51 teal stijl, CTA naar /#registration
    • sendReminderEmail({ to }) — onderwerp: "Nog 1 uur — inschrijvingen openen straks!"

Cron HTTP-handler

  • apps/web/src/routes/api/cron/reminders.tsPOST /api/cron/reminders
    • Accepteert secret via Authorization: Bearer header of JSON body
    • Roept runSendReminders() aan uit @kk/api
  • apps/web/src/routeTree.gen.ts — route toegevoegd aan de router tree

Cloudflare Cron Trigger (Alchemy)

  • packages/infra/alchemy.run.ts:
    • crons: ["0 * * * *"] toegevoegd aan TanStackStart()
    • CRON_SECRET binding toegevoegd

Env

  • packages/env/src/server.tsCRON_SECRET: z.string().min(1).optional() toegevoegd

UI — opt-in formulier

  • apps/web/src/components/homepage/CountdownBanner.tsx:
    • ReminderForm component met e-mailinput + "Herinner mij" knop
    • Success-state ("Je ontvangt een herinnering!"), already-subscribed + error afhandeling
    • Verborgen zodra registratie open is (de banner verdwijnt sowieso)

TypeScript

  • Geen nieuwe type-fouten geïntroduceerd. De bestaande fout in mollie.ts (ontbrekende drizzle-orm dep in apps/web) was pre-existent.
## Implementatie voltooid ✓ Alle acceptatiecriteria zijn geïmplementeerd. Hier een overzicht per onderdeel: ### DB — `reminder` tabel - `packages/db/src/schema/reminders.ts` — nieuw schema: `id`, `email`, `sentAt`, `createdAt` + index op `email` - `packages/db/src/migrations/0007_reminder.sql` — handmatig geschreven migratie (CREATE TABLE + CREATE INDEX) - `packages/db/src/schema/index.ts` — export toegevoegd ### API — oRPC procedures - `packages/api/src/routers/index.ts`: - `subscribeReminder({ email })` — publiek, stille deduplicatie, weigert als registratie al open is - `sendReminders({ secret })` — beveiligd met `CRON_SECRET`, stuurt alleen in het 1-uur venster, markeert `sentAt` - `runSendReminders()` — 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, `#214e51` teal stijl, CTA naar `/#registration` - `sendReminderEmail({ to })` — onderwerp: "Nog 1 uur — inschrijvingen openen straks!" ### Cron HTTP-handler - `apps/web/src/routes/api/cron/reminders.ts` — `POST /api/cron/reminders` - Accepteert secret via `Authorization: Bearer` header of JSON body - Roept `runSendReminders()` aan uit `@kk/api` - `apps/web/src/routeTree.gen.ts` — route toegevoegd aan de router tree ### Cloudflare Cron Trigger (Alchemy) - `packages/infra/alchemy.run.ts`: - `crons: ["0 * * * *"]` toegevoegd aan `TanStackStart()` - `CRON_SECRET` binding toegevoegd ### Env - `packages/env/src/server.ts` — `CRON_SECRET: z.string().min(1).optional()` toegevoegd ### UI — opt-in formulier - `apps/web/src/components/homepage/CountdownBanner.tsx`: - `ReminderForm` component met e-mailinput + "Herinner mij" knop - Success-state ("Je ontvangt een herinnering!"), already-subscribed + error afhandeling - Verborgen zodra registratie open is (de banner verdwijnt sowieso) ### TypeScript - Geen nieuwe type-fouten geïntroduceerd. De bestaande fout in `mollie.ts` (ontbrekende `drizzle-orm` dep in apps/web) was pre-existent.
zias closed this issue 2026-03-11 11:32:23 +00:00
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: zias/kunstenkamp#3