- Add contact, privacy, and terms pages - Add CookieConsent component with accept/decline and localStorage - Add self-hosted DM Sans font with @font-face definitions - Improve registration form with field validation, blur handlers, and performer toggle - Redesign Info section with 'Ongedesemd Brood' hero and FAQ layout - Remove scroll-snap behavior from all sections - Add reduced motion support and selection color theming - Add SVG favicon and SEO meta tags in root layout - Improve accessibility: aria attributes, semantic HTML, focus styles - Add link-hover underline animation utility
326 lines
8.1 KiB
TypeScript
326 lines
8.1 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 type { RouterClient } from "@orpc/server";
|
|
import { and, count, desc, eq, gte, like, lte } from "drizzle-orm";
|
|
import { z } from "zod";
|
|
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
|
|
|
|
const submitRegistrationSchema = z.object({
|
|
firstName: z.string().min(1),
|
|
lastName: z.string().min(1),
|
|
email: z.string().email(),
|
|
phone: z.string().optional(),
|
|
wantsToPerform: z.boolean().default(false),
|
|
artForm: z.string().optional(),
|
|
experience: z.string().optional(),
|
|
extraQuestions: z.string().optional(),
|
|
});
|
|
|
|
const getRegistrationsSchema = z.object({
|
|
search: z.string().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),
|
|
});
|
|
|
|
export const appRouter = {
|
|
healthCheck: publicProcedure.handler(() => {
|
|
return "OK";
|
|
}),
|
|
|
|
privateData: protectedProcedure.handler(({ context }) => {
|
|
return {
|
|
message: "This is private",
|
|
user: context.session?.user,
|
|
};
|
|
}),
|
|
|
|
submitRegistration: publicProcedure
|
|
.input(submitRegistrationSchema)
|
|
.handler(async ({ input }) => {
|
|
const result = await db.insert(registration).values({
|
|
id: randomUUID(),
|
|
firstName: input.firstName,
|
|
lastName: input.lastName,
|
|
email: input.email,
|
|
phone: input.phone || null,
|
|
wantsToPerform: input.wantsToPerform,
|
|
artForm: input.artForm || null,
|
|
experience: input.experience || null,
|
|
extraQuestions: input.extraQuestions || null,
|
|
});
|
|
|
|
return { success: true, id: result.lastInsertRowid };
|
|
}),
|
|
|
|
getRegistrations: adminProcedure
|
|
.input(getRegistrationsSchema)
|
|
.handler(async ({ input }) => {
|
|
const conditions = [];
|
|
|
|
if (input.search) {
|
|
const searchTerm = `%${input.search}%`;
|
|
conditions.push(
|
|
and(
|
|
like(registration.firstName, searchTerm),
|
|
like(registration.lastName, searchTerm),
|
|
like(registration.email, searchTerm),
|
|
),
|
|
);
|
|
}
|
|
|
|
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 whereClause =
|
|
conditions.length > 0 ? and(...conditions) : undefined;
|
|
|
|
const [data, countResult] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(registration)
|
|
.where(whereClause)
|
|
.orderBy(desc(registration.createdAt))
|
|
.limit(input.pageSize)
|
|
.offset((input.page - 1) * input.pageSize),
|
|
db.select({ count: count() }).from(registration).where(whereClause),
|
|
]);
|
|
|
|
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] = 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)
|
|
.groupBy(registration.artForm),
|
|
]);
|
|
|
|
return {
|
|
total: totalResult[0]?.count ?? 0,
|
|
today: todayResult[0]?.count ?? 0,
|
|
byArtForm: artFormResult.map((r) => ({
|
|
artForm: r.artForm,
|
|
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",
|
|
"Wants To Perform",
|
|
"Art Form",
|
|
"Experience",
|
|
"Extra Questions",
|
|
"Created At",
|
|
];
|
|
const rows = data.map((r) => [
|
|
r.id,
|
|
r.firstName,
|
|
r.lastName,
|
|
r.email,
|
|
r.phone || "",
|
|
r.wantsToPerform ? "Yes" : "No",
|
|
r.artForm || "",
|
|
r.experience || "",
|
|
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 Request Procedures
|
|
requestAdminAccess: protectedProcedure.handler(async ({ context }) => {
|
|
const userId = context.session.user.id;
|
|
|
|
// Check if user is already an admin
|
|
if (context.session.user.role === "admin") {
|
|
return { success: false, message: "Je bent al een admin" };
|
|
}
|
|
|
|
// Check if request already exists
|
|
const existingRequest = await db
|
|
.select()
|
|
.from(adminRequest)
|
|
.where(eq(adminRequest.userId, userId))
|
|
.limit(1);
|
|
|
|
if (existingRequest.length > 0) {
|
|
if (existingRequest[0].status === "pending") {
|
|
return { success: false, message: "Je hebt al een aanvraag openstaan" };
|
|
}
|
|
if (existingRequest[0].status === "approved") {
|
|
return {
|
|
success: false,
|
|
message: "Je aanvraag is al goedgekeurd, log opnieuw in",
|
|
};
|
|
}
|
|
if (existingRequest[0].status === "rejected") {
|
|
// Allow re-requesting if previously rejected
|
|
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" };
|
|
}
|
|
}
|
|
|
|
// Create new request
|
|
await db.insert(adminRequest).values({
|
|
id: randomUUID(),
|
|
userId,
|
|
status: "pending",
|
|
});
|
|
|
|
return { success: true, message: "Admin toegang aangevraagd" };
|
|
}),
|
|
|
|
getAdminRequests: adminProcedure.handler(async () => {
|
|
const requests = await 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));
|
|
|
|
return requests;
|
|
}),
|
|
|
|
approveAdminRequest: adminProcedure
|
|
.input(z.object({ requestId: z.string() }))
|
|
.handler(async ({ input, context }) => {
|
|
const request = await db
|
|
.select()
|
|
.from(adminRequest)
|
|
.where(eq(adminRequest.id, input.requestId))
|
|
.limit(1);
|
|
|
|
if (request.length === 0) {
|
|
throw new Error("Aanvraag niet gevonden");
|
|
}
|
|
|
|
if (request[0].status !== "pending") {
|
|
throw new Error("Deze aanvraag is al behandeld");
|
|
}
|
|
|
|
// Update request status
|
|
await db
|
|
.update(adminRequest)
|
|
.set({
|
|
status: "approved",
|
|
reviewedAt: new Date(),
|
|
reviewedBy: context.session.user.id,
|
|
})
|
|
.where(eq(adminRequest.id, input.requestId));
|
|
|
|
// Update user role to admin
|
|
await db
|
|
.update(user)
|
|
.set({ role: "admin" })
|
|
.where(eq(user.id, request[0].userId));
|
|
|
|
return { success: true, message: "Admin toegang goedgekeurd" };
|
|
}),
|
|
|
|
rejectAdminRequest: adminProcedure
|
|
.input(z.object({ requestId: z.string() }))
|
|
.handler(async ({ input, context }) => {
|
|
const request = await db
|
|
.select()
|
|
.from(adminRequest)
|
|
.where(eq(adminRequest.id, input.requestId))
|
|
.limit(1);
|
|
|
|
if (request.length === 0) {
|
|
throw new Error("Aanvraag niet gevonden");
|
|
}
|
|
|
|
if (request[0].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" };
|
|
}),
|
|
};
|
|
|
|
export type AppRouter = typeof appRouter;
|
|
export type AppRouterClient = RouterClient<typeof appRouter>;
|