feat:admin
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"@orpc/server": "catalog:",
|
||||
"@orpc/zod": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.
@@ -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",
|
||||
|
||||
23
packages/db/src/schema/admin-requests.ts
Normal file
23
packages/db/src/schema/admin-requests.ts
Normal 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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./admin-requests";
|
||||
export * from "./auth";
|
||||
export * from "./registrations";
|
||||
|
||||
23
packages/db/src/schema/registrations.ts
Normal file
23
packages/db/src/schema/registrations.ts
Normal 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),
|
||||
],
|
||||
);
|
||||
Reference in New Issue
Block a user