diff --git a/packages/api/src/email.ts b/packages/api/src/email.ts index 93da0ac..8693218 100644 --- a/packages/api/src/email.ts +++ b/packages/api/src/email.ts @@ -1,6 +1,10 @@ import { env } from "@kk/env/server"; import nodemailer from "nodemailer"; +// --------------------------------------------------------------------------- +// Transport +// --------------------------------------------------------------------------- + // Singleton transport — created once per module, reused across all email sends. // Re-creating it on every call causes EventEmitter listener accumulation in // long-lived Cloudflare Worker processes, triggering memory leak warnings. @@ -27,46 +31,134 @@ function getTransport(): nodemailer.Transporter | null { const from = env.SMTP_FROM ?? "Kunstenkamp "; const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be"; -function registrationConfirmationHtml(params: { - firstName: string; - manageUrl: string; - signupUrl: string; - wantsToPerform: boolean; - artForm?: string | null; - giftAmount?: number; - drinkCardValue?: number; -}) { - const role = params.wantsToPerform - ? `Optreden${params.artForm ? ` — ${params.artForm}` : ""}` - : "Toeschouwer"; +// --------------------------------------------------------------------------- +// Primitives +// --------------------------------------------------------------------------- - const giftCents = params.giftAmount ?? 0; - // drinkCardValue is stored in euros (e.g., 5), giftAmount in cents (e.g., 5000) - const drinkCardCents = (params.drinkCardValue ?? 0) * 100; - const hasPayment = drinkCardCents > 0 || giftCents > 0; +/** Format a cent amount as a Euro string, e.g. 500 → "€5", 550 → "€5,50". */ +function formatEuro(cents: number): string { + return `€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`; +} - const formatEuro = (cents: number) => - `€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`; +/** + * A shaded info box with a small uppercase label and a bold value. + * + * @example + * infoBox("Jouw rol", "Optreden — Muziek") + * infoBox("Inschrijvingen openen", "maandag 16 maart 2026 om 19:00") + */ +function infoBox(label: string, value: string): string { + return ` + + + +
+

${label}

+

${value}

+
`; +} - const paymentSummary = hasPayment - ? ` - - - -
-

Betaling

- ${drinkCardCents > 0 ? `

Drinkkaart: ${formatEuro(drinkCardCents)}

` : ""} - ${giftCents > 0 ? `

Vrijwillige gift: ${formatEuro(giftCents)}

` : ""} -

Totaal: ${formatEuro(drinkCardCents + giftCents)}

-
` - : ""; +/** + * A CTA button. + * + * - `"primary"` — white background, teal text (main action) + * - `"secondary"` — translucent white background, white text (secondary action) + */ +function ctaButton( + href: string, + label: string, + style: "primary" | "secondary" = "primary", +): string { + const bg = style === "primary" ? "#ffffff" : "rgba(255,255,255,0.15)"; + const color = style === "primary" ? "#214e51" : "#ffffff"; + return ` + + + +
+ + ${label} + +
`; +} + +/** + * A small muted link line rendered below a CTA button, e.g. "Of kopieer deze link: …" + */ +function ctaSubtext(text: string): string { + return `

${text}

`; +} + +/** + * A standard body paragraph (16 px, 85% white, 1.6 line-height). + * Pass raw HTML as the content, e.g. include `` tags freely. + */ +function p(content: string): string { + return `

${content}

`; +} + +/** + * Payment summary box (drinkcard + gift + total). + * Returns an empty string when both amounts are zero. + */ +function paymentSummaryBox( + drinkCardCents: number, + giftCents: number, + heading = "Betaling", +): string { + if (drinkCardCents === 0 && giftCents === 0) return ""; + const total = drinkCardCents + giftCents; + return ` + + + +
+

${heading}

+ ${drinkCardCents > 0 ? `

Drinkkaart: ${formatEuro(drinkCardCents)}

` : ""} + ${giftCents > 0 ? `

Vrijwillige gift: ${formatEuro(giftCents)}

` : ""} +

