- 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
1016 lines
29 KiB
TypeScript
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 };
|
|
}
|