feat:admin

This commit is contained in:
2026-03-02 16:42:15 +01:00
parent 52563d80de
commit b343314931
19 changed files with 1221 additions and 8 deletions

View File

@@ -19,6 +19,7 @@
"@orpc/server": "catalog:",
"@orpc/zod": "catalog:",
"dotenv": "catalog:",
"drizzle-orm": "^0.45.1",
"zod": "catalog:"
},
"devDependencies": {

View File

@@ -18,3 +18,19 @@ const requireAuth = o.middleware(async ({ context, next }) => {
});
export const protectedProcedure = publicProcedure.use(requireAuth);
const requireAdmin = o.middleware(async ({ context, next }) => {
if (!context.session?.user) {
throw new ORPCError("UNAUTHORIZED");
}
if (context.session.user.role !== "admin") {
throw new ORPCError("FORBIDDEN");
}
return next({
context: {
session: context.session,
},
});
});
export const adminProcedure = protectedProcedure.use(requireAdmin);

View File

@@ -1,17 +1,317 @@
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 { randomUUID } from "crypto";
import { and, count, desc, eq, gte, like, lte } from "drizzle-orm";
import { z } from "zod";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
import { 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(),
artForm: z.string().min(1),
experience: 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,
artForm: input.artForm,
experience: input.experience || 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",
"Art Form",
"Experience",
"Created At",
];
const rows = data.map((r) => [
r.id,
r.firstName,
r.lastName,
r.email,
r.phone || "",
r.artForm,
r.experience || "",
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>;

View File

@@ -15,5 +15,13 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
},
user: {
additionalFields: {
role: {
type: "string",
input: false,
},
},
},
plugins: [tanstackStartCookies()],
});

Binary file not shown.

Binary file not shown.

View File

@@ -5,11 +5,18 @@
".": {
"default": "./src/index.ts"
},
"./schema": {
"default": "./src/schema/index.ts"
},
"./schema/*": {
"default": "./src/schema/*.ts"
},
"./*": {
"default": "./src/*.ts"
}
},
"scripts": {
"dev": "turso dev --db-file local.db",
"db:local": "turso dev --db-file local.db",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",

View File

@@ -0,0 +1,23 @@
import { sql } from "drizzle-orm";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const adminRequest = sqliteTable(
"admin_request",
{
id: text("id").primaryKey(),
userId: text("user_id").notNull().unique(),
status: text("status").default("pending").notNull(), // pending, approved, rejected
requestedAt: integer("requested_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
reviewedAt: integer("reviewed_at", { mode: "timestamp_ms" }),
reviewedBy: text("reviewed_by"),
},
(table) => [
index("admin_request_userId_idx").on(table.userId),
index("admin_request_status_idx").on(table.status),
],
);
export type AdminRequest = typeof adminRequest.$inferSelect;
export type NewAdminRequest = typeof adminRequest.$inferInsert;

View File

@@ -9,6 +9,7 @@ export const user = sqliteTable("user", {
.default(false)
.notNull(),
image: text("image"),
role: text("role").default("user").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),

View File

@@ -1 +1,3 @@
export * from "./admin-requests";
export * from "./auth";
export * from "./registrations";

View File

@@ -0,0 +1,23 @@
import { sql } from "drizzle-orm";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const registration = sqliteTable(
"registration",
{
id: text("id").primaryKey(),
firstName: text("first_name").notNull(),
lastName: text("last_name").notNull(),
email: text("email").notNull(),
phone: text("phone"),
artForm: text("art_form").notNull(),
experience: text("experience"),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
},
(table) => [
index("registration_email_idx").on(table.email),
index("registration_artForm_idx").on(table.artForm),
index("registration_createdAt_idx").on(table.createdAt),
],
);