Totaal: ${formatEuro(total)}

+
`; +} + +// --------------------------------------------------------------------------- +// Layout shell +// --------------------------------------------------------------------------- + +/** + * Wraps content in the full Kunstenkamp email chrome: + * outer grey background → teal card → header → body → footer. + * + * @param title HTML `` value + * @param heading Large white `<h1>` in the card header + * @param bodyHtml Everything between the header and the footer + * @param footerLines Optional extra lines rendered above the default contact + * line in the footer (e.g. unsubscribe notice). Pass an + * array of plain strings or HTML snippets. + * @param headerLabel Small uppercase label above the heading (default: "Kunstenkamp") + */ +function emailLayout({ + title, + heading, + bodyHtml, + footerLines = [], + headerLabel = "Kunstenkamp", +}: { + title: string; + heading: string; + bodyHtml: string; + footerLines?: string[]; + headerLabel?: string; +}): string { + const extraFooter = footerLines + .map((line) => `${line}<br/>`) + .join("\n "); return `<!DOCTYPE html> <html lang="nl"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>Bevestiging inschrijving + ${title} @@ -76,64 +168,21 @@ function registrationConfirmationHtml(params: {
-

Kunstenkamp

-

Je inschrijving is bevestigd!

+

${headerLabel}

+

${heading}

-

- Hoi ${params.firstName}, -

-

- We hebben je inschrijving voor Open Mic Night — vrijdag 18 april 2026 in goede orde ontvangen. -

- - - - - -
-

Jouw rol

-

${role}

-
- ${paymentSummary} -

- Maak een account aan om je inschrijving te beheren en (voor bezoekers) je Drinkkaart te activeren. -

- - - - - -
- - Account aanmaken - -
-

- Wil je je gegevens later nog aanpassen of je inschrijving annuleren? Gebruik dan de knop hieronder. De link is uniek voor jou — deel hem niet. -

- - - - - -
- - Beheer mijn inschrijving - -
-

- Of kopieer deze link: ${params.manageUrl} -

+ ${bodyHtml}

+ ${extraFooter} Vragen? Mail ons op info@kunstenkamp.be
Kunstenkamp vzw — Een initiatief voor en door kunstenaars.

@@ -147,6 +196,73 @@ function registrationConfirmationHtml(params: { `; } +// --------------------------------------------------------------------------- +// Shared role / payment helpers +// --------------------------------------------------------------------------- + +function roleLabel(wantsToPerform: boolean, artForm?: string | null): string { + return wantsToPerform + ? `Optreden${artForm ? ` — ${artForm}` : ""}` + : "Toeschouwer"; +} + +function toCents( + drinkCardValue: number | undefined, + giftAmount: number | undefined, +): { drinkCardCents: number; giftCents: number } { + // drinkCardValue is stored in euros (e.g., 5); giftAmount in cents (e.g., 5000) + return { + drinkCardCents: (drinkCardValue ?? 0) * 100, + giftCents: giftAmount ?? 0, + }; +} + +// --------------------------------------------------------------------------- +// Email composers +// --------------------------------------------------------------------------- + +function registrationConfirmationHtml(params: { + firstName: string; + manageUrl: string; + signupUrl: string; + wantsToPerform: boolean; + artForm?: string | null; + giftAmount?: number; + drinkCardValue?: number; +}): string { + const { drinkCardCents, giftCents } = toCents( + params.drinkCardValue, + params.giftAmount, + ); + + const body = [ + p(`Hoi ${params.firstName},`), + p( + `We hebben je inschrijving voor Open Mic Night — vrijdag 18 april 2026 in goede orde ontvangen.`, + ), + infoBox("Jouw rol", roleLabel(params.wantsToPerform, params.artForm)), + paymentSummaryBox(drinkCardCents, giftCents), + p( + "Maak een account aan om je inschrijving te beheren en (voor bezoekers) je Drinkkaart te activeren.", + ), + ctaButton(params.signupUrl, "Account aanmaken"), + `
`, + p( + "Wil je je gegevens later nog aanpassen of je inschrijving annuleren? Gebruik dan de knop hieronder. De link is uniek voor jou — deel hem niet.", + ), + ctaButton(params.manageUrl, "Beheer mijn inschrijving", "secondary"), + ctaSubtext( + `Of kopieer deze link: ${params.manageUrl}`, + ), + ].join("\n"); + + return emailLayout({ + title: "Bevestiging inschrijving", + heading: "Je inschrijving is bevestigd!", + bodyHtml: body, + }); +} + function updateConfirmationHtml(params: { firstName: string; manageUrl: string; @@ -154,138 +270,199 @@ function updateConfirmationHtml(params: { artForm?: string | null; giftAmount?: number; drinkCardValue?: number; -}) { - const role = params.wantsToPerform - ? `Optreden${params.artForm ? ` — ${params.artForm}` : ""}` - : "Toeschouwer"; +}): string { + const { drinkCardCents, giftCents } = toCents( + params.drinkCardValue, + params.giftAmount, + ); - const giftCents = params.giftAmount ?? 0; - // drinkCardValue is stored in euros (e.g., 5), giftAmount in cents (e.g., 5000) - const drinkCardCents = (params.drinkCardValue ?? 0) * 100; - const hasPayment = drinkCardCents > 0 || giftCents > 0; + const body = [ + p(`Hoi ${params.firstName},`), + p( + `Je inschrijving voor Open Mic Night — vrijdag 18 april 2026 is succesvol bijgewerkt.`, + ), + infoBox("Jouw rol", roleLabel(params.wantsToPerform, params.artForm)), + paymentSummaryBox(drinkCardCents, giftCents), + ctaButton(params.manageUrl, "Bekijk mijn inschrijving"), + ].join("\n"); - const formatEuro = (cents: number) => - `€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`; - - const paymentSummary = hasPayment - ? ` - - - -
-

Betaling

- ${drinkCardCents > 0 ? `

Drinkkaart: ${formatEuro(drinkCardCents)}

` : ""} - ${giftCents > 0 ? `

Vrijwillige gift: ${formatEuro(giftCents)}

` : ""} -

Totaal: ${formatEuro(drinkCardCents + giftCents)}

-
` - : ""; - - return ` - - - - Inschrijving bijgewerkt - - - - - - -
- - - - - - - - - - -
-

Kunstenkamp

-

Inschrijving bijgewerkt

-
-

- Hoi ${params.firstName}, -

-

- Je inschrijving voor Open Mic Night — vrijdag 18 april 2026 is succesvol bijgewerkt. -

- - - - -
-

Jouw rol

-

${role}

-
- ${paymentSummary} - - - - -
- - Bekijk mijn inschrijving - -
-
-

- Vragen? Mail ons op info@kunstenkamp.be -

-
-
- -`; + return emailLayout({ + title: "Inschrijving bijgewerkt", + heading: "Inschrijving bijgewerkt", + bodyHtml: body, + }); } -function cancellationHtml(params: { firstName: string }) { - return ` - - - - Inschrijving geannuleerd - - - - - - -
- - - - - - - - - - -
-

Kunstenkamp

-

Inschrijving geannuleerd

-
-

- Hoi ${params.firstName}, -

-

- Je inschrijving voor Open Mic Night — vrijdag 18 april 2026 is geannuleerd. -

-

- Van gedachten veranderd? Je kunt je altijd opnieuw inschrijven via kunstenkamp.be. -

-
-

- Vragen? Mail ons op info@kunstenkamp.be -

-
-
- -`; +function cancellationHtml(params: { firstName: string }): string { + const body = [ + p(`Hoi ${params.firstName},`), + p( + `Je inschrijving voor Open Mic Night — vrijdag 18 april 2026 is geannuleerd.`, + ), + p( + `Van gedachten veranderd? Je kunt je altijd opnieuw inschrijven via kunstenkamp.be.`, + ), + ].join("\n"); + + return emailLayout({ + title: "Inschrijving geannuleerd", + heading: "Inschrijving geannuleerd", + bodyHtml: body, + }); } +function reminder24hHtml(params: { firstName?: string | null }): string { + const greeting = params.firstName ? `Hoi ${params.firstName},` : "Hoi!"; + const openDateStr = "maandag 16 maart 2026 om 19:00"; + const registrationUrl = `${baseUrl}/#registration`; + + const body = [ + p(greeting), + p( + `Morgen openen de inschrijvingen voor Open Mic Night — vrijdag 18 april 2026. Zet je wekker!`, + ), + infoBox("Inschrijvingen openen", openDateStr), + p("Wees er snel bij — de plaatsen zijn beperkt!"), + ctaButton(registrationUrl, "Bekijk de pagina"), + ctaSubtext( + `Of ga naar: ${registrationUrl}`, + ), + ].join("\n"); + + return emailLayout({ + title: "Nog 24 uur — inschrijvingen openen morgen!", + heading: "Nog 24 uur!", + bodyHtml: body, + footerLines: [ + "Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd.", + ], + }); +} + +function reminderHtml(params: { firstName?: string | null }): string { + const greeting = params.firstName ? `Hoi ${params.firstName},` : "Hoi!"; + const openDateStr = "maandag 16 maart 2026 om 19:00"; + const registrationUrl = `${baseUrl}/#registration`; + + const body = [ + p(greeting), + p( + `Over ongeveer 1 uur openen de inschrijvingen voor Open Mic Night — vrijdag 18 april 2026.`, + ), + infoBox("Inschrijvingen openen", openDateStr), + p("Wees er snel bij — de plaatsen zijn beperkt!"), + ctaButton(registrationUrl, "Schrijf je in"), + ctaSubtext( + `Of ga naar: ${registrationUrl}`, + ), + ].join("\n"); + + return emailLayout({ + title: "Nog 1 uur — inschrijvingen openen straks!", + heading: "Nog 1 uur!", + bodyHtml: body, + footerLines: [ + "Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd.", + ], + }); +} + +function subscriptionConfirmationHtml(params: { email: string }): string { + const openDateStr = "maandag 16 maart 2026 om 19:00"; + const registrationUrl = `${baseUrl}/#registration`; + + const body = [ + p( + `We sturen een herinnering naar ${params.email} wanneer de inschrijvingen openen.`, + ), + infoBox("Inschrijvingen openen", openDateStr), + p( + "Je ontvangt twee herinneringen: één 24 uur van tevoren en één 1 uur voor de opening.", + ), + p("Wees er snel bij — de plaatsen zijn beperkt!"), + ctaButton(registrationUrl, "Bekijk de pagina"), + ].join("\n"); + + return emailLayout({ + title: "Herinnering ingepland — Open Mic Night", + heading: "Je herinnering is ingepland!", + bodyHtml: body, + footerLines: [ + "Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd op kunstenkamp.be.", + ], + }); +} + +function paymentReminderHtml(params: { + firstName: string; + accountUrl: string; + manageUrl: string; + drinkCardValue?: number; + giftAmount?: number; +}): string { + const { drinkCardCents, giftCents } = toCents( + params.drinkCardValue, + params.giftAmount, + ); + const totalCents = drinkCardCents + giftCents; + + const amountLine = + totalCents > 0 + ? p( + `Je betaling van ${formatEuro(totalCents)} staat nog open. Log in op je account om te betalen.`, + ) + : p("Je betaling staat nog open. Log in op je account om te betalen."); + + const body = [ + p(`Hoi ${params.firstName},`), + p( + `Je hebt je ingeschreven voor Open Mic Night — vrijdag 18 april 2026, maar we hebben nog geen betaling ontvangen.`, + ), + amountLine, + ctaButton(params.accountUrl, "Betaal nu"), + ctaSubtext( + `Je inschrijving beheren? Klik hier`, + ), + ].join("\n"); + + return emailLayout({ + title: "Herinnering: betaling open", + heading: "Betaling nog open", + bodyHtml: body, + }); +} + +function paymentConfirmationHtml(params: { + firstName: string; + manageUrl: string; + drinkCardValue?: number; + giftAmount?: number; +}): string { + const { drinkCardCents, giftCents } = toCents( + params.drinkCardValue, + params.giftAmount, + ); + + const body = [ + p(`Hoi ${params.firstName},`), + p( + `We hebben je betaling voor Open Mic Night — vrijdag 18 april 2026 in goede orde ontvangen. Tot dan!`, + ), + paymentSummaryBox(drinkCardCents, giftCents, "Betaling ontvangen"), + ctaButton(params.manageUrl, "Beheer mijn inschrijving", "secondary"), + ].join("\n"); + + return emailLayout({ + title: "Betaling ontvangen", + heading: "Betaling ontvangen!", + bodyHtml: body, + }); +} + +// --------------------------------------------------------------------------- +// Public send functions +// --------------------------------------------------------------------------- + export async function sendConfirmationEmail(params: { to: string; firstName: string; @@ -369,83 +546,39 @@ export async function sendCancellationEmail(params: { }); } -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`; +export async function sendSubscriptionConfirmationEmail(params: { + to: string; +}) { + const transport = getTransport(); + if (!transport) { + console.warn( + "SMTP not configured — skipping subscription confirmation email", + ); + return; + } + await transport.sendMail({ + from, + to: params.to, + subject: "Herinnering ingepland — Open Mic Night", + html: subscriptionConfirmationHtml({ email: params.to }), + }); +} - 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! -

- - - - - -
- - Schrijf je in - -
-

- 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 sendReminder24hEmail(params: { + to: string; + firstName?: string | null; +}) { + const transport = getTransport(); + if (!transport) { + console.warn("SMTP not configured — skipping 24h reminder email"); + return; + } + await transport.sendMail({ + from, + to: params.to, + subject: "Nog 24 uur — inschrijvingen openen morgen!", + html: reminder24hHtml({ firstName: params.firstName }), + }); } export async function sendReminderEmail(params: { @@ -465,87 +598,6 @@ export async function sendReminderEmail(params: { }); } -function paymentReminderHtml(params: { - firstName: string; - accountUrl: string; - manageUrl: string; - drinkCardValue?: number; - giftAmount?: number; -}) { - const formatEuro = (cents: number) => - `€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`; - - const giftCents = params.giftAmount ?? 0; - const drinkCardCents = (params.drinkCardValue ?? 0) * 100; - const totalCents = drinkCardCents + giftCents; - const totalStr = totalCents > 0 ? formatEuro(totalCents) : ""; - - const amountLine = totalStr - ? `

- Je betaling van ${totalStr} staat nog open. Log in op je account om te betalen. -

` - : `

- Je betaling staat nog open. Log in op je account om te betalen. -

`; - - return ` - - - - - Herinnering: betaling open - - - - - - -
- - - - - - - - - - -
-

Kunstenkamp

-

Betaling nog open

-
-

- Hoi ${params.firstName}, -

-

- Je hebt je ingeschreven voor Open Mic Night — vrijdag 18 april 2026, maar we hebben nog geen betaling ontvangen. -

- ${amountLine} - - - - - -
- - Betaal nu - -
-

- Je inschrijving beheren? Klik hier -

-
-

- Vragen? Mail ons op info@kunstenkamp.be
- Kunstenkamp vzw — Een initiatief voor en door kunstenaars. -

-
-
- -`; -} - export async function sendPaymentReminderEmail(params: { to: string; firstName: string; @@ -574,88 +626,6 @@ export async function sendPaymentReminderEmail(params: { }); } -function paymentConfirmationHtml(params: { - firstName: string; - manageUrl: string; - drinkCardValue?: number; - giftAmount?: number; -}) { - const formatEuro = (cents: number) => - `€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`; - - const giftCents = params.giftAmount ?? 0; - const drinkCardCents = (params.drinkCardValue ?? 0) * 100; - const totalCents = drinkCardCents + giftCents; - - const paymentSummary = - totalCents > 0 - ? ` - - - -
-

Betaling ontvangen

- ${drinkCardCents > 0 ? `

Drinkkaart: ${formatEuro(drinkCardCents)}

` : ""} - ${giftCents > 0 ? `

Vrijwillige gift: ${formatEuro(giftCents)}

` : ""} -

Totaal: ${formatEuro(totalCents)}

-
` - : ""; - - return ` - - - - - Betaling ontvangen - - - - - - -
- - - - - - - - - - -
-

Kunstenkamp

-

Betaling ontvangen!

-
-

- Hoi ${params.firstName}, -

-

- We hebben je betaling voor Open Mic Night — vrijdag 18 april 2026 in goede orde ontvangen. Tot dan! -

- ${paymentSummary} - - - - - -
- - Beheer mijn inschrijving - -
-
-

- Vragen? Mail ons op info@kunstenkamp.be
- Kunstenkamp vzw — Een initiatief voor en door kunstenaars. -

-
-
- -`; -} - export async function sendPaymentConfirmationEmail(params: { to: string; firstName: string; diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts index d5ac348..f9912af 100644 --- a/packages/api/src/routers/index.ts +++ b/packages/api/src/routers/index.ts @@ -22,18 +22,26 @@ import { sendCancellationEmail, sendConfirmationEmail, sendPaymentReminderEmail, + sendReminder24hEmail, sendReminderEmail, + sendSubscriptionConfirmationEmail, 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 +// Registration opens at this date — reminders fire 24 hours and 1 hour before const REGISTRATION_OPENS_AT = new Date("2026-03-16T19:00:00+01:00"); -const REMINDER_WINDOW_START = new Date( +const REMINDER_1H_WINDOW_START = new Date( REGISTRATION_OPENS_AT.getTime() - 60 * 60 * 1000, ); +const REMINDER_24H_WINDOW_START = new Date( + REGISTRATION_OPENS_AT.getTime() - 25 * 60 * 60 * 1000, +); +const REMINDER_24H_WINDOW_END = new Date( + REGISTRATION_OPENS_AT.getTime() - 23 * 60 * 60 * 1000, +); // --------------------------------------------------------------------------- // Shared helpers @@ -827,9 +835,10 @@ export const appRouter = { // --------------------------------------------------------------------------- /** - * 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. + * Subscribe an email address to receive reminders before registration opens: + * one 24 hours before and one 1 hour before. 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() })) @@ -858,6 +867,11 @@ export const appRouter = { email: input.email, }); + // Fire-and-forget — don't let a mail failure block the response + sendSubscriptionConfirmationEmail({ to: input.email }).catch((err) => + console.error("Failed to send subscription confirmation email:", err), + ); + return { ok: true }; }), @@ -867,8 +881,9 @@ export const appRouter = { * 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). + * Fires two waves: + * - 24 hours before: window [REGISTRATION_OPENS_AT - 25h, REGISTRATION_OPENS_AT - 23h) + * - 1 hour before: window [REGISTRATION_OPENS_AT - 1h, REGISTRATION_OPENS_AT) */ sendReminders: publicProcedure .input(z.object({ secret: z.string() })) @@ -879,34 +894,61 @@ export const appRouter = { } const now = Date.now(); - const windowStart = REMINDER_WINDOW_START.getTime(); - const windowEnd = REGISTRATION_OPENS_AT.getTime(); + const in24hWindow = + now >= REMINDER_24H_WINDOW_START.getTime() && + now < REMINDER_24H_WINDOW_END.getTime(); + const in1hWindow = + now >= REMINDER_1H_WINDOW_START.getTime() && + now < REGISTRATION_OPENS_AT.getTime(); - // Only send during the reminder window - if (now < windowStart || now >= windowEnd) { + if (!in24hWindow && !in1hWindow) { 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); + if (in24hWindow) { + // Fetch reminders where the 24h email has not been sent yet + const pending = await db + .select() + .from(reminder) + .where(isNull(reminder.sent24hAt)); + + for (const row of pending) { + try { + await sendReminder24hEmail({ to: row.email }); + await db + .update(reminder) + .set({ sent24hAt: new Date() }) + .where(eq(reminder.id, row.id)); + sent++; + } catch (err) { + errors.push(`24h ${row.email}: ${String(err)}`); + console.error(`Failed to send 24h reminder to ${row.email}:`, err); + } + } + } + + if (in1hWindow) { + // Fetch reminders where the 1h email has not been sent yet + const pending = await db + .select() + .from(reminder) + .where(isNull(reminder.sentAt)); + + 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(`1h ${row.email}: ${String(err)}`); + console.error(`Failed to send 1h reminder to ${row.email}:`, err); + } } } @@ -1032,32 +1074,59 @@ export async function runSendReminders(): Promise<{ errors: string[]; }> { const now = Date.now(); - const windowStart = REMINDER_WINDOW_START.getTime(); - const windowEnd = REGISTRATION_OPENS_AT.getTime(); + const in24hWindow = + now >= REMINDER_24H_WINDOW_START.getTime() && + now < REMINDER_24H_WINDOW_END.getTime(); + const in1hWindow = + now >= REMINDER_1H_WINDOW_START.getTime() && + now < REGISTRATION_OPENS_AT.getTime(); - if (now < windowStart || now >= windowEnd) { + if (!in24hWindow && !in1hWindow) { 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); + if (in24hWindow) { + const pending = await db + .select() + .from(reminder) + .where(isNull(reminder.sent24hAt)); + + for (const row of pending) { + try { + await sendReminder24hEmail({ to: row.email }); + await db + .update(reminder) + .set({ sent24hAt: new Date() }) + .where(eq(reminder.id, row.id)); + sent++; + } catch (err) { + errors.push(`24h ${row.email}: ${String(err)}`); + console.error(`Failed to send 24h reminder to ${row.email}:`, err); + } + } + } + + if (in1hWindow) { + const pending = await db + .select() + .from(reminder) + .where(isNull(reminder.sentAt)); + + 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(`1h ${row.email}: ${String(err)}`); + console.error(`Failed to send 1h reminder to ${row.email}:`, err); + } } } diff --git a/packages/db/src/migrations/0009_reminder_24h.sql b/packages/db/src/migrations/0009_reminder_24h.sql new file mode 100644 index 0000000..b638aff --- /dev/null +++ b/packages/db/src/migrations/0009_reminder_24h.sql @@ -0,0 +1,2 @@ +-- Add sent_24h_at column to track whether the 24-hour-before reminder was sent +ALTER TABLE `reminder` ADD `sent_24h_at` integer; diff --git a/packages/db/src/schema/reminders.ts b/packages/db/src/schema/reminders.ts index 90ca18d..15722a2 100644 --- a/packages/db/src/schema/reminders.ts +++ b/packages/db/src/schema/reminders.ts @@ -6,6 +6,9 @@ export const reminder = sqliteTable( { id: text("id").primaryKey(), // UUID email: text("email").notNull(), + /** Set when the 24-hours-before reminder has been sent. */ + sent24hAt: integer("sent_24h_at", { mode: "timestamp_ms" }), + /** Set when the 1-hour-before reminder has been sent. */ sentAt: integer("sent_at", { mode: "timestamp_ms" }), createdAt: integer("created_at", { mode: "timestamp_ms" }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) diff --git a/packages/infra/alchemy.run.ts b/packages/infra/alchemy.run.ts index 6611a3b..ebf6583 100644 --- a/packages/infra/alchemy.run.ts +++ b/packages/infra/alchemy.run.ts @@ -35,7 +35,7 @@ export const web = await TanStackStart("web", { // 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 + // Fire every hour so reminder checks can run at 19:00 on 2026-03-15 (24h) and 18:00 on 2026-03-16 (1h) crons: ["0 * * * *"], domains: ["kunstenkamp.be", "www.kunstenkamp.be"], });