642 lines
18 KiB
TypeScript
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>;
|