import { randomUUID } from "node:crypto"; import { db } from "@kk/db"; 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"; import type { RouterClient } from "@orpc/server"; import { and, count, desc, eq, gte, isNull, like, lte, or, sum, } from "drizzle-orm"; import { z } from "zod"; import { sendCancellationEmail, sendConfirmationEmail, sendPaymentReminderEmail, 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-11T10:30:00+01:00"); const REMINDER_WINDOW_START = new Date( REGISTRATION_OPENS_AT.getTime() - 60 * 60 * 1000, ); // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- /** Drink card price in euros: €5 base + €2 per extra guest. */ function drinkCardEuros(guestCount: number): number { return 5 + guestCount * 2; } /** Drink card price in cents for payment processing. */ function drinkCardCents(guestCount: number): number { return drinkCardEuros(guestCount) * 100; } /** Parses the stored guest JSON blob, returning an empty array on failure. */ function parseGuestsJson(raw: string | null): Array<{ firstName: string; lastName: string; email?: string; phone?: string; }> { if (!raw) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } /** * Credits a watcher's drinkCardValue to their drinkkaart account. * * Safe to call multiple times — the `drinkkaartCreditedAt IS NULL` guard and the * `mollie_payment_id` UNIQUE constraint together prevent double-crediting even * if called concurrently (webhook + signup racing each other). * * Returns a status string describing the outcome. */ export async function creditRegistrationToAccount( email: string, userId: string, ): Promise<{ credited: boolean; amountCents: number; status: | "credited" | "already_credited" | "no_paid_registration" | "zero_value"; }> { // Find the most recent paid watcher registration for this email that hasn't // been credited yet and has a non-zero drink card value. const rows = await db .select() .from(registration) .where( and( eq(registration.email, email), eq(registration.registrationType, "watcher"), eq(registration.paymentStatus, "paid"), isNull(registration.cancelledAt), isNull(registration.drinkkaartCreditedAt), ), ) .orderBy(desc(registration.createdAt)) .limit(1); const reg = rows[0]; if (!reg) { // Either no paid registration exists, or it was already credited. // Distinguish between the two for better logging. const credited = await db .select({ id: registration.id }) .from(registration) .where( and( eq(registration.email, email), eq(registration.registrationType, "watcher"), eq(registration.paymentStatus, "paid"), isNull(registration.cancelledAt), ), ) .limit(1) .then((r) => r[0]); return { credited: false, amountCents: 0, status: credited ? "already_credited" : "no_paid_registration", }; } const drinkCardValueEuros = reg.drinkCardValue ?? 0; if (drinkCardValueEuros === 0) { return { credited: false, amountCents: 0, status: "zero_value" }; } const amountCents = drinkCardValueEuros * 100; // Get or create the drinkkaart for this user. let card = await db .select() .from(drinkkaart) .where(eq(drinkkaart.userId, userId)) .limit(1) .then((r) => r[0]); if (!card) { const now = new Date(); card = { id: randomUUID(), userId, balance: 0, version: 0, qrSecret: generateQrSecret(), createdAt: now, updatedAt: now, }; await db.insert(drinkkaart).values(card); } const balanceBefore = card.balance; const balanceAfter = balanceBefore + amountCents; // Optimistic-lock update — if concurrent, the second caller will see // drinkkaartCreditedAt IS NOT NULL and return "already_credited" above. const updateResult = await db .update(drinkkaart) .set({ balance: balanceAfter, version: card.version + 1, updatedAt: new Date(), }) .where( and(eq(drinkkaart.id, card.id), eq(drinkkaart.version, card.version)), ); if (updateResult.rowsAffected === 0) { // Lost the optimistic lock race — the other caller will handle it. return { credited: false, amountCents: 0, status: "already_credited" }; } // Record the topup. Re-use the registration's molliePaymentId so the // topup row is clearly linked to the original payment. We prefix with // "reg_" to distinguish from direct drinkkaart top-up orders if needed. const topupPaymentId = reg.molliePaymentId ? `reg_${reg.molliePaymentId}` : null; await db.insert(drinkkaartTopup).values({ id: randomUUID(), drinkkaartId: card.id, userId, amountCents, balanceBefore, balanceAfter, type: "payment", molliePaymentId: topupPaymentId, adminId: null, reason: "Drinkkaart bij registratie", paidAt: reg.paidAt ?? new Date(), }); // Mark the registration as credited — prevents any future double-credit. await db .update(registration) .set({ drinkkaartCreditedAt: new Date() }) .where(eq(registration.id, reg.id)); return { credited: true, amountCents, status: "credited" }; } /** Fetches a single registration by management token, throws if not found or cancelled. */ async function getActiveRegistration(token: string) { const rows = await db .select() .from(registration) .where( and( eq(registration.managementToken, token), isNull(registration.cancelledAt), ), ) .limit(1); const row = rows[0]; if (!row) throw new Error("Inschrijving niet gevonden of al geannuleerd"); return row; } // --------------------------------------------------------------------------- // Schemas // --------------------------------------------------------------------------- const registrationTypeSchema = z.enum(["performer", "watcher"]); const guestSchema = z.object({ firstName: z.string().min(1), lastName: z.string().min(1), email: z.string().email().optional().or(z.literal("")), phone: z.string().optional(), }); const coreRegistrationFields = { firstName: z.string().min(1), lastName: z.string().min(1), email: z.string().email(), phone: z.string().optional(), registrationType: registrationTypeSchema.default("watcher"), artForm: z.string().optional(), experience: z.string().optional(), isOver16: z.boolean().optional(), guests: z.array(guestSchema).max(9).optional(), extraQuestions: z.string().optional(), giftAmount: z.number().int().min(0).default(0), }; const submitRegistrationSchema = z.object(coreRegistrationFields); const updateRegistrationSchema = z.object({ token: z.string().uuid(), ...coreRegistrationFields, }); const getRegistrationsSchema = z.object({ search: z.string().optional(), registrationType: registrationTypeSchema.optional(), artForm: z.string().optional(), fromDate: z.string().datetime().optional(), toDate: z.string().datetime().optional(), page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(100).default(50), }); // --------------------------------------------------------------------------- // Router // --------------------------------------------------------------------------- export const appRouter = { healthCheck: publicProcedure.handler(() => "OK"), drinkkaart: drinkkaartRouter, privateData: protectedProcedure.handler(({ context }) => ({ message: "This is private", user: context.session?.user, })), submitRegistration: publicProcedure .input(submitRegistrationSchema) .handler(async ({ input }) => { const managementToken = randomUUID(); const isPerformer = input.registrationType === "performer"; const guests = isPerformer ? [] : (input.guests ?? []); await db.insert(registration).values({ id: randomUUID(), firstName: input.firstName, lastName: input.lastName, email: input.email, phone: input.phone || null, registrationType: input.registrationType, artForm: isPerformer ? input.artForm || null : null, experience: isPerformer ? input.experience || null : null, isOver16: isPerformer ? (input.isOver16 ?? false) : false, drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length), guests: guests.length > 0 ? JSON.stringify(guests) : null, extraQuestions: input.extraQuestions || null, giftAmount: input.giftAmount, managementToken, }); await sendConfirmationEmail({ to: input.email, firstName: input.firstName, managementToken, wantsToPerform: isPerformer, artForm: input.artForm, giftAmount: input.giftAmount, drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length), }).catch((err) => console.error("Failed to send confirmation email:", err), ); return { success: true, managementToken }; }), getRegistrationByToken: publicProcedure .input(z.object({ token: z.string().uuid() })) .handler(async ({ input }) => { const rows = await db .select() .from(registration) .where(eq(registration.managementToken, input.token)) .limit(1); const row = rows[0]; if (!row) throw new Error("Inschrijving niet gevonden"); if (row.cancelledAt) throw new Error("Deze inschrijving is geannuleerd"); return row; }), updateRegistration: publicProcedure .input(updateRegistrationSchema) .handler(async ({ input }) => { const row = await getActiveRegistration(input.token); const isPerformer = input.registrationType === "performer"; const guests = isPerformer ? [] : (input.guests ?? []); // Validate gift amount cannot be changed after payment const hasPaidGift = (row.paymentStatus === "paid" || row.paymentStatus === "extra_payment_pending") && (row.giftAmount ?? 0) > 0; if (hasPaidGift && input.giftAmount !== row.giftAmount) { throw new Error( "Gift kan niet worden aangepast na betaling. Neem contact op met de organisatie.", ); } // Detect whether an already-paid watcher is adding extra guests so we // can charge them the delta instead of the full amount. const isPaid = row.paymentStatus === "paid"; const oldGuestCount = isPerformer ? 0 : parseGuestsJson(row.guests).length; const newGuestCount = guests.length; const extraGuests = !isPerformer && isPaid ? newGuestCount - oldGuestCount : 0; // Determine the new paymentStatus and paymentAmount (delta in cents). // Only flag extra_payment_pending when the watcher genuinely owes more. const newPaymentStatus = isPaid && extraGuests > 0 ? "extra_payment_pending" : row.paymentStatus; const newPaymentAmount = newPaymentStatus === "extra_payment_pending" ? extraGuests * 2 * 100 // €2 per extra guest, in cents : row.paymentAmount; await db .update(registration) .set({ firstName: input.firstName, lastName: input.lastName, email: input.email, phone: input.phone || null, registrationType: input.registrationType, artForm: isPerformer ? input.artForm || null : null, experience: isPerformer ? input.experience || null : null, isOver16: isPerformer ? (input.isOver16 ?? false) : false, drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length), guests: guests.length > 0 ? JSON.stringify(guests) : null, extraQuestions: input.extraQuestions || null, giftAmount: input.giftAmount, paymentStatus: newPaymentStatus, paymentAmount: newPaymentAmount, }) .where(eq(registration.managementToken, input.token)); await sendUpdateEmail({ to: input.email, firstName: input.firstName, managementToken: input.token, wantsToPerform: isPerformer, artForm: input.artForm, giftAmount: input.giftAmount, drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length), }).catch((err) => console.error("Failed to send update email:", err)); return { success: true }; }), cancelRegistration: publicProcedure .input(z.object({ token: z.string().uuid() })) .handler(async ({ input }) => { const row = await getActiveRegistration(input.token); await db .update(registration) .set({ cancelledAt: new Date() }) .where(eq(registration.managementToken, input.token)); await sendCancellationEmail({ to: row.email, firstName: row.firstName, }).catch((err) => console.error("Failed to send cancellation email:", err), ); return { success: true }; }), getRegistrations: adminProcedure .input(getRegistrationsSchema) .handler(async ({ input }) => { const conditions = []; if (input.search) { const term = `%${input.search}%`; conditions.push( or( like(registration.firstName, term), like(registration.lastName, term), like(registration.email, term), ), ); } if (input.registrationType) conditions.push( eq(registration.registrationType, input.registrationType), ); if (input.artForm) conditions.push(eq(registration.artForm, input.artForm)); if (input.fromDate) conditions.push(gte(registration.createdAt, new Date(input.fromDate))); if (input.toDate) conditions.push(lte(registration.createdAt, new Date(input.toDate))); const where = conditions.length > 0 ? and(...conditions) : undefined; const [data, countResult] = await Promise.all([ db .select() .from(registration) .where(where) .orderBy(desc(registration.createdAt)) .limit(input.pageSize) .offset((input.page - 1) * input.pageSize), db.select({ count: count() }).from(registration).where(where), ]); const total = countResult[0]?.count ?? 0; return { data, pagination: { page: input.page, pageSize: input.pageSize, total, totalPages: Math.ceil(total / input.pageSize), }, }; }), getRegistrationStats: adminProcedure.handler(async () => { const today = new Date(); today.setHours(0, 0, 0, 0); const [ totalResult, todayResult, artFormResult, typeResult, giftResult, watcherGuestRows, ] = await Promise.all([ db.select({ count: count() }).from(registration), db .select({ count: count() }) .from(registration) .where(gte(registration.createdAt, today)), db .select({ artForm: registration.artForm, count: count() }) .from(registration) .where(eq(registration.registrationType, "performer")) .groupBy(registration.artForm), db .select({ registrationType: registration.registrationType, count: count(), }) .from(registration) .groupBy(registration.registrationType), db.select({ total: sum(registration.giftAmount) }).from(registration), // Fetch guests column for all watcher registrations to count total guests db .select({ guests: registration.guests }) .from(registration) .where( and( eq(registration.registrationType, "watcher"), isNull(registration.cancelledAt), ), ), ]); // Sum up all guest counts across all watcher registrations const totalGuestCount = watcherGuestRows.reduce((sum, r) => { const guests = parseGuestsJson(r.guests); return sum + guests.length; }, 0); return { total: totalResult[0]?.count ?? 0, today: todayResult[0]?.count ?? 0, byArtForm: artFormResult.map((r) => ({ artForm: r.artForm, count: r.count, })), byType: typeResult.map((r) => ({ registrationType: r.registrationType, count: r.count, })), totalGiftRevenue: giftResult[0]?.total ?? 0, totalGuestCount, }; }), exportRegistrations: adminProcedure.handler(async () => { const data = await db .select() .from(registration) .orderBy(desc(registration.createdAt)); const escapeCell = (val: string) => `"${val.replace(/"/g, '""')}"`; // Main registration headers const headers = [ "ID", "First Name", "Last Name", "Email", "Phone", "Type", "Art Form", "Experience", "Is Over 16", "Drink Card Value (EUR)", "Gift Amount (cents)", "Guest Count", "Guest 1 First Name", "Guest 1 Last Name", "Guest 1 Email", "Guest 1 Phone", "Guest 2 First Name", "Guest 2 Last Name", "Guest 2 Email", "Guest 2 Phone", "Guest 3 First Name", "Guest 3 Last Name", "Guest 3 Email", "Guest 3 Phone", "Guest 4 First Name", "Guest 4 Last Name", "Guest 4 Email", "Guest 4 Phone", "Guest 5 First Name", "Guest 5 Last Name", "Guest 5 Email", "Guest 5 Phone", "Guest 6 First Name", "Guest 6 Last Name", "Guest 6 Email", "Guest 6 Phone", "Guest 7 First Name", "Guest 7 Last Name", "Guest 7 Email", "Guest 7 Phone", "Guest 8 First Name", "Guest 8 Last Name", "Guest 8 Email", "Guest 8 Phone", "Guest 9 First Name", "Guest 9 Last Name", "Guest 9 Email", "Guest 9 Phone", "Payment Status", "Paid At", "Extra Questions", "Cancelled At", "Created At", ]; const MAX_GUESTS = 9; const rows = data.map((r) => { const guests = parseGuestsJson(r.guests); // Build guest columns (up to 9 guests, 4 fields each) const guestCols: string[] = []; for (let i = 0; i < MAX_GUESTS; i++) { const g = guests[i]; guestCols.push(g?.firstName ?? ""); guestCols.push(g?.lastName ?? ""); guestCols.push(g?.email ?? ""); guestCols.push(g?.phone ?? ""); } return [ r.id, r.firstName, r.lastName, r.email, r.phone || "", r.registrationType ?? "", r.artForm || "", r.experience || "", r.isOver16 ? "Yes" : "No", String(r.drinkCardValue ?? 0), String(r.giftAmount ?? 0), String(guests.length), ...guestCols, r.paymentStatus === "paid" ? "Paid" : r.paymentStatus === "extra_payment_pending" ? "Extra Payment Pending" : "Pending", r.paidAt ? r.paidAt.toISOString() : "", r.extraQuestions || "", r.cancelledAt ? r.cancelledAt.toISOString() : "", r.createdAt.toISOString(), ]; }); const csvContent = [ headers.map(escapeCell).join(","), ...rows.map((row) => row.map((cell) => escapeCell(String(cell))).join(","), ), ].join("\n"); return { csv: csvContent, filename: `registrations-${new Date().toISOString().split("T")[0]}.csv`, }; }), // --------------------------------------------------------------------------- // User account // --------------------------------------------------------------------------- claimRegistrationCredit: protectedProcedure.handler(async ({ context }) => { const result = await creditRegistrationToAccount( context.session.user.email, context.session.user.id, ); return result; }), getMyRegistration: protectedProcedure.handler(async ({ context }) => { const email = context.session.user.email; const rows = await db .select() .from(registration) .where( and(eq(registration.email, email), isNull(registration.cancelledAt)), ) .orderBy(desc(registration.createdAt)) .limit(1); const row = rows[0]; if (!row) return null; return { ...row, guests: parseGuestsJson(row.guests), }; }), // --------------------------------------------------------------------------- // Admin access requests // --------------------------------------------------------------------------- requestAdminAccess: protectedProcedure.handler(async ({ context }) => { const userId = context.session.user.id; if (context.session.user.role === "admin") { return { success: false, message: "Je bent al een admin" }; } const existing = await db .select() .from(adminRequest) .where(eq(adminRequest.userId, userId)) .limit(1) .then((rows) => rows[0]); if (existing) { if (existing.status === "pending") return { success: false, message: "Je hebt al een aanvraag openstaan", }; if (existing.status === "approved") return { success: false, message: "Je aanvraag is al goedgekeurd, log opnieuw in", }; // Rejected — allow re-request await db .update(adminRequest) .set({ status: "pending", requestedAt: new Date(), reviewedAt: null, reviewedBy: null, }) .where(eq(adminRequest.userId, userId)); return { success: true, message: "Nieuwe aanvraag ingediend" }; } await db.insert(adminRequest).values({ id: randomUUID(), userId, status: "pending", }); return { success: true, message: "Admin toegang aangevraagd" }; }), getAdminRequests: adminProcedure.handler(async () => { return db .select({ id: adminRequest.id, userId: adminRequest.userId, status: adminRequest.status, requestedAt: adminRequest.requestedAt, reviewedAt: adminRequest.reviewedAt, reviewedBy: adminRequest.reviewedBy, userName: user.name, userEmail: user.email, }) .from(adminRequest) .leftJoin(user, eq(adminRequest.userId, user.id)) .orderBy(desc(adminRequest.requestedAt)); }), approveAdminRequest: adminProcedure .input(z.object({ requestId: z.string() })) .handler(async ({ input, context }) => { const req = await db .select() .from(adminRequest) .where(eq(adminRequest.id, input.requestId)) .limit(1) .then((rows) => rows[0]); if (!req) throw new Error("Aanvraag niet gevonden"); if (req.status !== "pending") throw new Error("Deze aanvraag is al behandeld"); await db .update(adminRequest) .set({ status: "approved", reviewedAt: new Date(), reviewedBy: context.session.user.id, }) .where(eq(adminRequest.id, input.requestId)); await db .update(user) .set({ role: "admin" }) .where(eq(user.id, req.userId)); return { success: true, message: "Admin toegang goedgekeurd" }; }), rejectAdminRequest: adminProcedure .input(z.object({ requestId: z.string() })) .handler(async ({ input, context }) => { const req = await db .select() .from(adminRequest) .where(eq(adminRequest.id, input.requestId)) .limit(1) .then((rows) => rows[0]); if (!req) throw new Error("Aanvraag niet gevonden"); if (req.status !== "pending") throw new Error("Deze aanvraag is al behandeld"); await db .update(adminRequest) .set({ status: "rejected", reviewedAt: new Date(), reviewedBy: context.session.user.id, }) .where(eq(adminRequest.id, input.requestId)); 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 // --------------------------------------------------------------------------- getCheckoutUrl: publicProcedure .input( z.object({ token: z.string().uuid(), redirectUrl: z.string().optional(), }), ) .handler(async ({ input }) => { if (!env.MOLLIE_API_KEY) { throw new Error("Mollie is niet geconfigureerd"); } const rows = await db .select() .from(registration) .where( and( eq(registration.managementToken, input.token), isNull(registration.cancelledAt), ), ) .limit(1); const row = rows[0]; if (!row) throw new Error("Inschrijving niet gevonden"); if (row.paymentStatus === "paid") throw new Error("Betaling is al voltooid"); if ( row.paymentStatus !== "pending" && row.paymentStatus !== "extra_payment_pending" ) throw new Error("Onverwachte betalingsstatus"); const isPerformer = row.registrationType === "performer"; const guests = parseGuestsJson(row.guests); const giftTotal = row.giftAmount ?? 0; // Performers only pay if they have a gift; watchers pay drink card + gift if (isPerformer && giftTotal === 0) { throw new Error("Artiesten hoeven niet te betalen"); } // For an extra_payment_pending registration, charge only the delta // (stored in paymentAmount in cents). For a fresh pending registration // charge the full drink card price. Always add any gift amount. const isExtraPayment = row.paymentStatus === "extra_payment_pending"; const drinkCardTotal = isExtraPayment ? (row.paymentAmount ?? 0) : isPerformer ? 0 : drinkCardCents(guests.length); const amountInCents = drinkCardTotal + giftTotal; const productDescription = isExtraPayment ? `Extra bijdrage${giftTotal > 0 ? " + gift" : ""}` : isPerformer ? `Vrijwillige gift${drinkCardTotal > 0 ? " + drinkkaart" : ""}` : `Toegangskaart${giftTotal > 0 ? " + gift" : ""}`; const redirectUrl = input.redirectUrl ?? `${env.CORS_ORIGIN}/manage/${input.token}`; // Mollie amounts must be formatted as "10.00" (string, 2 decimal places) const amountValue = (amountInCents / 100).toFixed(2); const webhookUrl = `${env.CORS_ORIGIN}/api/webhook/mollie`; const response = await fetch("https://api.mollie.com/v2/payments", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${env.MOLLIE_API_KEY}`, }, body: JSON.stringify({ amount: { value: amountValue, currency: "EUR" }, description: `Kunstenkamp Evenement — ${productDescription}`, redirectUrl, webhookUrl, locale: "nl_NL", metadata: { registration_token: input.token }, }), }); if (!response.ok) { const errorData = await response.json(); console.error("Mollie checkout error:", errorData); throw new Error("Kon checkout niet aanmaken"); } const checkoutData = (await response.json()) as { _links?: { checkout?: { href?: string } }; }; const checkoutUrl = checkoutData._links?.checkout?.href; if (!checkoutUrl) throw new Error("Geen checkout URL ontvangen"); return { checkoutUrl }; }), }; 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); } } // Payment reminders: watchers with pending payment older than 3 days const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000; const unpaidWatchers = await db .select() .from(registration) .where( and( eq(registration.registrationType, "watcher"), eq(registration.paymentStatus, "pending"), isNull(registration.paymentReminderSentAt), lte(registration.createdAt, new Date(threeDaysAgo)), ), ); for (const reg of unpaidWatchers) { if (!reg.managementToken) continue; try { await sendPaymentReminderEmail({ to: reg.email, firstName: reg.firstName, managementToken: reg.managementToken, drinkCardValue: reg.drinkCardValue ?? undefined, giftAmount: reg.giftAmount ?? undefined, }); await db .update(registration) .set({ paymentReminderSentAt: new Date() }) .where(eq(registration.id, reg.id)); sent++; } catch (err) { errors.push(`payment-reminder ${reg.email}: ${String(err)}`); console.error(`Failed to send payment reminder to ${reg.email}:`, err); } } return { sent, skipped: false, errors }; }