diff --git a/.env.example b/.env.example index 66b13be..febb954 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,6 @@ AUTH_GITHUB_SECRET="" # Prisma # https://www.prisma.io/docs/reference/database-reference/connection-urls#env DATABASE_URL="file:./db.sqlite" + +# Admin IDs +ADMIN_FER="" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c3a1d4a..2687744 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,13 @@ "@auth/prisma-adapter": "^2.7.2", "@hookform/resolvers": "^5.0.1", "@prisma/client": "^6.5.0", + "@radix-ui/react-alert-dialog": "^1.1.13", "@radix-ui/react-avatar": "^1.1.9", "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-navigation-menu": "^1.2.11", "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-tabs": "^1.1.11", "@t3-oss/env-nextjs": "^0.12.0", "@tanstack/react-query": "^5.69.0", "@trpc/client": "^11.0.0", @@ -2111,6 +2113,57 @@ "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.13.tgz", + "integrity": "sha512-/uPs78OwxGxslYOG5TKeUsv9fZC0vo376cXSADdKirTmsLJU2au6L3n34c3p6W26rFDDDze/hwy4fYeNd0qdGA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.13", + "@radix-ui/react-primitive": "2.1.2", + "@radix-ui/react-slot": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.2.tgz", + "integrity": "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.6.tgz", @@ -2281,6 +2334,92 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.13.tgz", + "integrity": "sha512-ARFmqUyhIVS3+riWzwGTe7JLjqwqgnODBUZdqpWar/z1WFs9z76fuOs/2BOWCR+YboRn4/WN9aoaGVwqNRr8VA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.9", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.6", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.8", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.2", + "@radix-ui/react-slot": "1.2.2", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.9.tgz", + "integrity": "sha512-way197PiTvNp+WBP7svMJasHl+vibhWGQDb6Mgf5mhEWJkgb85z7Lfl9TUdkqpWsf8GRNmoopx9ZxCyDzmgRMQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.2", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.2.tgz", + "integrity": "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -2919,6 +3058,59 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.11.tgz", + "integrity": "sha512-4FiKSVoXqPP/KfzlB7lwwqoFV6EPwkrrqGp9cUYXjwDYHhvpnqq79P+EPHKcdoTE7Rl8w/+6s9rTlsfXHES9GA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.2", + "@radix-ui/react-roving-focus": "1.1.9", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.2.tgz", + "integrity": "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", diff --git a/package.json b/package.json index f2b2fcf..0a1d527 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,13 @@ "@auth/prisma-adapter": "^2.7.2", "@hookform/resolvers": "^5.0.1", "@prisma/client": "^6.5.0", + "@radix-ui/react-alert-dialog": "^1.1.13", "@radix-ui/react-avatar": "^1.1.9", "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-navigation-menu": "^1.2.11", "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-tabs": "^1.1.11", "@t3-oss/env-nextjs": "^0.12.0", "@tanstack/react-query": "^5.69.0", "@trpc/client": "^11.0.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 062cbcf..94c4d6c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -93,7 +93,7 @@ model Course { name String @unique // Calculus 2 code String @unique // MAC2312 url String? - count Int @default(0) + count Int @default(1) pending Boolean @default(true) notes Note[] } \ No newline at end of file diff --git a/src/app/admin/ApprovedCourseTable.tsx b/src/app/admin/ApprovedCourseTable.tsx new file mode 100644 index 0000000..cacab3c --- /dev/null +++ b/src/app/admin/ApprovedCourseTable.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { api } from "~/trpc/react"; + +const ITEMS_PER_PAGE = 10; + +export default function ApprovedCourseTable() { + const [approvedPage, setApprovedPage] = useState(1); + + const {data: courses, isLoading, error } = api.admin.getAllApprovedCourses.useQuery(); + + const handleEdit = (id: string) => { + console.log("Edit:", id); + }; + + const paginate = (data: T[], page: number) => + data.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE); + + const renderPagination = ( + totalItems: number, + currentPage: number, + setPage: (n: number) => void, + ) => { + const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE); + if (totalPages <= 1) return null; + + return ( +
+ {Array.from({ length: totalPages }, (_, i) => ( + + ))} +
+ ); + }; + + return ( + <> +
+ + + + + + + + + + {isLoading ? ( + + + + ) : error ? ( + + + + ) : courses && courses.length > 0 ? ( + paginate(courses, approvedPage).map((course) => ( + + + + + + )) + ) : ( + + + + )} + +
NameCodeActions
+ Loading... +
+ Error loading courses +
{course.name} + {course.code.replace(/^([A-Z]{3})(\d{4})$/, "$1 $2")} + + +
+ No Approved Courses +
+
+ {courses && + renderPagination(courses.length, approvedPage, setApprovedPage)} + + ); +} \ No newline at end of file diff --git a/src/app/admin/PendingCourseTable.tsx b/src/app/admin/PendingCourseTable.tsx new file mode 100644 index 0000000..35de419 --- /dev/null +++ b/src/app/admin/PendingCourseTable.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useState } from "react"; +import { api } from "~/trpc/react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "~/components/ui/alert-dialog" +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; +import { Button } from "~/components/ui/button"; + + +const ITEMS_PER_PAGE = 10; + +export default function PendingCourseTable() { + const [requestedPage, setRequestedPage] = useState(1); + const {data: courses, isLoading, error } = api.admin.getAllPendingCourses.useQuery(); + + const denyCourse = api.admin.denyCourse.useMutation({ + onSuccess: (result) => { + toast.success(`${result.code.replace(/^([A-Z]{3})(\d{4})$/, "$1 $2")} successfully denied`); + }, + onError: (error) => { + toast.error("Something went wrong"); + console.error(error.message, error.data); + }, + }); + + const handleApprove = (id: string) => { + console.log("Approved:", id); + }; + + const handleDeny = (id: string) => { + denyCourse.mutate({courseId: id}); + }; + + const paginate = (data: T[], page: number) => + data.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE); + + const renderPagination = ( + totalItems: number, + currentPage: number, + setPage: (n: number) => void, + ) => { + const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE); + if (totalPages <= 1) return null; + + return ( +
+ {Array.from({ length: totalPages }, (_, i) => ( + + ))} +
+ ); + }; + + return ( + <> +
+ + + + + + + + + + + {isLoading ? ( + + + + ) : error ? ( + + + + ) : courses && courses.length > 0 ? ( + paginate(courses, requestedPage).map((course) => ( + + + + + + + )) + ) : ( + + + + )} + +
NameCodeRequestsActions
+ Loading... +
+ Error loading courses +
{course.name} + {course.code.replace(/^([A-Z]{3})(\d{4})$/, "$1 $2")} + {course.count} + + + + + + + + + {`Are you sure you wan't to deny ${course.code.replace(/^([A-Z]{3})(\d{4})$/, "$1 $2")}`} + + + This action cannot be undone. This will permanently + remove this course from pending requests. + + + + + Cancel + + {denyCourse.isPending ? ( + + ) : ( + handleDeny(course.id)} + className="cursor-pointer" + > + Continue + + )} + + + +
+ No pending courses +
+
+ {courses && + renderPagination(courses.length, requestedPage, setRequestedPage)} + + ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index f9640b4..bf35e58 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,10 +1,27 @@ +import ApprovedCourseTable from "./ApprovedCourseTable"; +import PendingCourseTable from "./PendingCourseTable"; -export default function Admin() { - - return ( -
- Admin Page -
- ) +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs" +export default function AdminDashboard() { + + return ( +
+

Admin Dashboard

+ + + + Pending Courses + ApprovedCourses + + + + + + + + + +
+ ); } \ No newline at end of file diff --git a/src/components/ui/CourseCard.tsx b/src/components/ui/CourseCard.tsx index 153404d..af04a74 100644 --- a/src/components/ui/CourseCard.tsx +++ b/src/components/ui/CourseCard.tsx @@ -9,7 +9,7 @@ type CourseCardProps = { export default function CourseCard({ name, code, imageUrl }: CourseCardProps) { return (
- {name} + {name}

{name}

{code}

diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..bfd8243 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "~/lib/utils" +import { buttonVariants } from "~/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..49837b4 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "~/lib/utils" + +function Tabs({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/src/consts/admins.ts b/src/consts/admins.ts new file mode 100644 index 0000000..418ea47 --- /dev/null +++ b/src/consts/admins.ts @@ -0,0 +1 @@ +export const adminIds = new Set([process.env.ADMIN_FER]) \ No newline at end of file diff --git a/src/env.js b/src/env.js index aaae00d..38ace2b 100644 --- a/src/env.js +++ b/src/env.js @@ -16,6 +16,7 @@ export const env = createEnv({ DATABASE_URL: z.string().url(), AUTH_GITHUB_ID: z.string(), AUTH_GITHUB_SECRET: z.string(), + ADMIN_FER: z.string(), NODE_ENV: z .enum(["development", "test", "production"]) .default("development"), @@ -42,6 +43,7 @@ export const env = createEnv({ NODE_ENV: process.env.NODE_ENV, AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID, AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET, + ADMIN_FER: process.env.ADMIN_FER, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/src/hooks/use-mobile.ts b/src/hooks/use-mobile.ts new file mode 100644 index 0000000..2b0fe1d --- /dev/null +++ b/src/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index e358a17..e8dd98d 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,6 +1,7 @@ import { postRouter } from "~/server/api/routers/post"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; import { courseRouter } from "./routers/course"; +import { adminRouter } from "./routers/admin"; /** * This is the primary router for your server. @@ -10,6 +11,7 @@ import { courseRouter } from "./routers/course"; export const appRouter = createTRPCRouter({ post: postRouter, course: courseRouter, + admin: adminRouter, }); // export type definition of API diff --git a/src/server/api/routers/admin.ts b/src/server/api/routers/admin.ts new file mode 100644 index 0000000..0dea918 --- /dev/null +++ b/src/server/api/routers/admin.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +import { createTRPCRouter, adminProcedure } from "~/server/api/trpc"; + +export const adminRouter = createTRPCRouter({ + getAllApprovedCourses: adminProcedure.query(async ({ ctx }) => { + const courses = await ctx.db.course.findMany({ + where: { + pending: false, + }, + }); + return courses; + }), + + getAllPendingCourses: adminProcedure.query(async ({ ctx }) => { + const courses = await ctx.db.course.findMany({ + where: { + pending: true, + }, + }); + return courses; + }), + + denyCourse: adminProcedure + .input(z.object({courseId: z.string()})) + .mutation(async ({ctx, input}) => { + const deniedCourse = await ctx.db.course.delete({ + where: {id: input.courseId}, + }) + return deniedCourse; + }) +}); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 923751a..ada7bab 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -14,6 +14,8 @@ import { ZodError } from "zod"; import { auth } from "~/server/auth"; import { db } from "~/server/db"; +import { adminIds } from "~/consts/admins"; + /** * 1. CONTEXT * @@ -131,3 +133,12 @@ export const protectedProcedure = t.procedure }, }); }); + +export const adminProcedure = protectedProcedure +.use(timingMiddleware) +.use(({ctx, next}) => { + if(!adminIds.has(ctx.session.user.id)) { + throw new TRPCError({code: "FORBIDDEN"}); + } + return next(); +}) \ No newline at end of file