import { randomUUID } from "node:crypto"; import { db } from "@kk/db"; import { adminRequest, registration } from "@kk/db/schema"; import { user } from "@kk/db/schema/auth"; import { env } from "@kk/env/server"; import type { RouterClient } from "@orpc/server"; import { and, count, desc, eq, gte, isNull, like, lte } from "drizzle-orm"; import { z } from "zod"; import { sendCancellationEmail, sendConfirmationEmail, sendUpdateEmail, } from "../email"; import { adminProcedure, protectedProcedure, publicProcedure } from "../index"; // --------------------------------------------------------------------------- // 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 []; } } /** 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(), }; 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"), 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, managementToken, }); await sendConfirmationEmail({ to: input.email, firstName: input.firstName, managementToken, wantsToPerform: isPerformer, artForm: input.artForm, }).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 ?? []); // 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, 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, }).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] = 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), ]); 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, })), }; }), 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", "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(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`, }; }), // --------------------------------------------------------------------------- // 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" }; }), // --------------------------------------------------------------------------- // Checkout // --------------------------------------------------------------------------- getCheckoutUrl: publicProcedure .input(z.object({ token: z.string().uuid() })) .handler(async ({ input }) => { if ( !env.LEMON_SQUEEZY_API_KEY || !env.LEMON_SQUEEZY_STORE_ID || !env.LEMON_SQUEEZY_VARIANT_ID ) { throw new Error("Lemon Squeezy 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"); if (row.registrationType === "performer") throw new Error("Artiesten hoeven niet te betalen"); const guests = parseGuestsJson(row.guests); // 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. const isExtraPayment = row.paymentStatus === "extra_payment_pending"; const amountInCents = isExtraPayment ? (row.paymentAmount ?? 0) : drinkCardCents(guests.length); const productDescription = isExtraPayment ? `Extra bijdrage voor ${row.paymentAmount != null ? row.paymentAmount / 200 : 0} extra gast(en)` : `Toegangskaart voor ${1 + guests.length} perso(o)n(en)`; const response = await fetch( "https://api.lemonsqueezy.com/v1/checkouts", { method: "POST", headers: { Accept: "application/vnd.api+json", "Content-Type": "application/vnd.api+json", Authorization: `Bearer ${env.LEMON_SQUEEZY_API_KEY}`, }, body: JSON.stringify({ data: { type: "checkouts", attributes: { custom_price: amountInCents, product_options: { name: "Kunstenkamp Evenement", description: productDescription, redirect_url: `${env.CORS_ORIGIN}/manage/${input.token}`, }, checkout_data: { email: row.email, name: `${row.firstName} ${row.lastName}`, custom: { registration_token: input.token }, }, checkout_options: { embed: false, locale: "nl", }, }, relationships: { store: { data: { type: "stores", id: env.LEMON_SQUEEZY_STORE_ID }, }, variant: { data: { type: "variants", id: env.LEMON_SQUEEZY_VARIANT_ID }, }, }, }, }), }, ); if (!response.ok) { const errorData = await response.json(); console.error("Lemon Squeezy checkout error:", errorData); throw new Error("Kon checkout niet aanmaken"); } const checkoutData = (await response.json()) as { data?: { attributes?: { url?: string } }; }; const checkoutUrl = checkoutData.data?.attributes?.url; if (!checkoutUrl) throw new Error("Geen checkout URL ontvangen"); return { checkoutUrl }; }), }; export type AppRouter = typeof appRouter; export type AppRouterClient = RouterClient;