feat: reminder email opt-in 1 hour before registration opens
This commit is contained in:
@@ -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 `<!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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<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.
9
packages/db/src/migrations/0007_reminder.sql
Normal file
9
packages/db/src/migrations/0007_reminder.sql
Normal 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`);
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./admin-requests";
|
||||
export * from "./auth";
|
||||
export * from "./drinkkaart";
|
||||
export * from "./registrations";
|
||||
export * from "./reminders";
|
||||
|
||||
18
packages/db/src/schema/reminders.ts
Normal file
18
packages/db/src/schema/reminders.ts
Normal 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;
|
||||
1
packages/env/src/server.ts
vendored
1
packages/env/src/server.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user