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
+
+
+
+
+ {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(),