Files
kunstenkamp/packages/api/src/routers/index.ts
zias 17c6315dad Simplify registration flow: mandatory signup redirect, payment emails, payment reminder cron
- EventRegistrationForm/WatcherForm/PerformerForm: remove session/login nudge/SuccessScreen;
  both roles redirect to /login?signup=1&email=<email>&next=/account after submit
- SuccessScreen.tsx deleted (no longer used)
- account.tsx: add 'Betaal nu' CTA via checkoutMutation (shown when watcher + paymentStatus pending)
- DB schema: add paymentReminderSentAt column to registration table
- Migration: 0008_payment_reminder_sent_at.sql
- email.ts: update registrationConfirmationHtml / sendConfirmationEmail with signupUrl param;
  add sendPaymentReminderEmail and sendPaymentConfirmationEmail functions
- routers/index.ts: wire payment reminder into runSendReminders() — queries watchers
  with paymentStatus pending, paymentReminderSentAt IS NULL, createdAt <= now-3days
- mollie.ts webhook: call sendPaymentConfirmationEmail after marking registration paid
2026-03-11 11:17:24 +01:00

1016 lines
29 KiB
TypeScript

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, 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(
and(
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] =
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),
]);
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,
};
}),
exportRegistrations: adminProcedure.handler(async () => {
const data = await db
.select()
.from(registration)
.orderBy(desc(registration.createdAt));
const headers = [
"ID",
"First Name",
"Last Name",
"Email",
"Phone",
"Type",
"Art Form",
"Experience",
"Is Over 16",
"Drink Card Value",
"Gift Amount",
"Guest Count",
"Guests",
"Payment Status",
"Paid At",
"Extra Questions",
"Created At",
];
const rows = data.map((r) => {
const guests = parseGuestsJson(r.guests);
const guestSummary = guests
.map(
(g) =>
`${g.firstName} ${g.lastName}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`,
)
.join(" | ");
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),
guestSummary,
r.paymentStatus === "paid" ? "Paid" : "Pending",
r.paidAt ? r.paidAt.toISOString() : "",
r.extraQuestions || "",
r.createdAt.toISOString(),
];
});
const csvContent = [
headers.join(","),
...rows.map((row) =>
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).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<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);
}
}
// 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 };
}