diff --git a/drizzle/0002_add_teacher_verification.sql b/drizzle/0002_add_teacher_verification.sql new file mode 100644 index 0000000..c4e8068 --- /dev/null +++ b/drizzle/0002_add_teacher_verification.sql @@ -0,0 +1,6 @@ +ALTER TABLE "user" + ADD COLUMN "teacher_verified" boolean NOT NULL DEFAULT false; + +UPDATE "user" +SET "teacher_verified" = true +WHERE "role" = 'teacher'; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..1d6f15a --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "dialect": "postgresql", + "entries": [] +} diff --git a/src/app/(protected)/admin/page.tsx b/src/app/(protected)/admin/page.tsx new file mode 100644 index 0000000..86f7dcf --- /dev/null +++ b/src/app/(protected)/admin/page.tsx @@ -0,0 +1,17 @@ +import { redirect } from "next/navigation"; +import { getSession } from "~/server/better-auth/server"; +import AdminPanel from "~/modules/admin/admin-panel"; + +export default async function AdminPage() { + const session = await getSession(); + + if (!session) { + redirect("/auth"); + } + + if (session.user.role !== "admin") { + redirect("/dashboard"); + } + + return ; +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 52726b5..a3c0c38 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -124,6 +124,24 @@ export default function ProfileSidebar({ className="text-slate-600" /> + {user.role.toLowerCase() === "admin" && ( + +
+ + Admin Panel +
+ + + )} {/* Sign Out */} diff --git a/src/modules/admin/admin-panel.tsx b/src/modules/admin/admin-panel.tsx new file mode 100644 index 0000000..1c93f30 --- /dev/null +++ b/src/modules/admin/admin-panel.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useState } from "react"; +import { api, type RouterOutputs } from "~/trpc/react"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; + +export default function AdminPanel() { + const [email, setEmail] = useState(""); + const [searchedEmail, setSearchedEmail] = useState(null); + + type UserByEmail = RouterOutputs["user"]["getByEmail"]; + + const userQuery = api.user.getByEmail.useQuery( + { email: searchedEmail ?? "" }, + { enabled: !!searchedEmail } + ); + const user = userQuery.data; + const isFetching = userQuery.isFetching; + + const utils = api.useUtils(); + + const setRole = api.user.setRole.useMutation({ + onSuccess: async () => { + if (searchedEmail) { + await utils.user.getByEmail.invalidate({ + email: searchedEmail, + }); + } + }, + }); + + const verifyTeacher = api.user.verifyTeacher.useMutation({ + onSuccess: async () => { + if (searchedEmail) { + await utils.user.getByEmail.invalidate({ + email: searchedEmail, + }); + } + }, + }); + + const handleSearch = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = email.trim().toLowerCase(); + if (!trimmed) return; + setSearchedEmail(trimmed); + }; + + const handleToggle = () => { + if (!user) return; + const nextRole = user.role === "admin" ? "student" : "admin"; + setRole.mutate({ id: user.id, role: nextRole }); + }; + + const handleTeacherVerification = () => { + if (!user || user.role !== "teacher") return; + verifyTeacher.mutate({ + id: user.id, + verified: !user.teacherVerified, + }); + }; + + return ( +
+ + + Admin Panel + + +
+ setEmail(e.target.value)} + className="border-slate-700 bg-slate-800" + /> + +
+ + {searchedEmail && !user && !isFetching && ( +

+ No user found for that email. +

+ )} + + {user && ( +
+
+
+

+ {user.email} +

+

+ {user.name} +

+

+ Role:{" "} + + {user.role} + +

+ {user.role === "teacher" && ( +

+ Teacher verified:{" "} + + {user.teacherVerified + ? "Yes" + : "No"} + +

+ )} +
+
+ + {user.role === "teacher" && ( + + )} +
+
+
+ )} +
+
+
+ ); +} diff --git a/src/modules/course/features/create-course-dialog.tsx b/src/modules/course/features/create-course-dialog.tsx index 50846b7..f1f3eaa 100644 --- a/src/modules/course/features/create-course-dialog.tsx +++ b/src/modules/course/features/create-course-dialog.tsx @@ -63,6 +63,13 @@ export function CreateCourseDialog() { if (user?.role !== "teacher") { return null; } + if (!user.teacherVerified) { + return ( +
+ Your teacher account is pending verification by an admin. +
+ ); + } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/src/server/api/routers/activity.ts b/src/server/api/routers/activity.ts index 971146d..794b62c 100644 --- a/src/server/api/routers/activity.ts +++ b/src/server/api/routers/activity.ts @@ -10,8 +10,10 @@ const activityEventSchema = z.enum(["scroll", "page_change", "chat", "focus"]); const MAX_IDLE_SECONDS = 90; const MAX_ACTIVE_MINUTES_PER_DAY = 60; -const STREAK_MIN_MINUTES = 10; +const STREAK_MIN_MINUTES = 1; const STREAK_BONUS_POINTS = 5; +const STREAK_BONUS_7_DAYS = 50; +const STREAK_BONUS_30_DAYS = 250; export const activityRouter = createTRPCRouter({ ping: protectedProcedure @@ -139,7 +141,6 @@ export const activityRouter = createTRPCRouter({ } const qualifiesForStreak = - previousMinutes < STREAK_MIN_MINUTES && cappedMinutes >= STREAK_MIN_MINUTES && stats.lastStudyDate !== todayDate; @@ -154,6 +155,12 @@ export const activityRouter = createTRPCRouter({ ); pointsAwarded += STREAK_BONUS_POINTS; + if (nextStreak === 7) { + pointsAwarded += STREAK_BONUS_7_DAYS; + } + if (nextStreak === 30) { + pointsAwarded += STREAK_BONUS_30_DAYS; + } await tx .update(userStats) @@ -161,7 +168,7 @@ export const activityRouter = createTRPCRouter({ currentStreak: nextStreak, longestStreak: nextLongest, lastStudyDate: todayDate, - totalPoints: sql`${userStats.totalPoints} + ${STREAK_BONUS_POINTS}`, + totalPoints: sql`${userStats.totalPoints} + ${pointsAwarded}`, updatedAt: now, }) .where(eq(userStats.userId, userId)); diff --git a/src/server/api/routers/course.ts b/src/server/api/routers/course.ts index 2c27526..83ea32a 100644 --- a/src/server/api/routers/course.ts +++ b/src/server/api/routers/course.ts @@ -54,7 +54,7 @@ export const courseRouter = createTRPCRouter({ where: eq(user.id, ctx.session.user.id), }); - if (dbUser?.role !== "teacher") { + if (dbUser?.role !== "teacher" || !dbUser.teacherVerified) { throw new TRPCError({ code: "FORBIDDEN" }); } diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index c7b7ebe..8c8f252 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -1,5 +1,6 @@ import { z } from "zod"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; +import { TRPCError } from "@trpc/server"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { user } from "~/server/db/schema"; @@ -7,6 +8,137 @@ import { user } from "~/server/db/schema"; const userRoleSchema = z.enum(["admin", "teacher", "student"]); export const userRouter = createTRPCRouter({ + // Admin-only guard + verifyTeacher: protectedProcedure + .input( + z.object({ + id: z.string(), + verified: z.boolean(), + }) + ) + .mutation(async ({ ctx, input }) => { + const admin = await ctx.db.query.user.findFirst({ + where: eq(user.id, ctx.session.user.id), + }); + + if (admin?.role !== "admin") { + throw new TRPCError({ code: "FORBIDDEN" }); + } + + const target = await ctx.db.query.user.findFirst({ + where: eq(user.id, input.id), + }); + + if (!target) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + if (target.role !== "teacher") { + throw new TRPCError({ code: "BAD_REQUEST" }); + } + + const [updated] = await ctx.db + .update(user) + .set({ + teacherVerified: input.verified, + updatedAt: new Date(), + }) + .where(eq(user.id, input.id)) + .returning(); + + return updated ?? null; + }), + + getByEmail: protectedProcedure + .input(z.object({ email: z.string().email() })) + .query(async ({ ctx, input }) => { + const admin = await ctx.db.query.user.findFirst({ + where: eq(user.id, ctx.session.user.id), + }); + + if (admin?.role !== "admin") { + throw new TRPCError({ code: "FORBIDDEN" }); + } + + const rows = await ctx.db + .select() + .from(user) + .where(eq(user.email, input.email)) + .limit(1); + + return rows[0] ?? null; + }), + + setRole: protectedProcedure + .input(z.object({ id: z.string(), role: userRoleSchema })) + .mutation(async ({ ctx, input }) => { + const admin = await ctx.db.query.user.findFirst({ + where: eq(user.id, ctx.session.user.id), + }); + + if (admin?.role !== "admin") { + throw new TRPCError({ code: "FORBIDDEN" }); + } + + if (input.id === ctx.session.user.id) { + throw new TRPCError({ code: "BAD_REQUEST" }); + } + + const target = await ctx.db.query.user.findFirst({ + where: eq(user.id, input.id), + }); + + if (!target) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + + if (target.role === "admin" && input.role !== "admin") { + const admins = await ctx.db + .select({ + count: sql`count(*)`, + }) + .from(user) + .where(eq(user.role, "admin")); + + if ((admins[0]?.count ?? 0) <= 1) { + throw new TRPCError({ code: "BAD_REQUEST" }); + } + } + + const [updated] = await ctx.db + .update(user) + .set({ + role: input.role, + updatedAt: new Date(), + }) + .where(eq(user.id, input.id)) + .returning(); + + return updated ?? null; + }), + + promoteToAdmin: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const admin = await ctx.db.query.user.findFirst({ + where: eq(user.id, ctx.session.user.id), + }); + + if (admin?.role !== "admin") { + throw new TRPCError({ code: "FORBIDDEN" }); + } + + const [updated] = await ctx.db + .update(user) + .set({ + role: "admin", + updatedAt: new Date(), + }) + .where(eq(user.id, input.id)) + .returning(); + + return updated ?? null; + }), list: protectedProcedure.query(({ ctx }) => { return ctx.db.select().from(user); }), diff --git a/src/server/better-auth/config.ts b/src/server/better-auth/config.ts index 70924c0..5085b5a 100644 --- a/src/server/better-auth/config.ts +++ b/src/server/better-auth/config.ts @@ -33,6 +33,13 @@ export const auth = betterAuth({ input: false, fieldName: "onboardingCompleted", }, + teacherVerified: { + type: "boolean", + required: true, + defaultValue: false, + input: false, + fieldName: "teacherVerified", + }, }, }, emailAndPassword: { diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index e2df32c..8f9e84c 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -27,29 +27,26 @@ export const messageRoleEnum = pgEnum("message_role", [ "tool", ]); -export const user = pgTable( - "user", - { - id: text("id").primaryKey(), - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: boolean("email_verified") - .$defaultFn(() => false) - .notNull(), - image: text("image"), - role: userRoleEnum("role").notNull().default("student"), - onboardingCompleted: boolean("onboarding_completed") - .$defaultFn(() => false) - .notNull(), - createdAt: timestamp("created_at") - .$defaultFn(() => new Date()) - .notNull(), - updatedAt: timestamp("updated_at") - .$defaultFn(() => new Date()) - .notNull(), - }, - (table) => [index("user_email_idx").on(table.email)] -); +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified") + .$defaultFn(() => false) + .notNull(), + image: text("image"), + role: userRoleEnum("role").notNull().default("student"), + teacherVerified: boolean("teacher_verified").notNull().default(false), + onboardingCompleted: boolean("onboarding_completed") + .$defaultFn(() => false) + .notNull(), + createdAt: timestamp("created_at") + .$defaultFn(() => new Date()) + .notNull(), + updatedAt: timestamp("updated_at") + .$defaultFn(() => new Date()) + .notNull(), +}); export const session = pgTable("session", { id: text("id").primaryKey(),