Files
kunstenkamp/packages/api/src/routers/index.ts

642 lines
18 KiB
TypeScript

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<typeof appRouter>;