From 9c267fcc6e3b5220c653bd435414dc8f1dd38ff3 Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Tue, 30 Dec 2025 11:56:18 +0530 Subject: [PATCH 01/19] feat: subjects routes --- package-lock.json | 12 ++- package.json | 3 +- src/controllers/departments.ts | 13 +++ src/controllers/subjects.ts | 19 ++++ src/lib/validation.ts | 34 +++++++ src/routes/subjects.ts | 156 ++++++++++++++++++++++++++++++++- src/validation/subjects.ts | 37 ++++++++ 7 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 src/controllers/departments.ts create mode 100644 src/controllers/subjects.ts create mode 100644 src/lib/validation.ts create mode 100644 src/validation/subjects.ts diff --git a/package-lock.json b/package-lock.json index a665b77..137cf74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", - "express": "^5.2.1" + "express": "^5.2.1", + "zod": "^4.2.1" }, "devDependencies": { "@types/cors": "^2.8.19", @@ -2756,6 +2757,15 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/zod": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 07ffdda..60f7465 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", - "express": "^5.2.1" + "express": "^5.2.1", + "zod": "^4.2.1" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/src/controllers/departments.ts b/src/controllers/departments.ts new file mode 100644 index 0000000..d8fb974 --- /dev/null +++ b/src/controllers/departments.ts @@ -0,0 +1,13 @@ +import { eq } from "drizzle-orm"; + +import { db } from "../db"; +import { departments } from "../db/schema"; + +export const getDepartmentById = async (departmentId: number) => { + const departmentRows = await db + .select() + .from(departments) + .where(eq(departments.id, departmentId)); + + return departmentRows[0]; +}; diff --git a/src/controllers/subjects.ts b/src/controllers/subjects.ts new file mode 100644 index 0000000..f5232e3 --- /dev/null +++ b/src/controllers/subjects.ts @@ -0,0 +1,19 @@ +import { eq, getTableColumns } from "drizzle-orm"; + +import { db } from "../db"; +import { departments, subjects } from "../db/schema"; + +export const getSubjectById = async (subjectId: number) => { + const subjectRows = await db + .select({ + ...getTableColumns(subjects), + department: { + ...getTableColumns(departments), + }, + }) + .from(subjects) + .leftJoin(departments, eq(subjects.departmentId, departments.id)) + .where(eq(subjects.id, subjectId)); + + return subjectRows[0]; +}; diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..f51ee74 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +type ValidationErrorPayload = { + error: string; + details: unknown; +}; + +export class RequestValidationError extends Error { + status: number; + payload: ValidationErrorPayload; + + constructor(details: unknown) { + super("Invalid request"); + this.name = "RequestValidationError"; + this.status = 400; + this.payload = { + error: "Invalid request", + details, + }; + } +} + +export const parseRequest = ( + schema: TSchema, + input: unknown +): z.infer => { + const parsed = schema.safeParse(input); + + if (!parsed.success) { + throw new RequestValidationError(parsed.error.flatten()); + } + + return parsed.data; +}; diff --git a/src/routes/subjects.ts b/src/routes/subjects.ts index 28739dc..269e6a1 100644 --- a/src/routes/subjects.ts +++ b/src/routes/subjects.ts @@ -1,8 +1,25 @@ import express from "express"; -import { eq, ilike, or, and, desc, sql, getTableColumns } from "drizzle-orm"; +import { + eq, + ilike, + or, + and, + desc, + sql, + getTableColumns, + ne, +} from "drizzle-orm"; import { db } from "../db"; import { departments, subjects } from "../db/schema"; +import { + subjectCreateSchema, + subjectIdParamSchema, + subjectUpdateSchema, +} from "../validation/subjects"; +import { getSubjectById } from "../controllers/subjects"; +import { getDepartmentById } from "../controllers/departments"; +import { parseRequest } from "../lib/validation"; const router = express.Router(); @@ -72,4 +89,141 @@ router.get("/", async (req, res) => { } }); +// Get a single subject by ID +router.get("/:id", async (req, res) => { + try { + const { id: subjectId } = parseRequest(subjectIdParamSchema, req.params); + + const subject = await getSubjectById(subjectId); + + if (!subject) { + return res.status(404).json({ error: "Subject not found" }); + } + + res.status(200).json({ data: subject }); + } catch (error) { + console.error("GET /subjects/:id error:", error); + res.status(500).json({ error: "Failed to fetch subject" }); + } +}); + +// Create a new subject +router.post("/", async (req, res) => { + try { + const { departmentId, name, code, description } = parseRequest( + subjectCreateSchema, + req.body + ); + + const department = await getDepartmentById(departmentId); + + if (!department) + return res.status(404).json({ error: "Department not found" }); + + const [existingSubject] = await db + .select({ id: subjects.id }) + .from(subjects) + .where(eq(subjects.code, code)); + + if (existingSubject) + return res.status(409).json({ error: "Subject code already exists" }); + + const [createdSubject] = await db + .insert(subjects) + .values({ + departmentId, + name, + code, + description: description ?? null, + }) + .returning({ id: subjects.id }); + + if (!createdSubject) + return res.status(500).json({ error: "Failed to create subject" }); + + const subject = await getSubjectById(createdSubject.id); + + res.status(201).json({ data: subject }); + } catch (error) { + console.error("POST /subjects error:", error); + res.status(500).json({ error: "Failed to create subject" }); + } +}); + +// Update a subject by ID +router.put("/:id", async (req, res) => { + try { + const { id: subjectId } = parseRequest(subjectIdParamSchema, req.params); + + const existingSubjectById = await getSubjectById(subjectId); + if (!existingSubjectById) + return res.status(404).json({ error: "Subject not found" }); + + const { departmentId, name, code, description } = parseRequest( + subjectUpdateSchema, + req.body + ); + + const updateValues: Record = {}; + + if (departmentId) { + const department = await getDepartmentById(departmentId); + + if (!department) + return res.status(404).json({ error: "Department not found" }); + + updateValues.departmentId = departmentId; + } + + if (name) updateValues.name = name; + + if (code) { + const [existingSubjectWithCode] = await db + .select({ id: subjects.id }) + .from(subjects) + .where(and(eq(subjects.code, code), ne(subjects.id, subjectId))); + + if (existingSubjectWithCode) + return res.status(409).json({ error: "Subject code already exists" }); + + updateValues.code = code; + } + + if (description) updateValues.description = description; + + await db + .update(subjects) + .set(updateValues) + .where(eq(subjects.id, subjectId)); + + const subject = await getSubjectById(subjectId); + + res.status(200).json({ data: subject }); + } catch (error) { + console.error("PUT /subjects/:id error:", error); + res.status(500).json({ error: "Failed to update subject" }); + } +}); + +// Delete a subject by ID +router.delete("/:id", async (req, res) => { + try { + const { id: subjectId } = parseRequest(subjectIdParamSchema, req.params); + + const deletedRows = await db + .delete(subjects) + .where(eq(subjects.id, subjectId)) + .returning({ id: subjects.id }); + + if (deletedRows.length === 0) { + return res.status(404).json({ error: "Subject not found" }); + } + + res.status(200).json({ message: "Subject deleted" }); + } catch (error) { + console.error("DELETE /subjects/:id error:", error); + res.status(500).json({ error: "Failed to delete subject" }); + } +}); + export default router; diff --git a/src/validation/subjects.ts b/src/validation/subjects.ts new file mode 100644 index 0000000..627dae1 --- /dev/null +++ b/src/validation/subjects.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +export const subjectIdParamSchema = z + .object({ + id: z.coerce.number().int().positive(), + }) + .strict(); + +export const subjectListQuerySchema = z + .object({ + search: z.string().trim().min(1).optional(), + department: z.string().trim().min(1).optional(), + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }) + .strict(); + +export const subjectCreateSchema = z + .object({ + departmentId: z.coerce.number().int().positive(), + name: z.string().trim().min(1), + code: z.string().trim().min(1), + description: z.string().trim().optional().nullable(), + }) + .strict(); + +export const subjectUpdateSchema = z + .object({ + departmentId: z.coerce.number().int().positive().optional(), + name: z.string().trim().min(1).optional(), + code: z.string().trim().min(1).optional(), + description: z.string().trim().optional().nullable(), + }) + .strict() + .refine((data) => Object.values(data).some((value) => value !== undefined), { + message: "At least one field must be provided", + }); From cfe1dc0c5e1b2c440b3e52b93414e9ffb1608c4c Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Tue, 30 Dec 2025 12:17:11 +0530 Subject: [PATCH 02/19] feat: better auth --- package-lock.json | 264 +++++++++++++++++++++++++++++++++++++-- package.json | 1 + src/controllers/users.ts | 10 ++ src/db/schema/auth.ts | 107 ++++++++++++++++ src/index.ts | 7 ++ src/lib/auth.ts | 30 +++++ src/routes/users.ts | 209 +++++++++++++++++++++++++++++++ src/validation/users.ts | 44 +++++++ 8 files changed, 659 insertions(+), 13 deletions(-) create mode 100644 src/controllers/users.ts create mode 100644 src/db/schema/auth.ts create mode 100644 src/lib/auth.ts create mode 100644 src/routes/users.ts create mode 100644 src/validation/users.ts diff --git a/package-lock.json b/package-lock.json index 137cf74..8c585e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@neondatabase/serverless": "^1.0.2", + "better-auth": "^1.4.9", "cors": "^2.8.5", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", @@ -25,11 +26,51 @@ "typescript": "^5.9.3" } }, + "node_modules/@better-auth/core": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.4.9.tgz", + "integrity": "sha512-JT2q4NDkQzN22KclUEoZ7qU6tl9HUTfK1ctg2oWlT87SEagkwJcnrUwS9VznL+u9ziOIfY27P0f7/jSnmvLcoQ==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "zod": "^4.1.12" + }, + "peerDependencies": { + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21", + "better-call": "1.1.7", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1" + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.4.9.tgz", + "integrity": "sha512-Tthy1/Gmx+pYlbvRQPBTKfVei8+pJwvH1NZp+5SbhwA6K2EXIaoonx/K6N/AXYs2aKUpyR4/gzqDesDjL7zd6A==", + "dependencies": { + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21" + }, + "peerDependencies": { + "@better-auth/core": "1.4.9" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==", + "license": "MIT" + }, + "node_modules/@better-fetch/fetch": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@esbuild-kit/core-utils": { @@ -37,7 +78,7 @@ "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", "deprecated": "Merged into tsx: https://tsx.is", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.18.20", @@ -422,7 +463,7 @@ "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -461,7 +502,7 @@ "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", "deprecated": "Merged into tsx: https://tsx.is", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", @@ -938,6 +979,36 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1069,6 +1140,122 @@ "node": ">= 0.6" } }, + "node_modules/better-auth": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.4.9.tgz", + "integrity": "sha512-usSdjuyTzZwIvM8fjF8YGhPncxV3MAg3dHUO9uPUnf0yklXUSYISiH1+imk6/Z+UBqsscyyPRnbIyjyK97p7YA==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.4.9", + "@better-auth/telemetry": "1.4.9", + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21", + "@noble/ciphers": "^2.0.0", + "@noble/hashes": "^2.0.0", + "better-call": "1.1.7", + "defu": "^6.1.4", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1", + "zod": "^4.1.12" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": ">=0.41.0", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.1.7.tgz", + "integrity": "sha512-6gaJe1bBIEgVebQu/7q9saahVzvBsGaByEnE8aDVncZEDiJO7sdNB28ot9I6iXSbR25egGmmZ6aIURXyQHRraQ==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.3.0", + "@better-fetch/fetch": "^1.1.4", + "rou3": "^0.7.10", + "set-cookie-parser": "^2.7.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/body-parser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", @@ -1097,7 +1284,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/bytes": { @@ -1208,6 +1395,12 @@ } } }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1233,7 +1426,7 @@ "version": "0.31.8", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz", "integrity": "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@drizzle-team/brocli": "^0.10.2", @@ -1691,7 +1884,7 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1917,7 +2110,7 @@ "version": "0.27.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1959,7 +2152,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "debug": "^4.3.4" @@ -2130,7 +2323,7 @@ "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -2232,6 +2425,24 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/kysely": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.9.tgz", + "integrity": "sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2293,6 +2504,21 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanostores": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.1.0.tgz", + "integrity": "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -2489,12 +2715,18 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2554,6 +2786,12 @@ "node": ">= 18" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2636,7 +2874,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2646,7 +2884,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", diff --git a/package.json b/package.json index 60f7465..104c330 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "type": "module", "dependencies": { "@neondatabase/serverless": "^1.0.2", + "better-auth": "^1.4.9", "cors": "^2.8.5", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", diff --git a/src/controllers/users.ts b/src/controllers/users.ts new file mode 100644 index 0000000..85e0f16 --- /dev/null +++ b/src/controllers/users.ts @@ -0,0 +1,10 @@ +import { eq } from "drizzle-orm"; + +import { db } from "../db"; +import { user } from "../db/schema/auth"; + +export const getUserById = async (userId: string) => { + const userRows = await db.select().from(user).where(eq(user.id, userId)); + + return userRows[0]; +}; diff --git a/src/db/schema/auth.ts b/src/db/schema/auth.ts new file mode 100644 index 0000000..6988d53 --- /dev/null +++ b/src/db/schema/auth.ts @@ -0,0 +1,107 @@ +import { relations } from "drizzle-orm"; +import { + boolean, + index, + pgEnum, + pgTable, + text, + timestamp, + varchar, +} from "drizzle-orm/pg-core"; + +export const roleEnum = pgEnum("role", ["admin", "teacher", "student"]); + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + imageCldPubId: text("imageCldPubId"), + role: roleEnum("role").default("student").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), +}); + +export const session = pgTable( + "session", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => [index("session_userId_idx").on(table.userId)] +); + +export const account = pgTable( + "account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [index("account_userId_idx").on(table.userId)] +); + +export const verification = pgTable( + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [index("verification_identifier_idx").on(table.identifier)] +); + +export const userRelations = relations(user, ({ many }) => ({ + sessions: many(session), + accounts: many(account), +})); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { + fields: [session.userId], + references: [user.id], + }), +})); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { + fields: [account.userId], + references: [user.id], + }), +})); diff --git a/src/index.ts b/src/index.ts index a762666..e92efdd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,11 @@ import cors from "cors"; import express from "express"; +import { toNodeHandler } from "better-auth/node"; + +import { auth } from "./lib/auth"; import subjectsRouter from "./routes/subjects"; +import usersRouter from "./routes/users"; const app = express(); const PORT = 8000; @@ -16,6 +20,9 @@ app.use( app.use(express.json()); +app.all("/api/auth/{*any}", toNodeHandler(auth)); + +app.use("/api/users", usersRouter); app.use("/api/subjects", subjectsRouter); app.get("/", (req, res) => { diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..d8ce76f --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,30 @@ +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; + +import { db } from "../db"; + +export const auth = betterAuth({ + secret: process.env.BETTER_AUTH_SECRET!, + trustedOrigins: [process.env.FRONTEND_URL!], + database: drizzleAdapter(db, { + provider: "pg", + }), + emailAndPassword: { + enabled: true, + }, + user: { + additionalFields: { + role: { + type: "string", + required: true, + defaultValue: "student", + input: true, // Allow role to be set during registration + }, + imageCldPubId: { + type: "string", + required: false, + input: true, // Allow imageCldPubId to be set during registration + }, + }, + }, +}); diff --git a/src/routes/users.ts b/src/routes/users.ts new file mode 100644 index 0000000..5b274b5 --- /dev/null +++ b/src/routes/users.ts @@ -0,0 +1,209 @@ +import express from "express"; +import { and, desc, eq, ilike, ne, sql } from "drizzle-orm"; + +import { db } from "../db"; +import { user } from "../db/schema/auth"; +import { getUserById } from "../controllers/users"; +import { parseRequest } from "../lib/validation"; +import { + userCreateSchema, + userIdParamSchema, + userListQuerySchema, + userUpdateSchema, +} from "../validation/users"; + +const router = express.Router(); + +// Get all users with optional role filter, search by name, and pagination +router.get("/", async (req, res) => { + try { + const { + role, + search, + page = 1, + limit = 10, + } = parseRequest(userListQuerySchema, req.query); + + const filterConditions = []; + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + if (role) { + filterConditions.push(eq(user.role, role)); + } + + if (search) { + filterConditions.push(ilike(user.name, `%${search}%`)); + } + + const whereClause = + filterConditions.length > 0 ? and(...filterConditions) : undefined; + + const countResult = await db + .select({ count: sql`count(*)` }) + .from(user) + .where(whereClause); + + const totalCount = countResult[0]?.count ?? 0; + + const usersList = await db + .select() + .from(user) + .where(whereClause) + .orderBy(desc(user.createdAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: usersList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + message: "Users retrieved successfully", + }); + } catch (error) { + console.error("GET /users error:", error); + res.status(500).json({ error: "Failed to fetch users" }); + } +}); + +// Get user by ID +router.get("/:id", async (req, res) => { + try { + const { id: userId } = parseRequest(userIdParamSchema, req.params); + + const userRecord = await getUserById(userId); + + if (!userRecord) { + return res + .status(404) + .json({ error: "User not found", message: "User not found" }); + } + + res.status(200).json({ + data: userRecord, + message: "User retrieved successfully", + }); + } catch (error) { + console.error("GET /users/:id error:", error); + res.status(500).json({ error: "Failed to fetch user" }); + } +}); + +// Create user +router.post("/", async (req, res) => { + try { + const payload = parseRequest(userCreateSchema, req.body); + + const [existingUser] = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, payload.email)); + + if (existingUser) { + return res.status(409).json({ + error: "Email already exists", + message: "Email already exists", + }); + } + + const [createdUser] = await db.insert(user).values(payload).returning(); + + if (!createdUser) { + return res.status(500).json({ + error: "Internal server error", + message: "Failed to create user", + }); + } + + res.status(201).json({ + data: createdUser, + message: "User created successfully", + }); + } catch (error) { + console.error("POST /users error:", error); + res.status(500).json({ error: "Failed to create user" }); + } +}); + +// Update user +router.put("/:id", async (req, res) => { + try { + const { id: userId } = parseRequest(userIdParamSchema, req.params); + const payload = parseRequest(userUpdateSchema, req.body); + + const existingUser = await getUserById(userId); + if (!existingUser) { + return res + .status(404) + .json({ error: "User not found", message: "User not found" }); + } + + if (payload.email) { + const [existingEmail] = await db + .select({ id: user.id }) + .from(user) + .where(and(eq(user.email, payload.email), ne(user.id, userId))); + + if (existingEmail) { + return res.status(409).json({ + error: "Email already exists", + message: "Email already exists", + }); + } + } + + const [updatedUser] = await db + .update(user) + .set(payload) + .where(eq(user.id, userId)) + .returning(); + + if (!updatedUser) { + return res + .status(404) + .json({ error: "User not found", message: "User not found" }); + } + + res.status(200).json({ + data: updatedUser, + message: "User updated successfully", + }); + } catch (error) { + console.error("PUT /users/:id error:", error); + res.status(500).json({ error: "Failed to update user" }); + } +}); + +// Delete user +router.delete("/:id", async (req, res) => { + try { + const { id: userId } = parseRequest(userIdParamSchema, req.params); + + const [deletedUser] = await db + .delete(user) + .where(eq(user.id, userId)) + .returning(); + + if (!deletedUser) { + return res + .status(404) + .json({ error: "User not found", message: "User not found" }); + } + + res.status(200).json({ + data: deletedUser, + message: "User deleted successfully", + }); + } catch (error) { + console.error("DELETE /users/:id error:", error); + res.status(500).json({ error: "Failed to delete user" }); + } +}); + +export default router; diff --git a/src/validation/users.ts b/src/validation/users.ts new file mode 100644 index 0000000..df8c9d1 --- /dev/null +++ b/src/validation/users.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +import { roleEnum } from "../db/schema/auth"; + +export const userIdParamSchema = z + .object({ + id: z.string().trim().min(1), + }) + .strict(); + +export const userListQuerySchema = z + .object({ + role: z.enum(roleEnum.enumValues).optional(), + search: z.string().trim().min(1).optional(), + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }) + .strict(); + +export const userCreateSchema = z + .object({ + id: z.string().trim().min(1), + name: z.string().trim().min(1), + email: z.string().trim().email(), + emailVerified: z.boolean().optional().default(false), + image: z.string().trim().optional().nullable(), + imageCldPubId: z.string().trim().optional().nullable(), + role: z.enum(roleEnum.enumValues).optional().default("student"), + }) + .strict(); + +export const userUpdateSchema = z + .object({ + name: z.string().trim().min(1).optional(), + email: z.string().trim().email().optional(), + emailVerified: z.boolean().optional(), + image: z.string().trim().optional().nullable(), + imageCldPubId: z.string().trim().optional().nullable(), + role: z.enum(roleEnum.enumValues).optional(), + }) + .strict() + .refine((data) => Object.values(data).some((value) => value !== undefined), { + message: "At least one field must be provided", + }); From bb9ca3c80243d035d93c03a0604db78581d81d4d Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Tue, 30 Dec 2025 12:44:04 +0530 Subject: [PATCH 03/19] feat: classes and enrollments --- src/controllers/classes.ts | 24 +++ src/controllers/enrollments.ts | 24 +++ src/db/schema/app.ts | 94 +++++++++++ src/index.ts | 4 + src/routes/classes.ts | 270 ++++++++++++++++++++++++++++++ src/routes/enrollments.ts | 289 +++++++++++++++++++++++++++++++++ src/routes/subjects.ts | 9 +- src/routes/users.ts | 32 +++- src/validation/classes.ts | 67 ++++++++ src/validation/enrollments.ts | 40 +++++ 10 files changed, 839 insertions(+), 14 deletions(-) create mode 100644 src/controllers/classes.ts create mode 100644 src/controllers/enrollments.ts create mode 100644 src/routes/classes.ts create mode 100644 src/routes/enrollments.ts create mode 100644 src/validation/classes.ts create mode 100644 src/validation/enrollments.ts diff --git a/src/controllers/classes.ts b/src/controllers/classes.ts new file mode 100644 index 0000000..6b9c56a --- /dev/null +++ b/src/controllers/classes.ts @@ -0,0 +1,24 @@ +import { eq, getTableColumns } from "drizzle-orm"; + +import { db } from "../db"; +import { classes, subjects } from "../db/schema"; +import { user } from "../db/schema/auth"; + +export const getClassById = async (classId: number) => { + const classRows = await db + .select({ + ...getTableColumns(classes), + subject: { + ...getTableColumns(subjects), + }, + teacher: { + ...getTableColumns(user), + }, + }) + .from(classes) + .leftJoin(subjects, eq(classes.subjectId, subjects.id)) + .leftJoin(user, eq(classes.teacherId, user.id)) + .where(eq(classes.id, classId)); + + return classRows[0]; +}; diff --git a/src/controllers/enrollments.ts b/src/controllers/enrollments.ts new file mode 100644 index 0000000..5d6a7e9 --- /dev/null +++ b/src/controllers/enrollments.ts @@ -0,0 +1,24 @@ +import { eq, getTableColumns } from "drizzle-orm"; + +import { db } from "../db"; +import { classes, enrollments } from "../db/schema"; +import { user } from "../db/schema/auth"; + +export const getEnrollmentById = async (enrollmentId: number) => { + const enrollmentRows = await db + .select({ + ...getTableColumns(enrollments), + class: { + ...getTableColumns(classes), + }, + student: { + ...getTableColumns(user), + }, + }) + .from(enrollments) + .leftJoin(classes, eq(enrollments.classId, classes.id)) + .leftJoin(user, eq(enrollments.studentId, user.id)) + .where(eq(enrollments.id, enrollmentId)); + + return enrollmentRows[0]; +}; diff --git a/src/db/schema/app.ts b/src/db/schema/app.ts index 1eae645..73b3275 100644 --- a/src/db/schema/app.ts +++ b/src/db/schema/app.ts @@ -1,13 +1,18 @@ import { relations } from "drizzle-orm"; import { + index, integer, + jsonb, pgEnum, pgTable, text, timestamp, + uniqueIndex, varchar, } from "drizzle-orm/pg-core"; +import { user } from "./auth"; + const timestamps = { createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") @@ -39,6 +44,65 @@ export const subjects = pgTable("subjects", { ...timestamps, }); +export const classStatusEnum = pgEnum("class_status", [ + "active", + "inactive", + "archived", +]); + +export const classes = pgTable( + "classes", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + name: varchar("name", { length: 255 }).notNull(), + inviteCode: varchar("invite_code", { length: 20 }).unique().notNull(), + subjectId: integer("subject_id") + .references(() => subjects.id, { onDelete: "cascade" }) + .notNull(), + teacherId: text("teacher_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + description: text("description"), + bannerUrl: text("banner_url"), + bannerCldPubId: text("imageCldPubId"), + capacity: integer("capacity").default(50), + status: classStatusEnum("status").notNull().default("active"), + schedules: jsonb("schedules").$type().default([]).notNull(), + + ...timestamps, + }, + (table) => [ + index("classes_subject_id_idx").on(table.subjectId), + index("classes_teacher_id_idx").on(table.teacherId), + ] +); + +export const enrollments = pgTable( + "enrollments", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + studentId: text("student_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + classId: integer("class_id") + .references(() => classes.id, { onDelete: "cascade" }) + .notNull(), + enrolledAt: timestamp("enrolled_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [ + index("enrollments_class_id_idx").on(table.classId), + index("enrollments_student_id_idx").on(table.studentId), + uniqueIndex("enrollments_student_class_unq").on( + table.studentId, + table.classId + ), + ] +); + export const departmentsRelations = relations(departments, ({ many }) => ({ subjects: many(subjects), })); @@ -48,6 +112,30 @@ export const subjectsRelations = relations(subjects, ({ one, many }) => ({ fields: [subjects.departmentId], references: [departments.id], }), + classes: many(classes), +})); + +export const classesRelations = relations(classes, ({ one, many }) => ({ + subject: one(subjects, { + fields: [classes.subjectId], + references: [subjects.id], + }), + teacher: one(user, { + fields: [classes.teacherId], + references: [user.id], + }), + enrollments: many(enrollments), +})); + +export const enrollmentsRelations = relations(enrollments, ({ one }) => ({ + student: one(user, { + fields: [enrollments.studentId], + references: [user.id], + }), + class: one(classes, { + fields: [enrollments.classId], + references: [classes.id], + }), })); export type Department = typeof departments.$inferSelect; @@ -55,3 +143,9 @@ export type NewDepartment = typeof departments.$inferInsert; export type Subject = typeof subjects.$inferSelect; export type NewSubject = typeof subjects.$inferInsert; + +export type Class = typeof classes.$inferSelect; +export type NewClass = typeof classes.$inferInsert; + +export type Enrollment = typeof enrollments.$inferSelect; +export type NewEnrollment = typeof enrollments.$inferInsert; diff --git a/src/index.ts b/src/index.ts index e92efdd..49c7651 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ import { auth } from "./lib/auth"; import subjectsRouter from "./routes/subjects"; import usersRouter from "./routes/users"; +import classesRouter from "./routes/classes"; +import enrollmentsRouter from "./routes/enrollments"; const app = express(); const PORT = 8000; @@ -24,6 +26,8 @@ app.all("/api/auth/{*any}", toNodeHandler(auth)); app.use("/api/users", usersRouter); app.use("/api/subjects", subjectsRouter); +app.use("/api/classes", classesRouter); +app.use("/api/enrollments", enrollmentsRouter); app.get("/", (req, res) => { res.send("Backend server is running!"); diff --git a/src/routes/classes.ts b/src/routes/classes.ts new file mode 100644 index 0000000..0ccca58 --- /dev/null +++ b/src/routes/classes.ts @@ -0,0 +1,270 @@ +import express from "express"; +import { and, desc, eq, ilike, ne, or, sql } from "drizzle-orm"; + +import { db } from "../db"; +import { classes } from "../db/schema"; +import { getClassById } from "../controllers/classes"; +import { getSubjectById } from "../controllers/subjects"; +import { getUserById } from "../controllers/users"; +import { parseRequest } from "../lib/validation"; +import { + classCreateSchema, + classIdParamSchema, + classInviteParamSchema, + classListQuerySchema, + classUpdateSchema, +} from "../validation/classes"; + +const router = express.Router(); + +// Get all classes with optional filters and pagination +router.get("/", async (req, res) => { + try { + const { + search, + subjectId, + teacherId, + status, + page = 1, + limit = 10, + } = parseRequest(classListQuerySchema, req.query); + + const filterConditions = []; + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + if (search) { + filterConditions.push( + or( + ilike(classes.name, `%${search}%`), + ilike(classes.inviteCode, `%${search}%`) + ) + ); + } + + if (subjectId) { + filterConditions.push(eq(classes.subjectId, subjectId)); + } + + if (teacherId) { + filterConditions.push(eq(classes.teacherId, teacherId)); + } + + if (status) { + filterConditions.push(eq(classes.status, status)); + } + + const whereClause = + filterConditions.length > 0 ? and(...filterConditions) : undefined; + + const countResult = await db + .select({ count: sql`count(*)` }) + .from(classes) + .where(whereClause); + + const totalCount = countResult[0]?.count ?? 0; + + const classesList = await db + .select() + .from(classes) + .where(whereClause) + .orderBy(desc(classes.createdAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: classesList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + }); + } catch (error) { + console.error("GET /classes error:", error); + res.status(500).json({ error: "Failed to fetch classes" }); + } +}); + +// Get class by invite code +router.get("/invite/:code", async (req, res) => { + try { + const { code } = parseRequest(classInviteParamSchema, req.params); + + const [classRecord] = await db + .select() + .from(classes) + .where(eq(classes.inviteCode, code)); + + if (!classRecord) { + return res.status(404).json({ error: "Class not found" }); + } + + res.status(200).json({ data: classRecord }); + } catch (error) { + console.error("GET /classes/invite/:code error:", error); + res.status(500).json({ error: "Failed to fetch class" }); + } +}); + +// Get class by ID +router.get("/:id", async (req, res) => { + try { + const { id: classId } = parseRequest(classIdParamSchema, req.params); + + const classRecord = await getClassById(classId); + + if (!classRecord) { + return res.status(404).json({ error: "Class not found" }); + } + + res.status(200).json({ data: classRecord }); + } catch (error) { + console.error("GET /classes/:id error:", error); + res.status(500).json({ error: "Failed to fetch class" }); + } +}); + +// Create class +router.post("/", async (req, res) => { + try { + const payload = parseRequest(classCreateSchema, req.body); + + const subject = await getSubjectById(payload.subjectId); + if (!subject) { + return res.status(404).json({ error: "Subject not found" }); + } + + const teacher = await getUserById(payload.teacherId); + if (!teacher) { + return res.status(404).json({ error: "Teacher not found" }); + } + + const [existingInvite] = await db + .select({ id: classes.id }) + .from(classes) + .where(eq(classes.inviteCode, payload.inviteCode)); + + if (existingInvite) { + return res.status(409).json({ error: "Invite code already exists" }); + } + + const [createdClass] = await db + .insert(classes) + .values({ + ...payload, + description: payload.description ?? null, + bannerUrl: payload.bannerUrl ?? null, + bannerCldPubId: payload.bannerCldPubId ?? null, + schedules: payload.schedules ?? [], + }) + .returning({ id: classes.id }); + + if (!createdClass) { + return res.status(500).json({ error: "Failed to create class" }); + } + + const classRecord = await getClassById(createdClass.id); + + res.status(201).json({ data: classRecord }); + } catch (error) { + console.error("POST /classes error:", error); + res.status(500).json({ error: "Failed to create class" }); + } +}); + +// Update class +router.put("/:id", async (req, res) => { + try { + const { id: classId } = parseRequest(classIdParamSchema, req.params); + const { + subjectId, + teacherId, + inviteCode, + bannerCldPubId, + bannerUrl, + capacity, + description, + name, + schedules, + status, + } = parseRequest(classUpdateSchema, req.body); + + const existingClass = await getClassById(classId); + if (!existingClass) + return res.status(404).json({ error: "Class not found" }); + + if (subjectId) { + const subject = await getSubjectById(subjectId); + if (!subject) return res.status(404).json({ error: "Subject not found" }); + } + + if (teacherId) { + const teacher = await getUserById(teacherId); + if (!teacher) return res.status(404).json({ error: "Teacher not found" }); + } + + if (inviteCode) { + const [existingInvite] = await db + .select({ id: classes.id }) + .from(classes) + .where( + and(eq(classes.inviteCode, inviteCode), ne(classes.id, classId)) + ); + + if (existingInvite) + return res.status(409).json({ error: "Invite code already exists" }); + } + + const updateValues: Record = {}; + + for (const [key, value] of Object.entries({ + subjectId, + teacherId, + inviteCode, + bannerCldPubId, + bannerUrl, + capacity, + description, + name, + schedules, + status, + })) { + if (value) updateValues[key] = value; + } + + await db.update(classes).set(updateValues).where(eq(classes.id, classId)); + + const classRecord = await getClassById(classId); + + res.status(200).json({ data: classRecord }); + } catch (error) { + console.error("PUT /classes/:id error:", error); + res.status(500).json({ error: "Failed to update class" }); + } +}); + +// Delete class +router.delete("/:id", async (req, res) => { + try { + const { id: classId } = parseRequest(classIdParamSchema, req.params); + + const deletedRows = await db + .delete(classes) + .where(eq(classes.id, classId)) + .returning({ id: classes.id }); + + if (deletedRows.length === 0) + return res.status(404).json({ error: "Class not found" }); + + res.status(200).json({ message: "Class deleted" }); + } catch (error) { + console.error("DELETE /classes/:id error:", error); + res.status(500).json({ error: "Failed to delete class" }); + } +}); + +export default router; diff --git a/src/routes/enrollments.ts b/src/routes/enrollments.ts new file mode 100644 index 0000000..4762467 --- /dev/null +++ b/src/routes/enrollments.ts @@ -0,0 +1,289 @@ +import express from "express"; +import { and, desc, eq, ne, sql } from "drizzle-orm"; + +import { db } from "../db"; +import { classes, enrollments } from "../db/schema"; +import { getClassById } from "../controllers/classes"; +import { getEnrollmentById } from "../controllers/enrollments"; +import { getUserById } from "../controllers/users"; +import { parseRequest } from "../lib/validation"; +import { + enrollmentCreateSchema, + enrollmentIdParamSchema, + enrollmentJoinSchema, + enrollmentListQuerySchema, + enrollmentUpdateSchema, +} from "../validation/enrollments"; + +const router = express.Router(); + +// Get all enrollments with optional filters and pagination +router.get("/", async (req, res) => { + try { + const { + classId, + studentId, + page = 1, + limit = 10, + } = parseRequest(enrollmentListQuerySchema, req.query); + + const filterConditions = []; + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + if (classId) { + filterConditions.push(eq(enrollments.classId, classId)); + } + + if (studentId) { + filterConditions.push(eq(enrollments.studentId, studentId)); + } + + const whereClause = + filterConditions.length > 0 ? and(...filterConditions) : undefined; + + const countResult = await db + .select({ count: sql`count(*)` }) + .from(enrollments) + .where(whereClause); + + const totalCount = countResult[0]?.count ?? 0; + + const enrollmentList = await db + .select() + .from(enrollments) + .where(whereClause) + .orderBy(desc(enrollments.enrolledAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: enrollmentList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + }); + } catch (error) { + console.error("GET /enrollments error:", error); + res.status(500).json({ error: "Failed to fetch enrollments" }); + } +}); + +// Get enrollment by ID +router.get("/:id", async (req, res) => { + try { + const { id: enrollmentId } = parseRequest( + enrollmentIdParamSchema, + req.params + ); + + const enrollment = await getEnrollmentById(enrollmentId); + + if (!enrollment) + return res.status(404).json({ error: "Enrollment not found" }); + + res.status(200).json({ data: enrollment }); + } catch (error) { + console.error("GET /enrollments/:id error:", error); + res.status(500).json({ error: "Failed to fetch enrollment" }); + } +}); + +// Create enrollment +router.post("/", async (req, res) => { + try { + const { classId, studentId } = parseRequest( + enrollmentCreateSchema, + req.body + ); + + const classRecord = await getClassById(classId); + if (!classRecord) return res.status(404).json({ error: "Class not found" }); + + const student = await getUserById(studentId); + if (!student) return res.status(404).json({ error: "Student not found" }); + + const [existingEnrollment] = await db + .select({ id: enrollments.id }) + .from(enrollments) + .where( + and( + eq(enrollments.classId, classId), + eq(enrollments.studentId, studentId) + ) + ); + + if (existingEnrollment) + return res + .status(409) + .json({ error: "Student already enrolled in class" }); + + const [createdEnrollment] = await db + .insert(enrollments) + .values({ classId, studentId }) + .returning({ id: enrollments.id }); + + if (!createdEnrollment) + return res.status(500).json({ error: "Failed to create enrollment" }); + + const enrollment = await getEnrollmentById(createdEnrollment.id); + + res.status(201).json({ data: enrollment }); + } catch (error) { + console.error("POST /enrollments error:", error); + res.status(500).json({ error: "Failed to create enrollment" }); + } +}); + +// Join class by invite code +router.post("/join", async (req, res) => { + try { + const { inviteCode, studentId } = parseRequest( + enrollmentJoinSchema, + req.body + ); + + const [classRecord] = await db + .select() + .from(classes) + .where(eq(classes.inviteCode, inviteCode)); + + if (!classRecord) return res.status(404).json({ error: "Class not found" }); + + const student = await getUserById(studentId); + if (!student) return res.status(404).json({ error: "Student not found" }); + + const [existingEnrollment] = await db + .select({ id: enrollments.id }) + .from(enrollments) + .where( + and( + eq(enrollments.classId, classRecord.id), + eq(enrollments.studentId, studentId) + ) + ); + + if (existingEnrollment) + return res + .status(409) + .json({ error: "Student already enrolled in class" }); + + const [createdEnrollment] = await db + .insert(enrollments) + .values({ classId: classRecord.id, studentId }) + .returning({ id: enrollments.id }); + + if (!createdEnrollment) + return res.status(500).json({ error: "Failed to create enrollment" }); + + const enrollment = await getEnrollmentById(createdEnrollment.id); + + res.status(201).json({ data: enrollment }); + } catch (error) { + console.error("POST /enrollments/join error:", error); + res.status(500).json({ error: "Failed to join class" }); + } +}); + +// Update enrollment +router.put("/:id", async (req, res) => { + try { + const { id: enrollmentId } = parseRequest( + enrollmentIdParamSchema, + req.params + ); + + const { classId, studentId } = parseRequest( + enrollmentUpdateSchema, + req.body + ); + + const existingEnrollment = await getEnrollmentById(enrollmentId); + if (!existingEnrollment) { + return res.status(404).json({ error: "Enrollment not found" }); + } + + if (classId) { + const classRecord = await getClassById(classId); + if (!classRecord) + return res.status(404).json({ error: "Class not found" }); + } + + if (studentId) { + const student = await getUserById(studentId); + if (!student) return res.status(404).json({ error: "Student not found" }); + } + + if (classId || studentId) { + const classIdToCheck = classId ?? existingEnrollment.classId; + const studentIdToCheck = studentId ?? existingEnrollment.studentId; + + const [existingEnrollmentPair] = await db + .select({ id: enrollments.id }) + .from(enrollments) + .where( + and( + eq(enrollments.classId, classIdToCheck), + eq(enrollments.studentId, studentIdToCheck), + ne(enrollments.id, enrollmentId) + ) + ); + + if (existingEnrollmentPair) + return res + .status(409) + .json({ error: "Student already enrolled in class" }); + } + + const updateValues: Record = {}; + + for (const [key, value] of Object.entries({ + classId, + studentId, + })) { + if (value) updateValues[key] = value; + } + + await db + .update(enrollments) + .set(updateValues) + .where(eq(enrollments.id, enrollmentId)); + + const enrollment = await getEnrollmentById(enrollmentId); + + res.status(200).json({ data: enrollment }); + } catch (error) { + console.error("PUT /enrollments/:id error:", error); + res.status(500).json({ error: "Failed to update enrollment" }); + } +}); + +// Delete enrollment +router.delete("/:id", async (req, res) => { + try { + const { id: enrollmentId } = parseRequest( + enrollmentIdParamSchema, + req.params + ); + + const deletedRows = await db + .delete(enrollments) + .where(eq(enrollments.id, enrollmentId)) + .returning({ id: enrollments.id }); + + if (deletedRows.length === 0) + return res.status(404).json({ error: "Enrollment not found" }); + + res.status(200).json({ message: "Enrollment deleted" }); + } catch (error) { + console.error("DELETE /enrollments/:id error:", error); + res.status(500).json({ error: "Failed to delete enrollment" }); + } +}); + +export default router; diff --git a/src/routes/subjects.ts b/src/routes/subjects.ts index 269e6a1..a025b0a 100644 --- a/src/routes/subjects.ts +++ b/src/routes/subjects.ts @@ -175,8 +175,6 @@ router.put("/:id", async (req, res) => { updateValues.departmentId = departmentId; } - if (name) updateValues.name = name; - if (code) { const [existingSubjectWithCode] = await db .select({ id: subjects.id }) @@ -189,7 +187,9 @@ router.put("/:id", async (req, res) => { updateValues.code = code; } - if (description) updateValues.description = description; + for (const [key, value] of Object.entries({ name, description })) { + if (value) updateValues[key] = value; + } await db .update(subjects) @@ -215,9 +215,8 @@ router.delete("/:id", async (req, res) => { .where(eq(subjects.id, subjectId)) .returning({ id: subjects.id }); - if (deletedRows.length === 0) { + if (deletedRows.length === 0) return res.status(404).json({ error: "Subject not found" }); - } res.status(200).json({ message: "Subject deleted" }); } catch (error) { diff --git a/src/routes/users.ts b/src/routes/users.ts index 5b274b5..128bec1 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -135,32 +135,47 @@ router.post("/", async (req, res) => { router.put("/:id", async (req, res) => { try { const { id: userId } = parseRequest(userIdParamSchema, req.params); - const payload = parseRequest(userUpdateSchema, req.body); + const { name, email, image, imageCldPubId, role } = parseRequest( + userUpdateSchema, + req.body + ); const existingUser = await getUserById(userId); - if (!existingUser) { + if (!existingUser) return res .status(404) .json({ error: "User not found", message: "User not found" }); - } - if (payload.email) { + if (email) { const [existingEmail] = await db .select({ id: user.id }) .from(user) - .where(and(eq(user.email, payload.email), ne(user.id, userId))); + .where(and(eq(user.email, email), ne(user.id, userId))); - if (existingEmail) { + if (existingEmail) return res.status(409).json({ error: "Email already exists", message: "Email already exists", }); + } + + const updateValues: Record = {}; + + for (const [key, value] of Object.entries({ + name, + email, + image, + imageCldPubId, + role, + })) { + if (value) { + updateValues[key] = value; } } const [updatedUser] = await db .update(user) - .set(payload) + .set(updateValues) .where(eq(user.id, userId)) .returning(); @@ -190,11 +205,10 @@ router.delete("/:id", async (req, res) => { .where(eq(user.id, userId)) .returning(); - if (!deletedUser) { + if (!deletedUser) return res .status(404) .json({ error: "User not found", message: "User not found" }); - } res.status(200).json({ data: deletedUser, diff --git a/src/validation/classes.ts b/src/validation/classes.ts new file mode 100644 index 0000000..14a28b6 --- /dev/null +++ b/src/validation/classes.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import { classStatusEnum } from "../db/schema"; + +const scheduleSchema = z + .object({ + day: z.string().trim().min(1), + startTime: z.string().trim().min(1), + endTime: z.string().trim().min(1), + }) + .strict(); + +export const classIdParamSchema = z + .object({ + id: z.coerce.number().int().positive(), + }) + .strict(); + +export const classInviteParamSchema = z + .object({ + code: z.string().trim().min(1), + }) + .strict(); + +export const classListQuerySchema = z + .object({ + search: z.string().trim().min(1).optional(), + subjectId: z.coerce.number().int().positive().optional(), + teacherId: z.string().trim().min(1).optional(), + status: z.enum(classStatusEnum.enumValues).optional(), + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }) + .strict(); + +export const classCreateSchema = z + .object({ + name: z.string().trim().min(1), + inviteCode: z.string().trim().min(1), + subjectId: z.coerce.number().int().positive(), + teacherId: z.string().trim().min(1), + description: z.string().trim().optional().nullable(), + bannerUrl: z.string().trim().optional().nullable(), + bannerCldPubId: z.string().trim().optional().nullable(), + capacity: z.coerce.number().int().min(1).optional(), + status: z.enum(classStatusEnum.enumValues).optional(), + schedules: z.array(scheduleSchema).optional(), + }) + .strict(); + +export const classUpdateSchema = z + .object({ + name: z.string().trim().min(1).optional(), + inviteCode: z.string().trim().min(1).optional(), + subjectId: z.coerce.number().int().positive().optional(), + teacherId: z.string().trim().min(1).optional(), + description: z.string().trim().optional().nullable(), + bannerUrl: z.string().trim().optional().nullable(), + bannerCldPubId: z.string().trim().optional().nullable(), + capacity: z.coerce.number().int().min(1).optional(), + status: z.enum(classStatusEnum.enumValues).optional(), + schedules: z.array(scheduleSchema).optional(), + }) + .strict() + .refine((data) => Object.values(data).some((value) => value !== undefined), { + message: "At least one field must be provided", + }); diff --git a/src/validation/enrollments.ts b/src/validation/enrollments.ts new file mode 100644 index 0000000..099a440 --- /dev/null +++ b/src/validation/enrollments.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +export const enrollmentIdParamSchema = z + .object({ + id: z.coerce.number().int().positive(), + }) + .strict(); + +export const enrollmentListQuerySchema = z + .object({ + classId: z.coerce.number().int().positive().optional(), + studentId: z.string().trim().min(1).optional(), + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }) + .strict(); + +export const enrollmentCreateSchema = z + .object({ + classId: z.coerce.number().int().positive(), + studentId: z.string().trim().min(1), + }) + .strict(); + +export const enrollmentJoinSchema = z + .object({ + inviteCode: z.string().trim().min(1), + studentId: z.string().trim().min(1), + }) + .strict(); + +export const enrollmentUpdateSchema = z + .object({ + classId: z.coerce.number().int().positive().optional(), + studentId: z.string().trim().min(1).optional(), + }) + .strict() + .refine((data) => Object.values(data).some((value) => value !== undefined), { + message: "At least one field must be provided", + }); From 457e20044130dac7e13a778a97126cba77fc085d Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Tue, 30 Dec 2025 12:50:28 +0530 Subject: [PATCH 04/19] feat: reusable controllers --- src/controllers/classes.ts | 9 +++++ src/controllers/subjects.ts | 9 +++++ src/controllers/users.ts | 6 ++++ src/routes/classes.ts | 72 ++++++++++++++++++------------------- src/routes/enrollments.ts | 9 ++--- src/routes/subjects.ts | 26 +++----------- src/routes/users.ts | 27 +++++--------- 7 files changed, 75 insertions(+), 83 deletions(-) diff --git a/src/controllers/classes.ts b/src/controllers/classes.ts index 6b9c56a..0d14405 100644 --- a/src/controllers/classes.ts +++ b/src/controllers/classes.ts @@ -22,3 +22,12 @@ export const getClassById = async (classId: number) => { return classRows[0]; }; + +export const getClassByInviteCode = async (inviteCode: string) => { + const classRows = await db + .select() + .from(classes) + .where(eq(classes.inviteCode, inviteCode)); + + return classRows[0]; +}; diff --git a/src/controllers/subjects.ts b/src/controllers/subjects.ts index f5232e3..e2f9132 100644 --- a/src/controllers/subjects.ts +++ b/src/controllers/subjects.ts @@ -17,3 +17,12 @@ export const getSubjectById = async (subjectId: number) => { return subjectRows[0]; }; + +export const getSubjectByCode = async (code: string) => { + const subjectRows = await db + .select() + .from(subjects) + .where(eq(subjects.code, code)); + + return subjectRows[0]; +}; diff --git a/src/controllers/users.ts b/src/controllers/users.ts index 85e0f16..025de3d 100644 --- a/src/controllers/users.ts +++ b/src/controllers/users.ts @@ -8,3 +8,9 @@ export const getUserById = async (userId: string) => { return userRows[0]; }; + +export const getUserByEmail = async (email: string) => { + const userRows = await db.select().from(user).where(eq(user.email, email)); + + return userRows[0]; +}; diff --git a/src/routes/classes.ts b/src/routes/classes.ts index 0ccca58..4e8af21 100644 --- a/src/routes/classes.ts +++ b/src/routes/classes.ts @@ -1,9 +1,9 @@ import express from "express"; -import { and, desc, eq, ilike, ne, or, sql } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { db } from "../db"; import { classes } from "../db/schema"; -import { getClassById } from "../controllers/classes"; +import { getClassById, getClassByInviteCode } from "../controllers/classes"; import { getSubjectById } from "../controllers/subjects"; import { getUserById } from "../controllers/users"; import { parseRequest } from "../lib/validation"; @@ -94,14 +94,9 @@ router.get("/invite/:code", async (req, res) => { try { const { code } = parseRequest(classInviteParamSchema, req.params); - const [classRecord] = await db - .select() - .from(classes) - .where(eq(classes.inviteCode, code)); + const classRecord = await getClassByInviteCode(code); - if (!classRecord) { - return res.status(404).json({ error: "Class not found" }); - } + if (!classRecord) return res.status(404).json({ error: "Class not found" }); res.status(200).json({ data: classRecord }); } catch (error) { @@ -131,41 +126,49 @@ router.get("/:id", async (req, res) => { // Create class router.post("/", async (req, res) => { try { - const payload = parseRequest(classCreateSchema, req.body); + const { + subjectId, + inviteCode, + name, + teacherId, + bannerCldPubId, + bannerUrl, + capacity, + description, + schedules, + status, + } = parseRequest(classCreateSchema, req.body); - const subject = await getSubjectById(payload.subjectId); + const subject = await getSubjectById(subjectId); if (!subject) { return res.status(404).json({ error: "Subject not found" }); } - const teacher = await getUserById(payload.teacherId); - if (!teacher) { - return res.status(404).json({ error: "Teacher not found" }); - } + const teacher = await getUserById(teacherId); + if (!teacher) return res.status(404).json({ error: "Teacher not found" }); - const [existingInvite] = await db - .select({ id: classes.id }) - .from(classes) - .where(eq(classes.inviteCode, payload.inviteCode)); - - if (existingInvite) { + const existingInvite = await getClassByInviteCode(inviteCode); + if (existingInvite) return res.status(409).json({ error: "Invite code already exists" }); - } const [createdClass] = await db .insert(classes) .values({ - ...payload, - description: payload.description ?? null, - bannerUrl: payload.bannerUrl ?? null, - bannerCldPubId: payload.bannerCldPubId ?? null, - schedules: payload.schedules ?? [], + subjectId, + inviteCode, + name, + teacherId, + bannerCldPubId, + bannerUrl, + capacity, + description, + schedules, + status, }) .returning({ id: classes.id }); - if (!createdClass) { + if (!createdClass) return res.status(500).json({ error: "Failed to create class" }); - } const classRecord = await getClassById(createdClass.id); @@ -208,14 +211,9 @@ router.put("/:id", async (req, res) => { } if (inviteCode) { - const [existingInvite] = await db - .select({ id: classes.id }) - .from(classes) - .where( - and(eq(classes.inviteCode, inviteCode), ne(classes.id, classId)) - ); - - if (existingInvite) + const existingInvite = await getClassByInviteCode(inviteCode); + + if (existingInvite && existingInvite.id !== classId) return res.status(409).json({ error: "Invite code already exists" }); } diff --git a/src/routes/enrollments.ts b/src/routes/enrollments.ts index 4762467..4c4e1df 100644 --- a/src/routes/enrollments.ts +++ b/src/routes/enrollments.ts @@ -2,8 +2,8 @@ import express from "express"; import { and, desc, eq, ne, sql } from "drizzle-orm"; import { db } from "../db"; -import { classes, enrollments } from "../db/schema"; -import { getClassById } from "../controllers/classes"; +import { enrollments } from "../db/schema"; +import { getClassById, getClassByInviteCode } from "../controllers/classes"; import { getEnrollmentById } from "../controllers/enrollments"; import { getUserById } from "../controllers/users"; import { parseRequest } from "../lib/validation"; @@ -148,10 +148,7 @@ router.post("/join", async (req, res) => { req.body ); - const [classRecord] = await db - .select() - .from(classes) - .where(eq(classes.inviteCode, inviteCode)); + const classRecord = await getClassByInviteCode(inviteCode); if (!classRecord) return res.status(404).json({ error: "Class not found" }); diff --git a/src/routes/subjects.ts b/src/routes/subjects.ts index a025b0a..ca80fd5 100644 --- a/src/routes/subjects.ts +++ b/src/routes/subjects.ts @@ -1,14 +1,5 @@ import express from "express"; -import { - eq, - ilike, - or, - and, - desc, - sql, - getTableColumns, - ne, -} from "drizzle-orm"; +import { eq, ilike, or, and, desc, sql, getTableColumns } from "drizzle-orm"; import { db } from "../db"; import { departments, subjects } from "../db/schema"; @@ -17,7 +8,7 @@ import { subjectIdParamSchema, subjectUpdateSchema, } from "../validation/subjects"; -import { getSubjectById } from "../controllers/subjects"; +import { getSubjectByCode, getSubjectById } from "../controllers/subjects"; import { getDepartmentById } from "../controllers/departments"; import { parseRequest } from "../lib/validation"; @@ -120,11 +111,7 @@ router.post("/", async (req, res) => { if (!department) return res.status(404).json({ error: "Department not found" }); - const [existingSubject] = await db - .select({ id: subjects.id }) - .from(subjects) - .where(eq(subjects.code, code)); - + const existingSubject = await getSubjectByCode(code); if (existingSubject) return res.status(409).json({ error: "Subject code already exists" }); @@ -176,12 +163,9 @@ router.put("/:id", async (req, res) => { } if (code) { - const [existingSubjectWithCode] = await db - .select({ id: subjects.id }) - .from(subjects) - .where(and(eq(subjects.code, code), ne(subjects.id, subjectId))); + const existingSubjectWithCode = await getSubjectByCode(code); - if (existingSubjectWithCode) + if (existingSubjectWithCode && existingSubjectWithCode.id !== subjectId) return res.status(409).json({ error: "Subject code already exists" }); updateValues.code = code; diff --git a/src/routes/users.ts b/src/routes/users.ts index 128bec1..3627c4e 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -1,9 +1,9 @@ import express from "express"; -import { and, desc, eq, ilike, ne, sql } from "drizzle-orm"; +import { and, desc, eq, ilike, sql } from "drizzle-orm"; import { db } from "../db"; import { user } from "../db/schema/auth"; -import { getUserById } from "../controllers/users"; +import { getUserByEmail, getUserById } from "../controllers/users"; import { parseRequest } from "../lib/validation"; import { userCreateSchema, @@ -100,26 +100,19 @@ router.post("/", async (req, res) => { try { const payload = parseRequest(userCreateSchema, req.body); - const [existingUser] = await db - .select({ id: user.id }) - .from(user) - .where(eq(user.email, payload.email)); - - if (existingUser) { + const existingUser = await getUserByEmail(payload.email); + if (existingUser) return res.status(409).json({ error: "Email already exists", message: "Email already exists", }); - } const [createdUser] = await db.insert(user).values(payload).returning(); - - if (!createdUser) { + if (!createdUser) return res.status(500).json({ error: "Internal server error", message: "Failed to create user", }); - } res.status(201).json({ data: createdUser, @@ -147,12 +140,9 @@ router.put("/:id", async (req, res) => { .json({ error: "User not found", message: "User not found" }); if (email) { - const [existingEmail] = await db - .select({ id: user.id }) - .from(user) - .where(and(eq(user.email, email), ne(user.id, userId))); + const existingEmail = await getUserByEmail(email); - if (existingEmail) + if (existingEmail && existingEmail.id !== userId) return res.status(409).json({ error: "Email already exists", message: "Email already exists", @@ -179,11 +169,10 @@ router.put("/:id", async (req, res) => { .where(eq(user.id, userId)) .returning(); - if (!updatedUser) { + if (!updatedUser) return res .status(404) .json({ error: "User not found", message: "User not found" }); - } res.status(200).json({ data: updatedUser, From b274afedc1c6e9bc0859e308db22536b467d4a19 Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Tue, 30 Dec 2025 15:45:31 +0530 Subject: [PATCH 05/19] feat: authentication middleware --- src/index.ts | 4 +-- src/middleware/auth-middleware.ts | 48 +++++++++++++++++++++++++++++++ src/routes/classes.ts | 37 ++++++++++++++++++++---- src/routes/enrollments.ts | 37 ++++++++++++++++++++---- src/routes/subjects.ts | 31 ++++++++++++++++---- src/routes/users.ts | 3 ++ src/type.d.ts | 15 ++++++++++ 7 files changed, 156 insertions(+), 19 deletions(-) create mode 100644 src/middleware/auth-middleware.ts diff --git a/src/index.ts b/src/index.ts index 49c7651..bfc1d45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,9 +20,9 @@ app.use( }) ); -app.use(express.json()); +app.all("/api/auth/*splat", toNodeHandler(auth)); -app.all("/api/auth/{*any}", toNodeHandler(auth)); +app.use(express.json()); app.use("/api/users", usersRouter); app.use("/api/subjects", subjectsRouter); diff --git a/src/middleware/auth-middleware.ts b/src/middleware/auth-middleware.ts new file mode 100644 index 0000000..9c5f387 --- /dev/null +++ b/src/middleware/auth-middleware.ts @@ -0,0 +1,48 @@ +import type { NextFunction, Request, Response } from "express"; +import { fromNodeHeaders } from "better-auth/node"; + +import { auth } from "../lib/auth"; +import { roleEnum } from "../db/schema/auth"; + +type UserRole = (typeof roleEnum.enumValues)[number]; + +export const authenticate = async ( + req: Request, + res: Response, + next: NextFunction +) => { + try { + const session = await auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }); + + if (!session || !session.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const user = session.user as { id?: string; role?: UserRole }; + res.locals.user = user; + res.locals.session = session.session ?? session; + + return next(); + } catch (error) { + console.error("Auth middleware error:", error); + return res.status(500).json({ error: "Failed to authenticate" }); + } +}; + +export const authorizeRoles = + (...roles: UserRole[]) => + (_req: Request, res: Response, next: NextFunction) => { + const user = res.locals.user as { role?: UserRole } | undefined; + + if (!user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + if (!user.role || !roles.includes(user.role)) { + return res.status(403).json({ error: "Forbidden" }); + } + + return next(); + }; diff --git a/src/routes/classes.ts b/src/routes/classes.ts index 4e8af21..87cd16a 100644 --- a/src/routes/classes.ts +++ b/src/routes/classes.ts @@ -7,6 +7,7 @@ import { getClassById, getClassByInviteCode } from "../controllers/classes"; import { getSubjectById } from "../controllers/subjects"; import { getUserById } from "../controllers/users"; import { parseRequest } from "../lib/validation"; +import { authenticate, authorizeRoles } from "../middleware/auth-middleware"; import { classCreateSchema, classIdParamSchema, @@ -18,7 +19,11 @@ import { const router = express.Router(); // Get all classes with optional filters and pagination -router.get("/", async (req, res) => { +router.get( + "/", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { try { const { search, @@ -90,7 +95,11 @@ router.get("/", async (req, res) => { }); // Get class by invite code -router.get("/invite/:code", async (req, res) => { +router.get( + "/invite/:code", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { try { const { code } = parseRequest(classInviteParamSchema, req.params); @@ -106,7 +115,11 @@ router.get("/invite/:code", async (req, res) => { }); // Get class by ID -router.get("/:id", async (req, res) => { +router.get( + "/:id", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { try { const { id: classId } = parseRequest(classIdParamSchema, req.params); @@ -124,7 +137,11 @@ router.get("/:id", async (req, res) => { }); // Create class -router.post("/", async (req, res) => { +router.post( + "/", + authenticate, + authorizeRoles("admin", "teacher"), + async (req, res) => { try { const { subjectId, @@ -180,7 +197,11 @@ router.post("/", async (req, res) => { }); // Update class -router.put("/:id", async (req, res) => { +router.put( + "/:id", + authenticate, + authorizeRoles("admin", "teacher"), + async (req, res) => { try { const { id: classId } = parseRequest(classIdParamSchema, req.params); const { @@ -246,7 +267,11 @@ router.put("/:id", async (req, res) => { }); // Delete class -router.delete("/:id", async (req, res) => { +router.delete( + "/:id", + authenticate, + authorizeRoles("admin", "teacher"), + async (req, res) => { try { const { id: classId } = parseRequest(classIdParamSchema, req.params); diff --git a/src/routes/enrollments.ts b/src/routes/enrollments.ts index 4c4e1df..946d65e 100644 --- a/src/routes/enrollments.ts +++ b/src/routes/enrollments.ts @@ -7,6 +7,7 @@ import { getClassById, getClassByInviteCode } from "../controllers/classes"; import { getEnrollmentById } from "../controllers/enrollments"; import { getUserById } from "../controllers/users"; import { parseRequest } from "../lib/validation"; +import { authenticate, authorizeRoles } from "../middleware/auth-middleware"; import { enrollmentCreateSchema, enrollmentIdParamSchema, @@ -18,7 +19,11 @@ import { const router = express.Router(); // Get all enrollments with optional filters and pagination -router.get("/", async (req, res) => { +router.get( + "/", + authenticate, + authorizeRoles("admin", "teacher"), + async (req, res) => { try { const { classId, @@ -75,7 +80,11 @@ router.get("/", async (req, res) => { }); // Get enrollment by ID -router.get("/:id", async (req, res) => { +router.get( + "/:id", + authenticate, + authorizeRoles("admin", "teacher"), + async (req, res) => { try { const { id: enrollmentId } = parseRequest( enrollmentIdParamSchema, @@ -95,7 +104,11 @@ router.get("/:id", async (req, res) => { }); // Create enrollment -router.post("/", async (req, res) => { +router.post( + "/", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { try { const { classId, studentId } = parseRequest( enrollmentCreateSchema, @@ -141,7 +154,11 @@ router.post("/", async (req, res) => { }); // Join class by invite code -router.post("/join", async (req, res) => { +router.post( + "/join", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { try { const { inviteCode, studentId } = parseRequest( enrollmentJoinSchema, @@ -188,7 +205,11 @@ router.post("/join", async (req, res) => { }); // Update enrollment -router.put("/:id", async (req, res) => { +router.put( + "/:id", + authenticate, + authorizeRoles("admin", "teacher"), + async (req, res) => { try { const { id: enrollmentId } = parseRequest( enrollmentIdParamSchema, @@ -261,7 +282,11 @@ router.put("/:id", async (req, res) => { }); // Delete enrollment -router.delete("/:id", async (req, res) => { +router.delete( + "/:id", + authenticate, + authorizeRoles("admin", "teacher"), + async (req, res) => { try { const { id: enrollmentId } = parseRequest( enrollmentIdParamSchema, diff --git a/src/routes/subjects.ts b/src/routes/subjects.ts index ca80fd5..3133f05 100644 --- a/src/routes/subjects.ts +++ b/src/routes/subjects.ts @@ -11,11 +11,16 @@ import { import { getSubjectByCode, getSubjectById } from "../controllers/subjects"; import { getDepartmentById } from "../controllers/departments"; import { parseRequest } from "../lib/validation"; +import { authenticate, authorizeRoles } from "../middleware/auth-middleware"; const router = express.Router(); // Get all subjects with optional search, department filter, and pagination -router.get("/", async (req, res) => { +router.get( + "/", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { try { const { search, department, page = 1, limit = 10 } = req.query; @@ -81,7 +86,11 @@ router.get("/", async (req, res) => { }); // Get a single subject by ID -router.get("/:id", async (req, res) => { +router.get( + "/:id", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { try { const { id: subjectId } = parseRequest(subjectIdParamSchema, req.params); @@ -99,7 +108,11 @@ router.get("/:id", async (req, res) => { }); // Create a new subject -router.post("/", async (req, res) => { +router.post( + "/", + authenticate, + authorizeRoles("admin", "teacher"), + async (req, res) => { try { const { departmentId, name, code, description } = parseRequest( subjectCreateSchema, @@ -138,7 +151,11 @@ router.post("/", async (req, res) => { }); // Update a subject by ID -router.put("/:id", async (req, res) => { +router.put( + "/:id", + authenticate, + authorizeRoles("admin", "teacher"), + async (req, res) => { try { const { id: subjectId } = parseRequest(subjectIdParamSchema, req.params); @@ -190,7 +207,11 @@ router.put("/:id", async (req, res) => { }); // Delete a subject by ID -router.delete("/:id", async (req, res) => { +router.delete( + "/:id", + authenticate, + authorizeRoles("admin", "teacher"), + async (req, res) => { try { const { id: subjectId } = parseRequest(subjectIdParamSchema, req.params); diff --git a/src/routes/users.ts b/src/routes/users.ts index 3627c4e..7f31b4e 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -5,6 +5,7 @@ import { db } from "../db"; import { user } from "../db/schema/auth"; import { getUserByEmail, getUserById } from "../controllers/users"; import { parseRequest } from "../lib/validation"; +import { authenticate, authorizeRoles } from "../middleware/auth-middleware"; import { userCreateSchema, userIdParamSchema, @@ -14,6 +15,8 @@ import { const router = express.Router(); +router.use(authenticate, authorizeRoles("admin")); + // Get all users with optional role filter, search by name, and pagination router.get("/", async (req, res) => { try { diff --git a/src/type.d.ts b/src/type.d.ts index 49e9869..2007aee 100644 --- a/src/type.d.ts +++ b/src/type.d.ts @@ -3,3 +3,18 @@ type Schedule = { startTime: string; endTime: string; }; + +declare global { + namespace Express { + interface Locals { + user?: { + id?: string; + role?: "admin" | "teacher" | "student"; + [key: string]: unknown; + }; + session?: unknown; + } + } +} + +export {}; From 526a1e51ab39344af6d8bb80b1b7ab2c251511e2 Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Tue, 30 Dec 2025 15:56:04 +0530 Subject: [PATCH 06/19] feat: department router and authorization --- src/controllers/departments.ts | 9 ++ src/index.ts | 2 + src/routes/classes.ts | 7 - src/routes/departments.ts | 230 +++++++++++++++++++++++++++++++++ src/routes/enrollments.ts | 16 +-- src/validation/departments.ts | 34 +++++ src/validation/enrollments.ts | 2 - 7 files changed, 283 insertions(+), 17 deletions(-) create mode 100644 src/routes/departments.ts create mode 100644 src/validation/departments.ts diff --git a/src/controllers/departments.ts b/src/controllers/departments.ts index d8fb974..e2eabe4 100644 --- a/src/controllers/departments.ts +++ b/src/controllers/departments.ts @@ -11,3 +11,12 @@ export const getDepartmentById = async (departmentId: number) => { return departmentRows[0]; }; + +export const getDepartmentByCode = async (code: string) => { + const departmentRows = await db + .select() + .from(departments) + .where(eq(departments.code, code)); + + return departmentRows[0]; +}; diff --git a/src/index.ts b/src/index.ts index bfc1d45..50e315f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import subjectsRouter from "./routes/subjects"; import usersRouter from "./routes/users"; import classesRouter from "./routes/classes"; import enrollmentsRouter from "./routes/enrollments"; +import departmentsRouter from "./routes/departments"; const app = express(); const PORT = 8000; @@ -28,6 +29,7 @@ app.use("/api/users", usersRouter); app.use("/api/subjects", subjectsRouter); app.use("/api/classes", classesRouter); app.use("/api/enrollments", enrollmentsRouter); +app.use("/api/departments", departmentsRouter); app.get("/", (req, res) => { res.send("Backend server is running!"); diff --git a/src/routes/classes.ts b/src/routes/classes.ts index 87cd16a..65f9ab7 100644 --- a/src/routes/classes.ts +++ b/src/routes/classes.ts @@ -206,7 +206,6 @@ router.put( const { id: classId } = parseRequest(classIdParamSchema, req.params); const { subjectId, - teacherId, inviteCode, bannerCldPubId, bannerUrl, @@ -226,11 +225,6 @@ router.put( if (!subject) return res.status(404).json({ error: "Subject not found" }); } - if (teacherId) { - const teacher = await getUserById(teacherId); - if (!teacher) return res.status(404).json({ error: "Teacher not found" }); - } - if (inviteCode) { const existingInvite = await getClassByInviteCode(inviteCode); @@ -242,7 +236,6 @@ router.put( for (const [key, value] of Object.entries({ subjectId, - teacherId, inviteCode, bannerCldPubId, bannerUrl, diff --git a/src/routes/departments.ts b/src/routes/departments.ts new file mode 100644 index 0000000..1fe2a35 --- /dev/null +++ b/src/routes/departments.ts @@ -0,0 +1,230 @@ +import express from "express"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; + +import { db } from "../db"; +import { departments } from "../db/schema"; +import { + getDepartmentByCode, + getDepartmentById, +} from "../controllers/departments"; +import { parseRequest } from "../lib/validation"; +import { authenticate, authorizeRoles } from "../middleware/auth-middleware"; +import { + departmentCreateSchema, + departmentIdParamSchema, + departmentListQuerySchema, + departmentUpdateSchema, +} from "../validation/departments"; + +const router = express.Router(); + +// Get all departments with optional search and pagination +router.get( + "/", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { + try { + const { search, page = 1, limit = 10 } = parseRequest( + departmentListQuerySchema, + req.query + ); + + const filterConditions = []; + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + if (search) { + filterConditions.push( + or( + ilike(departments.name, `%${search}%`), + ilike(departments.code, `%${search}%`) + ) + ); + } + + const whereClause = + filterConditions.length > 0 ? and(...filterConditions) : undefined; + + const countResult = await db + .select({ count: sql`count(*)` }) + .from(departments) + .where(whereClause); + + const totalCount = countResult[0]?.count ?? 0; + + const departmentList = await db + .select() + .from(departments) + .where(whereClause) + .orderBy(desc(departments.createdAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: departmentList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + }); + } catch (error) { + console.error("GET /departments error:", error); + res.status(500).json({ error: "Failed to fetch departments" }); + } + } +); + +// Get department by ID +router.get( + "/:id", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { + try { + const { id: departmentId } = parseRequest( + departmentIdParamSchema, + req.params + ); + + const department = await getDepartmentById(departmentId); + + if (!department) { + return res.status(404).json({ error: "Department not found" }); + } + + res.status(200).json({ data: department }); + } catch (error) { + console.error("GET /departments/:id error:", error); + res.status(500).json({ error: "Failed to fetch department" }); + } + } +); + +// Create department +router.post( + "/", + authenticate, + authorizeRoles("admin"), + async (req, res) => { + try { + const { name, code, description } = parseRequest( + departmentCreateSchema, + req.body + ); + + const existingDepartment = await getDepartmentByCode(code); + if (existingDepartment) { + return res.status(409).json({ error: "Department code already exists" }); + } + + const [createdDepartment] = await db + .insert(departments) + .values({ + name, + code, + description: description ?? null, + }) + .returning({ id: departments.id }); + + if (!createdDepartment) { + return res.status(500).json({ error: "Failed to create department" }); + } + + const department = await getDepartmentById(createdDepartment.id); + + res.status(201).json({ data: department }); + } catch (error) { + console.error("POST /departments error:", error); + res.status(500).json({ error: "Failed to create department" }); + } + } +); + +// Update department +router.put( + "/:id", + authenticate, + authorizeRoles("admin"), + async (req, res) => { + try { + const { id: departmentId } = parseRequest( + departmentIdParamSchema, + req.params + ); + const { name, code, description } = parseRequest( + departmentUpdateSchema, + req.body + ); + + const existingDepartment = await getDepartmentById(departmentId); + if (!existingDepartment) { + return res.status(404).json({ error: "Department not found" }); + } + + if (code) { + const existingDepartmentWithCode = await getDepartmentByCode(code); + if ( + existingDepartmentWithCode && + existingDepartmentWithCode.id !== departmentId + ) { + return res + .status(409) + .json({ error: "Department code already exists" }); + } + } + + const updateValues: Record = {}; + for (const [key, value] of Object.entries({ name, code, description })) { + if (value) updateValues[key] = value; + } + + await db + .update(departments) + .set(updateValues) + .where(eq(departments.id, departmentId)); + + const department = await getDepartmentById(departmentId); + + res.status(200).json({ data: department }); + } catch (error) { + console.error("PUT /departments/:id error:", error); + res.status(500).json({ error: "Failed to update department" }); + } + } +); + +// Delete department +router.delete( + "/:id", + authenticate, + authorizeRoles("admin"), + async (req, res) => { + try { + const { id: departmentId } = parseRequest( + departmentIdParamSchema, + req.params + ); + + const deletedRows = await db + .delete(departments) + .where(eq(departments.id, departmentId)) + .returning({ id: departments.id }); + + if (deletedRows.length === 0) { + return res.status(404).json({ error: "Department not found" }); + } + + res.status(200).json({ message: "Department deleted" }); + } catch (error) { + console.error("DELETE /departments/:id error:", error); + res.status(500).json({ error: "Failed to delete department" }); + } + } +); + +export default router; diff --git a/src/routes/enrollments.ts b/src/routes/enrollments.ts index 946d65e..ea60cab 100644 --- a/src/routes/enrollments.ts +++ b/src/routes/enrollments.ts @@ -110,10 +110,10 @@ router.post( authorizeRoles("admin", "teacher", "student"), async (req, res) => { try { - const { classId, studentId } = parseRequest( - enrollmentCreateSchema, - req.body - ); + const { classId } = parseRequest(enrollmentCreateSchema, req.body); + const studentId = res.locals.user?.id; + + if (!studentId) return res.status(401).json({ error: "Unauthorized" }); const classRecord = await getClassById(classId); if (!classRecord) return res.status(404).json({ error: "Class not found" }); @@ -160,10 +160,10 @@ router.post( authorizeRoles("admin", "teacher", "student"), async (req, res) => { try { - const { inviteCode, studentId } = parseRequest( - enrollmentJoinSchema, - req.body - ); + const { inviteCode } = parseRequest(enrollmentJoinSchema, req.body); + const studentId = res.locals.user?.id; + + if (!studentId) return res.status(401).json({ error: "Unauthorized" }); const classRecord = await getClassByInviteCode(inviteCode); diff --git a/src/validation/departments.ts b/src/validation/departments.ts new file mode 100644 index 0000000..1f8eab8 --- /dev/null +++ b/src/validation/departments.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +export const departmentIdParamSchema = z + .object({ + id: z.coerce.number().int().positive(), + }) + .strict(); + +export const departmentListQuerySchema = z + .object({ + search: z.string().trim().min(1).optional(), + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }) + .strict(); + +export const departmentCreateSchema = z + .object({ + name: z.string().trim().min(1), + code: z.string().trim().min(1), + description: z.string().trim().optional().nullable(), + }) + .strict(); + +export const departmentUpdateSchema = z + .object({ + name: z.string().trim().min(1).optional(), + code: z.string().trim().min(1).optional(), + description: z.string().trim().optional().nullable(), + }) + .strict() + .refine((data) => Object.values(data).some((value) => value !== undefined), { + message: "At least one field must be provided", + }); diff --git a/src/validation/enrollments.ts b/src/validation/enrollments.ts index 099a440..1fc87d2 100644 --- a/src/validation/enrollments.ts +++ b/src/validation/enrollments.ts @@ -18,14 +18,12 @@ export const enrollmentListQuerySchema = z export const enrollmentCreateSchema = z .object({ classId: z.coerce.number().int().positive(), - studentId: z.string().trim().min(1), }) .strict(); export const enrollmentJoinSchema = z .object({ inviteCode: z.string().trim().min(1), - studentId: z.string().trim().min(1), }) .strict(); From 2dc840d86b79cd2d79739eb6bfb94829a637ab8c Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Tue, 30 Dec 2025 16:02:04 +0530 Subject: [PATCH 07/19] feat: seed functionality --- package.json | 3 +- src/seed/data.json | 181 +++++++++++++++++++++++++++++++++++++++++++++ src/seed/seed.ts | 153 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 src/seed/data.json create mode 100644 src/seed/seed.ts diff --git a/package.json b/package.json index 104c330..db8ff06 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "start": "node dist/server.js", "test": "echo \"Error: no test specified\" && exit 1", "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate" + "db:migrate": "drizzle-kit migrate", + "seed": "tsx src/seed/seed.ts" }, "keywords": [], "author": "", diff --git a/src/seed/data.json b/src/seed/data.json new file mode 100644 index 0000000..1ea2ccf --- /dev/null +++ b/src/seed/data.json @@ -0,0 +1,181 @@ +{ + "departments": [ + { + "code": "CS", + "name": "Computer Science", + "description": "Core computing curriculum" + }, + { + "code": "MATH", + "name": "Mathematics", + "description": "Pure and applied math" + }, + { + "code": "ENG", + "name": "English", + "description": "Literature and composition" + }, + { + "code": "PHY", + "name": "Physics", + "description": "Classical and modern physics" + } + ], + "subjects": [ + { + "code": "CS101", + "name": "Intro to Programming", + "description": "Programming fundamentals", + "departmentCode": "CS" + }, + { + "code": "MATH201", + "name": "Linear Algebra", + "description": "Vectors and matrices", + "departmentCode": "MATH" + }, + { + "code": "ENG110", + "name": "Composition I", + "description": "Academic writing essentials", + "departmentCode": "ENG" + }, + { + "code": "PHY150", + "name": "General Physics", + "description": "Mechanics and thermodynamics", + "departmentCode": "PHY" + } + ], + "users": [ + { + "id": "user_admin_1", + "name": "Admin User", + "email": "admin@classroom.test", + "emailVerified": true, + "role": "admin" + }, + { + "id": "user_teacher_1", + "name": "Teacher One", + "email": "teacher1@classroom.test", + "emailVerified": true, + "role": "teacher" + }, + { + "id": "user_teacher_2", + "name": "Teacher Two", + "email": "teacher2@classroom.test", + "emailVerified": true, + "role": "teacher" + }, + { + "id": "user_student_1", + "name": "Student One", + "email": "student1@classroom.test", + "emailVerified": true, + "role": "student" + }, + { + "id": "user_student_2", + "name": "Student Two", + "email": "student2@classroom.test", + "emailVerified": true, + "role": "student" + } + ], + "classes": [ + { + "name": "CS101 - Section A", + "inviteCode": "CS101A", + "subjectCode": "CS101", + "teacherId": "user_teacher_1", + "description": "Morning section", + "capacity": 35, + "status": "active", + "schedules": [ + { + "day": "Mon", + "startTime": "09:00", + "endTime": "10:30" + }, + { + "day": "Wed", + "startTime": "09:00", + "endTime": "10:30" + } + ] + }, + { + "name": "CS101 - Section B", + "inviteCode": "CS101B", + "subjectCode": "CS101", + "teacherId": "user_teacher_2", + "description": "Afternoon section", + "capacity": 40, + "status": "active", + "schedules": [ + { + "day": "Tue", + "startTime": "14:00", + "endTime": "15:30" + }, + { + "day": "Thu", + "startTime": "14:00", + "endTime": "15:30" + } + ] + }, + { + "name": "MATH201 - Section A", + "inviteCode": "M201A", + "subjectCode": "MATH201", + "teacherId": "user_teacher_1", + "description": "Evening section", + "capacity": 30, + "status": "active", + "schedules": [ + { + "day": "Mon", + "startTime": "17:00", + "endTime": "18:30" + } + ] + }, + { + "name": "ENG110 - Section A", + "inviteCode": "ENG110A", + "subjectCode": "ENG110", + "teacherId": "user_teacher_2", + "description": "Writing workshop", + "capacity": 25, + "status": "active", + "schedules": [ + { + "day": "Wed", + "startTime": "11:00", + "endTime": "12:30" + } + ] + } + ], + "enrollments": [ + { + "classInviteCode": "CS101A", + "studentId": "user_student_1" + }, + { + "classInviteCode": "CS101B", + "studentId": "user_student_2" + }, + { + "classInviteCode": "M201A", + "studentId": "user_student_1" + }, + { + "classInviteCode": "ENG110A", + "studentId": "user_student_2" + } + ] +} diff --git a/src/seed/seed.ts b/src/seed/seed.ts new file mode 100644 index 0000000..9c1ca96 --- /dev/null +++ b/src/seed/seed.ts @@ -0,0 +1,153 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { db } from "../db"; +import { departments, subjects, classes, enrollments } from "../db/schema"; +import { user } from "../db/schema/auth"; + +type SeedData = { + departments: Array<{ + code: string; + name: string; + description?: string | null; + }>; + subjects: Array<{ + code: string; + name: string; + description?: string | null; + departmentCode: string; + }>; + users: Array<{ + id: string; + name: string; + email: string; + emailVerified?: boolean; + image?: string | null; + imageCldPubId?: string | null; + role?: "admin" | "teacher" | "student"; + }>; + classes: Array<{ + name: string; + inviteCode: string; + subjectCode: string; + teacherId: string; + description?: string | null; + bannerUrl?: string | null; + bannerCldPubId?: string | null; + capacity?: number; + status?: "active" | "inactive" | "archived"; + schedules?: Array<{ + day: string; + startTime: string; + endTime: string; + }>; + }>; + enrollments: Array<{ + classInviteCode: string; + studentId: string; + }>; +}; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const loadSeedData = async (): Promise => { + const filePath = path.join(__dirname, "data.json"); + const fileContents = await readFile(filePath, "utf8"); + return JSON.parse(fileContents) as SeedData; +}; + +const seed = async () => { + const data = await loadSeedData(); + + await db.delete(enrollments); + await db.delete(classes); + await db.delete(subjects); + await db.delete(departments); + await db.delete(user); + + const departmentRows = await db + .insert(departments) + .values( + data.departments.map((department) => ({ + code: department.code, + name: department.name, + description: department.description ?? null, + })) + ) + .returning({ id: departments.id, code: departments.code }); + + const departmentIdByCode = new Map( + departmentRows.map((department) => [department.code, department.id]) + ); + + const subjectRows = await db + .insert(subjects) + .values( + data.subjects.map((subject) => ({ + code: subject.code, + name: subject.name, + description: subject.description ?? null, + departmentId: departmentIdByCode.get(subject.departmentCode)!, + })) + ) + .returning({ id: subjects.id, code: subjects.code }); + + const subjectIdByCode = new Map( + subjectRows.map((subject) => [subject.code, subject.id]) + ); + + await db + .insert(user) + .values( + data.users.map((seedUser) => ({ + id: seedUser.id, + name: seedUser.name, + email: seedUser.email, + emailVerified: seedUser.emailVerified ?? false, + image: seedUser.image ?? null, + imageCldPubId: seedUser.imageCldPubId ?? null, + role: seedUser.role ?? "student", + })) + ) + .returning({ id: user.id }); + + const classRows = await db + .insert(classes) + .values( + data.classes.map((classItem) => ({ + name: classItem.name, + inviteCode: classItem.inviteCode, + subjectId: subjectIdByCode.get(classItem.subjectCode)!, + teacherId: classItem.teacherId, + description: classItem.description ?? null, + bannerUrl: classItem.bannerUrl ?? null, + bannerCldPubId: classItem.bannerCldPubId ?? null, + capacity: classItem.capacity ?? 50, + status: classItem.status ?? "active", + schedules: classItem.schedules ?? [], + })) + ) + .returning({ id: classes.id, inviteCode: classes.inviteCode }); + + const classIdByInvite = new Map( + classRows.map((classItem) => [classItem.inviteCode, classItem.id]) + ); + + if (data.enrollments.length > 0) { + await db.insert(enrollments).values( + data.enrollments.map((enrollment) => ({ + classId: classIdByInvite.get(enrollment.classInviteCode)!, + studentId: enrollment.studentId, + })) + ); + } + + console.log("Seed completed."); +}; + +seed().catch((error) => { + console.error("Seed failed:", error); + process.exitCode = 1; +}); From 2edc326bb3c3de8a2a7d1b3f5ac9438542e15345 Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Tue, 30 Dec 2025 17:50:18 +0530 Subject: [PATCH 08/19] update README --- README.md | 490 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 490 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..a14096f --- /dev/null +++ b/README.md @@ -0,0 +1,490 @@ +# Classroom Backend API + +Production-ready API for departments, subjects, classes, enrollments, and users with Better Auth. + +## Contents +- Setup +- Auth and Roles +- Common Conventions +- Endpoints +- Seeding + +## Setup + +### Requirements +- Node.js 18+ +- Postgres (or Neon) connection string + +### Environment +Create `.env`: +``` +DATABASE_URL=postgres://user:password@host:port/db +BETTER_AUTH_SECRET=your_secret_here +FRONTEND_URL=http://localhost:3000 +``` + +### Install and Run +``` +npm install +npm run dev +``` + +### Base URL +``` +http://localhost:8000 +``` + +## Auth and Roles + +### Better Auth +Mounted at `/api/auth/*`. Use cookie-based auth. Store cookies after sign-in and pass them on subsequent requests. + +Important: +- Better Auth handler is mounted before `express.json()`. +- Use `-c` and `-b` in curl to persist cookies. + +### Role Access +- admin: full access +- teacher: read all; write subjects/classes/enrollments +- student: read all; write enrollments only + +Role matrix: +- `/api/users`: admin only +- `/api/departments`: admin write; all roles read +- `/api/subjects`: admin/teacher write; all roles read +- `/api/classes`: admin/teacher write; all roles read +- `/api/enrollments`: admin/teacher read/write; students create/join only + +## Common Conventions + +### Headers +Use JSON for request bodies: +``` +Content-Type: application/json +``` + +### Pagination +List endpoints support: +- `page` (default: 1) +- `limit` (default: 10) + +Response format: +``` +{ + "data": [], + "pagination": { + "page": 1, + "limit": 10, + "total": 0, + "totalPages": 0 + } +} +``` + +### Errors +``` +{ "error": "Message" } +``` + +### Authentication in curl +Sign in and store cookies: +``` +curl -X POST http://localhost:8000/api/auth/sign-in/email \ + -H "Content-Type: application/json" \ + -d '{ "email": "teacher1@classroom.test", "password": "Password123!" }' \ + -c cookie.txt +``` +Use cookies on protected endpoints: +``` +curl http://localhost:8000/api/subjects -b cookie.txt +``` + +## Endpoints + +### Auth (Better Auth) + +#### POST /api/auth/sign-up/email +Body: +``` +{ + "email": "teacher1@classroom.test", + "password": "Password123!", + "name": "Teacher One", + "role": "teacher", + "imageCldPubId": "demo-image" +} +``` +Example: +``` +curl -X POST http://localhost:8000/api/auth/sign-up/email \ + -H "Content-Type: application/json" \ + -d '{ + "email": "teacher1@classroom.test", + "password": "Password123!", + "name": "Teacher One", + "role": "teacher" + }' +``` + +#### POST /api/auth/sign-in/email +Body: +``` +{ + "email": "teacher1@classroom.test", + "password": "Password123!" +} +``` +Example: +``` +curl -X POST http://localhost:8000/api/auth/sign-in/email \ + -H "Content-Type: application/json" \ + -d '{ "email": "teacher1@classroom.test", "password": "Password123!" }' \ + -c cookie.txt +``` + +#### GET /api/auth/get-session +Example: +``` +curl http://localhost:8000/api/auth/get-session -b cookie.txt +``` + +#### POST /api/auth/sign-out +Example: +``` +curl -X POST http://localhost:8000/api/auth/sign-out -b cookie.txt +``` + +Other auth endpoints (if enabled in Better Auth): +- `/api/auth/list-sessions` (GET) +- `/api/auth/revoke-session` (POST) +- `/api/auth/revoke-sessions` (POST) +- `/api/auth/revoke-other-sessions` (POST) +- `/api/auth/account-info` (GET) +- `/api/auth/list-accounts` (GET) +- `/api/auth/refresh-token` (POST) +- `/api/auth/get-access-token` (POST) +- `/api/auth/send-verification-email` (POST) +- `/api/auth/verify-email` (GET) +- `/api/auth/request-password-reset` (POST) +- `/api/auth/reset-password/:token` (GET) +- `/api/auth/reset-password` (POST) +- `/api/auth/change-password` (POST) +- `/api/auth/change-email` (POST) +- `/api/auth/update-user` (POST) +- `/api/auth/delete-user` (POST) + +### Users (Admin Only) + +#### GET /api/users +Query: +- `role`: admin | teacher | student +- `search`: name search +- `page`, `limit` + +Example: +``` +curl "http://localhost:8000/api/users?role=teacher&page=1&limit=10" -b cookie.txt +``` + +#### GET /api/users/:id +Example: +``` +curl http://localhost:8000/api/users/user_teacher_1 -b cookie.txt +``` + +#### POST /api/users +Body: +``` +{ + "id": "user_teacher_3", + "name": "Teacher Three", + "email": "teacher3@classroom.test", + "emailVerified": true, + "role": "teacher" +} +``` +Example: +``` +curl -X POST http://localhost:8000/api/users \ + -H "Content-Type: application/json" \ + -d '{ "id": "user_teacher_3", "name": "Teacher Three", "email": "teacher3@classroom.test", "role": "teacher" }' \ + -b cookie.txt +``` + +#### PUT /api/users/:id +Body: +``` +{ "name": "Teacher One Updated" } +``` +Example: +``` +curl -X PUT http://localhost:8000/api/users/user_teacher_1 \ + -H "Content-Type: application/json" \ + -d '{ "name": "Teacher One Updated" }' \ + -b cookie.txt +``` + +#### DELETE /api/users/:id +Example: +``` +curl -X DELETE http://localhost:8000/api/users/user_teacher_1 -b cookie.txt +``` + +### Departments + +#### GET /api/departments +Query: +- `search` +- `page`, `limit` + +Example: +``` +curl "http://localhost:8000/api/departments?search=CS" -b cookie.txt +``` + +#### GET /api/departments/:id +Example: +``` +curl http://localhost:8000/api/departments/1 -b cookie.txt +``` + +#### POST /api/departments (admin) +Body: +``` +{ + "code": "BIO", + "name": "Biology", + "description": "Life sciences" +} +``` +Example: +``` +curl -X POST http://localhost:8000/api/departments \ + -H "Content-Type: application/json" \ + -d '{ "code": "BIO", "name": "Biology", "description": "Life sciences" }' \ + -b cookie.txt +``` + +#### PUT /api/departments/:id (admin) +Body: +``` +{ "name": "Computer Science and Engineering" } +``` +Example: +``` +curl -X PUT http://localhost:8000/api/departments/1 \ + -H "Content-Type: application/json" \ + -d '{ "name": "Computer Science and Engineering" }' \ + -b cookie.txt +``` + +#### DELETE /api/departments/:id (admin) +Example: +``` +curl -X DELETE http://localhost:8000/api/departments/1 -b cookie.txt +``` + +### Subjects + +#### GET /api/subjects +Query: +- `search` (name/code) +- `department` (department name search) +- `page`, `limit` + +Example: +``` +curl "http://localhost:8000/api/subjects?search=CS&page=1&limit=10" -b cookie.txt +``` + +#### GET /api/subjects/:id +Example: +``` +curl http://localhost:8000/api/subjects/1 -b cookie.txt +``` + +#### POST /api/subjects (admin/teacher) +Body: +``` +{ + "departmentId": 1, + "name": "Data Structures", + "code": "CS210", + "description": "Core data structures" +} +``` +Example: +``` +curl -X POST http://localhost:8000/api/subjects \ + -H "Content-Type: application/json" \ + -d '{ "departmentId": 1, "name": "Data Structures", "code": "CS210" }' \ + -b cookie.txt +``` + +#### PUT /api/subjects/:id (admin/teacher) +Body: +``` +{ "name": "Intro to Programming Updated" } +``` +Example: +``` +curl -X PUT http://localhost:8000/api/subjects/1 \ + -H "Content-Type: application/json" \ + -d '{ "name": "Intro to Programming Updated" }' \ + -b cookie.txt +``` + +#### DELETE /api/subjects/:id (admin/teacher) +Example: +``` +curl -X DELETE http://localhost:8000/api/subjects/1 -b cookie.txt +``` + +### Classes + +#### GET /api/classes +Query: +- `search` (name/inviteCode) +- `subjectId` +- `teacherId` +- `status`: active | inactive | archived +- `page`, `limit` + +Example: +``` +curl "http://localhost:8000/api/classes?subjectId=1&page=1&limit=10" -b cookie.txt +``` + +#### GET /api/classes/invite/:code +Example: +``` +curl http://localhost:8000/api/classes/invite/CS101A -b cookie.txt +``` + +#### GET /api/classes/:id +Example: +``` +curl http://localhost:8000/api/classes/1 -b cookie.txt +``` + +#### POST /api/classes (admin/teacher) +Body: +``` +{ + "name": "CS101 - Section C", + "inviteCode": "CS101C", + "subjectId": 1, + "teacherId": "user_teacher_1", + "description": "New section", + "capacity": 30, + "status": "active", + "schedules": [ + { "day": "Tue", "startTime": "10:00", "endTime": "11:30" } + ] +} +``` +Example: +``` +curl -X POST http://localhost:8000/api/classes \ + -H "Content-Type: application/json" \ + -d '{ "name": "CS101 - Section C", "inviteCode": "CS101C", "subjectId": 1, "teacherId": "user_teacher_1" }' \ + -b cookie.txt +``` + +#### PUT /api/classes/:id (admin/teacher) +Body: +``` +{ "name": "CS101 - Section A Updated" } +``` +Example: +``` +curl -X PUT http://localhost:8000/api/classes/1 \ + -H "Content-Type: application/json" \ + -d '{ "name": "CS101 - Section A Updated" }' \ + -b cookie.txt +``` + +#### DELETE /api/classes/:id (admin/teacher) +Example: +``` +curl -X DELETE http://localhost:8000/api/classes/1 -b cookie.txt +``` + +### Enrollments + +#### GET /api/enrollments (admin/teacher) +Query: +- `classId` +- `studentId` +- `page`, `limit` + +Example: +``` +curl "http://localhost:8000/api/enrollments?classId=1" -b cookie.txt +``` + +#### GET /api/enrollments/:id (admin/teacher) +Example: +``` +curl http://localhost:8000/api/enrollments/1 -b cookie.txt +``` + +#### POST /api/enrollments (admin/teacher/student) +Body: +``` +{ "classId": 1 } +``` +Note: `studentId` is taken from the authenticated user. + +Example: +``` +curl -X POST http://localhost:8000/api/enrollments \ + -H "Content-Type: application/json" \ + -d '{ "classId": 1 }' \ + -b cookie.txt +``` + +#### POST /api/enrollments/join (admin/teacher/student) +Body: +``` +{ "inviteCode": "CS101A" } +``` +Note: `studentId` is taken from the authenticated user. + +Example: +``` +curl -X POST http://localhost:8000/api/enrollments/join \ + -H "Content-Type: application/json" \ + -d '{ "inviteCode": "CS101A" }' \ + -b cookie.txt +``` + +#### PUT /api/enrollments/:id (admin/teacher) +Body: +``` +{ "classId": 2 } +``` +Example: +``` +curl -X PUT http://localhost:8000/api/enrollments/1 \ + -H "Content-Type: application/json" \ + -d '{ "classId": 2 }' \ + -b cookie.txt +``` + +#### DELETE /api/enrollments/:id (admin/teacher) +Example: +``` +curl -X DELETE http://localhost:8000/api/enrollments/1 -b cookie.txt +``` + +## Seeding +Seed demo data: +``` +npm run seed +``` + +Data file: +``` +src/seed/data.json +``` From 9900751354f9597e9972e0af5a721d3331c3b53d Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Tue, 30 Dec 2025 18:14:55 +0530 Subject: [PATCH 09/19] chore: drizzle generation and auth fix --- drizzle/0000_rainy_inertia.sql | 22 - drizzle/0000_tidy_mentor.sql | 57 +++ drizzle/0001_goofy_nehzno.sql | 56 ++ drizzle/meta/0000_snapshot.json | 323 +++++++++++- drizzle/meta/0001_snapshot.json | 878 ++++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 11 +- package.json | 1 + src/db/schema/app.ts | 12 +- src/db/schema/index.ts | 1 + src/lib/auth.ts | 2 + src/type.d.ts | 24 +- 11 files changed, 1341 insertions(+), 46 deletions(-) delete mode 100644 drizzle/0000_rainy_inertia.sql create mode 100644 drizzle/0000_tidy_mentor.sql create mode 100644 drizzle/0001_goofy_nehzno.sql create mode 100644 drizzle/meta/0001_snapshot.json diff --git a/drizzle/0000_rainy_inertia.sql b/drizzle/0000_rainy_inertia.sql deleted file mode 100644 index 0545dc7..0000000 --- a/drizzle/0000_rainy_inertia.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE "departments" ( - "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "departments_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), - "code" varchar(50) NOT NULL, - "name" varchar(255) NOT NULL, - "description" text, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "departments_code_unique" UNIQUE("code") -); ---> statement-breakpoint -CREATE TABLE "subjects" ( - "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "subjects_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), - "department_id" integer NOT NULL, - "name" varchar(255) NOT NULL, - "code" varchar(50) NOT NULL, - "description" text, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "subjects_code_unique" UNIQUE("code") -); ---> statement-breakpoint -ALTER TABLE "subjects" ADD CONSTRAINT "subjects_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE restrict ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/0000_tidy_mentor.sql b/drizzle/0000_tidy_mentor.sql new file mode 100644 index 0000000..3eb417a --- /dev/null +++ b/drizzle/0000_tidy_mentor.sql @@ -0,0 +1,57 @@ +CREATE TYPE "public"."class_status" AS ENUM('active', 'inactive', 'archived');--> statement-breakpoint +CREATE TABLE "classes" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "classes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "name" varchar(255) NOT NULL, + "invite_code" varchar(20) NOT NULL, + "subject_id" integer NOT NULL, + "teacher_id" text NOT NULL, + "description" text, + "banner_url" text, + "imageCldPubId" text, + "capacity" integer DEFAULT 50, + "status" "class_status" DEFAULT 'active' NOT NULL, + "schedules" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "classes_invite_code_unique" UNIQUE("invite_code") +); +--> statement-breakpoint +CREATE TABLE "departments" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "departments_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "code" varchar(50) NOT NULL, + "name" varchar(255) NOT NULL, + "description" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "departments_code_unique" UNIQUE("code") +); +--> statement-breakpoint +CREATE TABLE "enrollments" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "enrollments_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "student_id" text NOT NULL, + "class_id" integer NOT NULL, + "enrolled_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "subjects" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "subjects_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "department_id" integer NOT NULL, + "name" varchar(255) NOT NULL, + "code" varchar(50) NOT NULL, + "description" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "subjects_code_unique" UNIQUE("code") +); +--> statement-breakpoint +ALTER TABLE "classes" ADD CONSTRAINT "classes_subject_id_subjects_id_fk" FOREIGN KEY ("subject_id") REFERENCES "public"."subjects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "classes" ADD CONSTRAINT "classes_teacher_id_user_id_fk" FOREIGN KEY ("teacher_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "enrollments" ADD CONSTRAINT "enrollments_student_id_user_id_fk" FOREIGN KEY ("student_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "enrollments" ADD CONSTRAINT "enrollments_class_id_classes_id_fk" FOREIGN KEY ("class_id") REFERENCES "public"."classes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "subjects" ADD CONSTRAINT "subjects_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "classes_subject_id_idx" ON "classes" USING btree ("subject_id");--> statement-breakpoint +CREATE INDEX "classes_teacher_id_idx" ON "classes" USING btree ("teacher_id");--> statement-breakpoint +CREATE INDEX "enrollments_class_id_idx" ON "enrollments" USING btree ("class_id");--> statement-breakpoint +CREATE INDEX "enrollments_student_id_idx" ON "enrollments" USING btree ("student_id");--> statement-breakpoint +CREATE UNIQUE INDEX "enrollments_student_class_unq" ON "enrollments" USING btree ("student_id","class_id"); \ No newline at end of file diff --git a/drizzle/0001_goofy_nehzno.sql b/drizzle/0001_goofy_nehzno.sql new file mode 100644 index 0000000..2024f4c --- /dev/null +++ b/drizzle/0001_goofy_nehzno.sql @@ -0,0 +1,56 @@ +CREATE TYPE "public"."role" AS ENUM('admin', 'teacher', 'student');--> statement-breakpoint +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "password" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp NOT NULL, + "token" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "imageCldPubId" text, + "role" "role" DEFAULT 'student' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier"); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 5e175d9..3400c7a 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,9 +1,183 @@ { - "id": "3db9c73c-ea2e-4df9-95bb-16127ddfc5ce", + "id": "24f3a1d5-fecb-4872-ab75-df89b3434660", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", "tables": { + "public.classes": { + "name": "classes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "classes_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "invite_code": { + "name": "invite_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "teacher_id": { + "name": "teacher_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imageCldPubId": { + "name": "imageCldPubId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 50 + }, + "status": { + "name": "status", + "type": "class_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "schedules": { + "name": "schedules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "classes_subject_id_idx": { + "name": "classes_subject_id_idx", + "columns": [ + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "classes_teacher_id_idx": { + "name": "classes_teacher_id_idx", + "columns": [ + { + "expression": "teacher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "classes_subject_id_subjects_id_fk": { + "name": "classes_subject_id_subjects_id_fk", + "tableFrom": "classes", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "classes_teacher_id_user_id_fk": { + "name": "classes_teacher_id_user_id_fk", + "tableFrom": "classes", + "tableTo": "user", + "columnsFrom": [ + "teacher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "classes_invite_code_unique": { + "name": "classes_invite_code_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.departments": { "name": "departments", "schema": "", @@ -74,6 +248,141 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.enrollments": { + "name": "enrollments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "enrollments_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "student_id": { + "name": "student_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "class_id": { + "name": "class_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "enrollments_class_id_idx": { + "name": "enrollments_class_id_idx", + "columns": [ + { + "expression": "class_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "enrollments_student_id_idx": { + "name": "enrollments_student_id_idx", + "columns": [ + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "enrollments_student_class_unq": { + "name": "enrollments_student_class_unq", + "columns": [ + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "class_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "enrollments_student_id_user_id_fk": { + "name": "enrollments_student_id_user_id_fk", + "tableFrom": "enrollments", + "tableTo": "user", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "enrollments_class_id_classes_id_fk": { + "name": "enrollments_class_id_classes_id_fk", + "tableFrom": "enrollments", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.subjects": { "name": "subjects", "schema": "", @@ -165,7 +474,17 @@ "isRLSEnabled": false } }, - "enums": {}, + "enums": { + "public.class_status": { + "name": "class_status", + "schema": "public", + "values": [ + "active", + "inactive", + "archived" + ] + } + }, "schemas": {}, "sequences": {}, "roles": {}, diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..b774db0 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,878 @@ +{ + "id": "d0773d01-8abf-4db0-873e-88183aa6d7f0", + "prevId": "24f3a1d5-fecb-4872-ab75-df89b3434660", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.classes": { + "name": "classes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "classes_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "invite_code": { + "name": "invite_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "teacher_id": { + "name": "teacher_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imageCldPubId": { + "name": "imageCldPubId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 50 + }, + "status": { + "name": "status", + "type": "class_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "schedules": { + "name": "schedules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "classes_subject_id_idx": { + "name": "classes_subject_id_idx", + "columns": [ + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "classes_teacher_id_idx": { + "name": "classes_teacher_id_idx", + "columns": [ + { + "expression": "teacher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "classes_subject_id_subjects_id_fk": { + "name": "classes_subject_id_subjects_id_fk", + "tableFrom": "classes", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "classes_teacher_id_user_id_fk": { + "name": "classes_teacher_id_user_id_fk", + "tableFrom": "classes", + "tableTo": "user", + "columnsFrom": [ + "teacher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "classes_invite_code_unique": { + "name": "classes_invite_code_unique", + "nullsNotDistinct": false, + "columns": [ + "invite_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.departments": { + "name": "departments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "departments_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "code": { + "name": "code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "departments_code_unique": { + "name": "departments_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enrollments": { + "name": "enrollments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "enrollments_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "student_id": { + "name": "student_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "class_id": { + "name": "class_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "enrollments_class_id_idx": { + "name": "enrollments_class_id_idx", + "columns": [ + { + "expression": "class_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "enrollments_student_id_idx": { + "name": "enrollments_student_id_idx", + "columns": [ + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "enrollments_student_class_unq": { + "name": "enrollments_student_class_unq", + "columns": [ + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "class_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "enrollments_student_id_user_id_fk": { + "name": "enrollments_student_id_user_id_fk", + "tableFrom": "enrollments", + "tableTo": "user", + "columnsFrom": [ + "student_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "enrollments_class_id_classes_id_fk": { + "name": "enrollments_class_id_classes_id_fk", + "tableFrom": "enrollments", + "tableTo": "classes", + "columnsFrom": [ + "class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subjects": { + "name": "subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "subjects_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "department_id": { + "name": "department_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "subjects_department_id_departments_id_fk": { + "name": "subjects_department_id_departments_id_fk", + "tableFrom": "subjects", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subjects_code_unique": { + "name": "subjects_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "imageCldPubId": { + "name": "imageCldPubId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'student'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.class_status": { + "name": "class_status", + "schema": "public", + "values": [ + "active", + "inactive", + "archived" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "teacher", + "student" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ce05aff..d9bf635 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,15 @@ { "idx": 0, "version": "7", - "when": 1765822907774, - "tag": "0000_rainy_inertia", + "when": 1767098070770, + "tag": "0000_tidy_mentor", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1767098118358, + "tag": "0001_goofy_nehzno", "breakpoints": true } ] diff --git a/package.json b/package.json index db8ff06..e38b644 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "echo \"Error: no test specified\" && exit 1", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", + "db:push": "drizzle-kit push", "seed": "tsx src/seed/seed.ts" }, "keywords": [], diff --git a/src/db/schema/app.ts b/src/db/schema/app.ts index 73b3275..9e8eba6 100644 --- a/src/db/schema/app.ts +++ b/src/db/schema/app.ts @@ -13,6 +13,12 @@ import { import { user } from "./auth"; +export const classStatusEnum = pgEnum("class_status", [ + "active", + "inactive", + "archived", +]); + const timestamps = { createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") @@ -44,12 +50,6 @@ export const subjects = pgTable("subjects", { ...timestamps, }); -export const classStatusEnum = pgEnum("class_status", [ - "active", - "inactive", - "archived", -]); - export const classes = pgTable( "classes", { diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index ac5307d..2002587 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1 +1,2 @@ export * from "./app"; +export * from "./auth"; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d8ce76f..e76d1c3 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,12 +2,14 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { db } from "../db"; +import * as schema from "../db/schema/auth"; export const auth = betterAuth({ secret: process.env.BETTER_AUTH_SECRET!, trustedOrigins: [process.env.FRONTEND_URL!], database: drizzleAdapter(db, { provider: "pg", + schema, }), emailAndPassword: { enabled: true, diff --git a/src/type.d.ts b/src/type.d.ts index 2007aee..3c97135 100644 --- a/src/type.d.ts +++ b/src/type.d.ts @@ -1,20 +1,16 @@ -type Schedule = { +interface Schedule { day: string; startTime: string; endTime: string; -}; +} -declare global { - namespace Express { - interface Locals { - user?: { - id?: string; - role?: "admin" | "teacher" | "student"; - [key: string]: unknown; - }; - session?: unknown; - } +declare namespace Express { + interface Locals { + user?: { + id?: string; + role?: "admin" | "teacher" | "student"; + [key: string]: unknown; + }; + session?: unknown; } } - -export {}; From ecf71e308e61514608a71c55fcd222677efacb99 Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Wed, 31 Dec 2025 09:17:27 +0530 Subject: [PATCH 10/19] update README --- README.md | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/README.md b/README.md index a14096f..9a6146a 100644 --- a/README.md +++ b/README.md @@ -3,20 +3,25 @@ Production-ready API for departments, subjects, classes, enrollments, and users with Better Auth. ## Contents + - Setup - Auth and Roles - Common Conventions - Endpoints - Seeding +- Video Flow Context ## Setup ### Requirements + - Node.js 18+ - Postgres (or Neon) connection string ### Environment + Create `.env`: + ``` DATABASE_URL=postgres://user:password@host:port/db BETTER_AUTH_SECRET=your_secret_here @@ -24,12 +29,14 @@ FRONTEND_URL=http://localhost:3000 ``` ### Install and Run + ``` npm install npm run dev ``` ### Base URL + ``` http://localhost:8000 ``` @@ -37,18 +44,22 @@ http://localhost:8000 ## Auth and Roles ### Better Auth + Mounted at `/api/auth/*`. Use cookie-based auth. Store cookies after sign-in and pass them on subsequent requests. Important: + - Better Auth handler is mounted before `express.json()`. - Use `-c` and `-b` in curl to persist cookies. ### Role Access + - admin: full access - teacher: read all; write subjects/classes/enrollments - student: read all; write enrollments only Role matrix: + - `/api/users`: admin only - `/api/departments`: admin write; all roles read - `/api/subjects`: admin/teacher write; all roles read @@ -58,17 +69,22 @@ Role matrix: ## Common Conventions ### Headers + Use JSON for request bodies: + ``` Content-Type: application/json ``` ### Pagination + List endpoints support: + - `page` (default: 1) - `limit` (default: 10) Response format: + ``` { "data": [], @@ -82,19 +98,24 @@ Response format: ``` ### Errors + ``` { "error": "Message" } ``` ### Authentication in curl + Sign in and store cookies: + ``` curl -X POST http://localhost:8000/api/auth/sign-in/email \ -H "Content-Type: application/json" \ -d '{ "email": "teacher1@classroom.test", "password": "Password123!" }' \ -c cookie.txt ``` + Use cookies on protected endpoints: + ``` curl http://localhost:8000/api/subjects -b cookie.txt ``` @@ -104,7 +125,9 @@ curl http://localhost:8000/api/subjects -b cookie.txt ### Auth (Better Auth) #### POST /api/auth/sign-up/email + Body: + ``` { "email": "teacher1@classroom.test", @@ -114,7 +137,9 @@ Body: "imageCldPubId": "demo-image" } ``` + Example: + ``` curl -X POST http://localhost:8000/api/auth/sign-up/email \ -H "Content-Type: application/json" \ @@ -127,14 +152,18 @@ curl -X POST http://localhost:8000/api/auth/sign-up/email \ ``` #### POST /api/auth/sign-in/email + Body: + ``` { "email": "teacher1@classroom.test", "password": "Password123!" } ``` + Example: + ``` curl -X POST http://localhost:8000/api/auth/sign-in/email \ -H "Content-Type: application/json" \ @@ -143,18 +172,23 @@ curl -X POST http://localhost:8000/api/auth/sign-in/email \ ``` #### GET /api/auth/get-session + Example: + ``` curl http://localhost:8000/api/auth/get-session -b cookie.txt ``` #### POST /api/auth/sign-out + Example: + ``` curl -X POST http://localhost:8000/api/auth/sign-out -b cookie.txt ``` Other auth endpoints (if enabled in Better Auth): + - `/api/auth/list-sessions` (GET) - `/api/auth/revoke-session` (POST) - `/api/auth/revoke-sessions` (POST) @@ -176,24 +210,31 @@ Other auth endpoints (if enabled in Better Auth): ### Users (Admin Only) #### GET /api/users + Query: + - `role`: admin | teacher | student - `search`: name search - `page`, `limit` Example: + ``` curl "http://localhost:8000/api/users?role=teacher&page=1&limit=10" -b cookie.txt ``` #### GET /api/users/:id + Example: + ``` curl http://localhost:8000/api/users/user_teacher_1 -b cookie.txt ``` #### POST /api/users + Body: + ``` { "id": "user_teacher_3", @@ -203,7 +244,9 @@ Body: "role": "teacher" } ``` + Example: + ``` curl -X POST http://localhost:8000/api/users \ -H "Content-Type: application/json" \ @@ -212,11 +255,15 @@ curl -X POST http://localhost:8000/api/users \ ``` #### PUT /api/users/:id + Body: + ``` { "name": "Teacher One Updated" } ``` + Example: + ``` curl -X PUT http://localhost:8000/api/users/user_teacher_1 \ -H "Content-Type: application/json" \ @@ -225,7 +272,9 @@ curl -X PUT http://localhost:8000/api/users/user_teacher_1 \ ``` #### DELETE /api/users/:id + Example: + ``` curl -X DELETE http://localhost:8000/api/users/user_teacher_1 -b cookie.txt ``` @@ -233,23 +282,30 @@ curl -X DELETE http://localhost:8000/api/users/user_teacher_1 -b cookie.txt ### Departments #### GET /api/departments + Query: + - `search` - `page`, `limit` Example: + ``` curl "http://localhost:8000/api/departments?search=CS" -b cookie.txt ``` #### GET /api/departments/:id + Example: + ``` curl http://localhost:8000/api/departments/1 -b cookie.txt ``` #### POST /api/departments (admin) + Body: + ``` { "code": "BIO", @@ -257,7 +313,9 @@ Body: "description": "Life sciences" } ``` + Example: + ``` curl -X POST http://localhost:8000/api/departments \ -H "Content-Type: application/json" \ @@ -266,11 +324,15 @@ curl -X POST http://localhost:8000/api/departments \ ``` #### PUT /api/departments/:id (admin) + Body: + ``` { "name": "Computer Science and Engineering" } ``` + Example: + ``` curl -X PUT http://localhost:8000/api/departments/1 \ -H "Content-Type: application/json" \ @@ -279,7 +341,9 @@ curl -X PUT http://localhost:8000/api/departments/1 \ ``` #### DELETE /api/departments/:id (admin) + Example: + ``` curl -X DELETE http://localhost:8000/api/departments/1 -b cookie.txt ``` @@ -287,24 +351,31 @@ curl -X DELETE http://localhost:8000/api/departments/1 -b cookie.txt ### Subjects #### GET /api/subjects + Query: + - `search` (name/code) - `department` (department name search) - `page`, `limit` Example: + ``` curl "http://localhost:8000/api/subjects?search=CS&page=1&limit=10" -b cookie.txt ``` #### GET /api/subjects/:id + Example: + ``` curl http://localhost:8000/api/subjects/1 -b cookie.txt ``` #### POST /api/subjects (admin/teacher) + Body: + ``` { "departmentId": 1, @@ -313,7 +384,9 @@ Body: "description": "Core data structures" } ``` + Example: + ``` curl -X POST http://localhost:8000/api/subjects \ -H "Content-Type: application/json" \ @@ -322,11 +395,15 @@ curl -X POST http://localhost:8000/api/subjects \ ``` #### PUT /api/subjects/:id (admin/teacher) + Body: + ``` { "name": "Intro to Programming Updated" } ``` + Example: + ``` curl -X PUT http://localhost:8000/api/subjects/1 \ -H "Content-Type: application/json" \ @@ -335,7 +412,9 @@ curl -X PUT http://localhost:8000/api/subjects/1 \ ``` #### DELETE /api/subjects/:id (admin/teacher) + Example: + ``` curl -X DELETE http://localhost:8000/api/subjects/1 -b cookie.txt ``` @@ -343,7 +422,9 @@ curl -X DELETE http://localhost:8000/api/subjects/1 -b cookie.txt ### Classes #### GET /api/classes + Query: + - `search` (name/inviteCode) - `subjectId` - `teacherId` @@ -351,24 +432,31 @@ Query: - `page`, `limit` Example: + ``` curl "http://localhost:8000/api/classes?subjectId=1&page=1&limit=10" -b cookie.txt ``` #### GET /api/classes/invite/:code + Example: + ``` curl http://localhost:8000/api/classes/invite/CS101A -b cookie.txt ``` #### GET /api/classes/:id + Example: + ``` curl http://localhost:8000/api/classes/1 -b cookie.txt ``` #### POST /api/classes (admin/teacher) + Body: + ``` { "name": "CS101 - Section C", @@ -383,7 +471,9 @@ Body: ] } ``` + Example: + ``` curl -X POST http://localhost:8000/api/classes \ -H "Content-Type: application/json" \ @@ -392,11 +482,15 @@ curl -X POST http://localhost:8000/api/classes \ ``` #### PUT /api/classes/:id (admin/teacher) + Body: + ``` { "name": "CS101 - Section A Updated" } ``` + Example: + ``` curl -X PUT http://localhost:8000/api/classes/1 \ -H "Content-Type: application/json" \ @@ -405,7 +499,9 @@ curl -X PUT http://localhost:8000/api/classes/1 \ ``` #### DELETE /api/classes/:id (admin/teacher) + Example: + ``` curl -X DELETE http://localhost:8000/api/classes/1 -b cookie.txt ``` @@ -413,30 +509,39 @@ curl -X DELETE http://localhost:8000/api/classes/1 -b cookie.txt ### Enrollments #### GET /api/enrollments (admin/teacher) + Query: + - `classId` - `studentId` - `page`, `limit` Example: + ``` curl "http://localhost:8000/api/enrollments?classId=1" -b cookie.txt ``` #### GET /api/enrollments/:id (admin/teacher) + Example: + ``` curl http://localhost:8000/api/enrollments/1 -b cookie.txt ``` #### POST /api/enrollments (admin/teacher/student) + Body: + ``` { "classId": 1 } ``` + Note: `studentId` is taken from the authenticated user. Example: + ``` curl -X POST http://localhost:8000/api/enrollments \ -H "Content-Type: application/json" \ @@ -445,13 +550,17 @@ curl -X POST http://localhost:8000/api/enrollments \ ``` #### POST /api/enrollments/join (admin/teacher/student) + Body: + ``` { "inviteCode": "CS101A" } ``` + Note: `studentId` is taken from the authenticated user. Example: + ``` curl -X POST http://localhost:8000/api/enrollments/join \ -H "Content-Type: application/json" \ @@ -460,11 +569,15 @@ curl -X POST http://localhost:8000/api/enrollments/join \ ``` #### PUT /api/enrollments/:id (admin/teacher) + Body: + ``` { "classId": 2 } ``` + Example: + ``` curl -X PUT http://localhost:8000/api/enrollments/1 \ -H "Content-Type: application/json" \ @@ -473,18 +586,23 @@ curl -X PUT http://localhost:8000/api/enrollments/1 \ ``` #### DELETE /api/enrollments/:id (admin/teacher) + Example: + ``` curl -X DELETE http://localhost:8000/api/enrollments/1 -b cookie.txt ``` ## Seeding + Seed demo data: + ``` npm run seed ``` Data file: + ``` src/seed/data.json ``` From 6b4ee191b5bb26f1dab4cb6d0ef12a1c9d9aa2f4 Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Mon, 5 Jan 2026 18:37:45 +0530 Subject: [PATCH 11/19] update: class routers --- src/controllers/classes.ts | 6 +- src/routes/classes.ts | 117 ++++++++++++++++++++++++++++++++++--- src/validation/classes.ts | 10 +++- 3 files changed, 123 insertions(+), 10 deletions(-) diff --git a/src/controllers/classes.ts b/src/controllers/classes.ts index 0d14405..f931007 100644 --- a/src/controllers/classes.ts +++ b/src/controllers/classes.ts @@ -1,7 +1,7 @@ import { eq, getTableColumns } from "drizzle-orm"; import { db } from "../db"; -import { classes, subjects } from "../db/schema"; +import { classes, departments, subjects } from "../db/schema"; import { user } from "../db/schema/auth"; export const getClassById = async (classId: number) => { @@ -11,12 +11,16 @@ export const getClassById = async (classId: number) => { subject: { ...getTableColumns(subjects), }, + department: { + ...getTableColumns(departments), + }, teacher: { ...getTableColumns(user), }, }) .from(classes) .leftJoin(subjects, eq(classes.subjectId, subjects.id)) + .leftJoin(departments, eq(subjects.departmentId, departments.id)) .leftJoin(user, eq(classes.teacherId, user.id)) .where(eq(classes.id, classId)); diff --git a/src/routes/classes.ts b/src/routes/classes.ts index 65f9ab7..af729c7 100644 --- a/src/routes/classes.ts +++ b/src/routes/classes.ts @@ -2,7 +2,7 @@ import express from "express"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { db } from "../db"; -import { classes } from "../db/schema"; +import { classes, enrollments, user } from "../db/schema"; import { getClassById, getClassByInviteCode } from "../controllers/classes"; import { getSubjectById } from "../controllers/subjects"; import { getUserById } from "../controllers/users"; @@ -14,6 +14,7 @@ import { classInviteParamSchema, classListQuerySchema, classUpdateSchema, + classUsersQuerySchema, } from "../validation/classes"; const router = express.Router(); @@ -114,6 +115,98 @@ router.get( } }); +// List users in a class by role with pagination +router.get( + "/:id/users", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { + try { + const { id: classId } = parseRequest(classIdParamSchema, req.params); + const { role, page = 1, limit = 10 } = parseRequest( + classUsersQuerySchema, + req.query + ); + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + const baseSelect = { + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + image: user.image, + role: user.role, + imageCldPubId: user.imageCldPubId, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + + const groupByFields = [ + user.id, + user.name, + user.email, + user.emailVerified, + user.image, + user.role, + user.imageCldPubId, + user.createdAt, + user.updatedAt, + ]; + + const countResult = + role === "teacher" + ? await db + .select({ count: sql`count(distinct ${user.id})` }) + .from(user) + .leftJoin(classes, eq(user.id, classes.teacherId)) + .where(and(eq(user.role, role), eq(classes.id, classId))) + : await db + .select({ count: sql`count(distinct ${user.id})` }) + .from(user) + .leftJoin(enrollments, eq(user.id, enrollments.studentId)) + .where(and(eq(user.role, role), eq(enrollments.classId, classId))); + + const totalCount = countResult[0]?.count ?? 0; + + const usersList = + role === "teacher" + ? await db + .select(baseSelect) + .from(user) + .leftJoin(classes, eq(user.id, classes.teacherId)) + .where(and(eq(user.role, role), eq(classes.id, classId))) + .groupBy(...groupByFields) + .orderBy(desc(user.createdAt)) + .limit(limitPerPage) + .offset(offset) + : await db + .select(baseSelect) + .from(user) + .leftJoin(enrollments, eq(user.id, enrollments.studentId)) + .where(and(eq(user.role, role), eq(enrollments.classId, classId))) + .groupBy(...groupByFields) + .orderBy(desc(user.createdAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: usersList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + }); + } catch (error) { + console.error("GET /classes/:id/users error:", error); + res.status(500).json({ error: "Failed to fetch class users" }); + } +}); + // Get class by ID router.get( "/:id", @@ -145,14 +238,12 @@ router.post( try { const { subjectId, - inviteCode, name, teacherId, bannerCldPubId, bannerUrl, capacity, description, - schedules, status, } = parseRequest(classCreateSchema, req.body); @@ -164,9 +255,21 @@ router.post( const teacher = await getUserById(teacherId); if (!teacher) return res.status(404).json({ error: "Teacher not found" }); - const existingInvite = await getClassByInviteCode(inviteCode); - if (existingInvite) - return res.status(409).json({ error: "Invite code already exists" }); + let inviteCode: string | undefined; + for (let attempt = 0; attempt < 5; attempt += 1) { + const candidate = Math.random().toString(36).substring(2, 9); + const existingInvite = await getClassByInviteCode(candidate); + if (!existingInvite) { + inviteCode = candidate; + break; + } + } + + if (!inviteCode) { + return res + .status(500) + .json({ error: "Failed to generate invite code" }); + } const [createdClass] = await db .insert(classes) @@ -179,7 +282,7 @@ router.post( bannerUrl, capacity, description, - schedules, + schedules: [], status, }) .returning({ id: classes.id }); diff --git a/src/validation/classes.ts b/src/validation/classes.ts index 14a28b6..1e74492 100644 --- a/src/validation/classes.ts +++ b/src/validation/classes.ts @@ -36,7 +36,6 @@ export const classListQuerySchema = z export const classCreateSchema = z .object({ name: z.string().trim().min(1), - inviteCode: z.string().trim().min(1), subjectId: z.coerce.number().int().positive(), teacherId: z.string().trim().min(1), description: z.string().trim().optional().nullable(), @@ -44,7 +43,14 @@ export const classCreateSchema = z bannerCldPubId: z.string().trim().optional().nullable(), capacity: z.coerce.number().int().min(1).optional(), status: z.enum(classStatusEnum.enumValues).optional(), - schedules: z.array(scheduleSchema).optional(), + }) + .strict(); + +export const classUsersQuerySchema = z + .object({ + role: z.enum(["teacher", "student"] as const), + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), }) .strict(); From 98c3050afe285abe6381343cf3f63e7d75b5d8ce Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Mon, 5 Jan 2026 18:42:25 +0530 Subject: [PATCH 12/19] update: department routes --- src/routes/departments.ts | 292 +++++++++++++++++++++++++++++++++- src/validation/departments.ts | 15 ++ 2 files changed, 300 insertions(+), 7 deletions(-) diff --git a/src/routes/departments.ts b/src/routes/departments.ts index 1fe2a35..b346caf 100644 --- a/src/routes/departments.ts +++ b/src/routes/departments.ts @@ -1,8 +1,8 @@ import express from "express"; -import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; +import { and, desc, eq, getTableColumns, ilike, or, sql } from "drizzle-orm"; import { db } from "../db"; -import { departments } from "../db/schema"; +import { classes, departments, enrollments, subjects, user } from "../db/schema"; import { getDepartmentByCode, getDepartmentById, @@ -12,8 +12,10 @@ import { authenticate, authorizeRoles } from "../middleware/auth-middleware"; import { departmentCreateSchema, departmentIdParamSchema, + departmentItemsQuerySchema, departmentListQuerySchema, departmentUpdateSchema, + departmentUsersQuerySchema, } from "../validation/departments"; const router = express.Router(); @@ -56,9 +58,14 @@ router.get( const totalCount = countResult[0]?.count ?? 0; const departmentList = await db - .select() + .select({ + ...getTableColumns(departments), + totalSubjects: sql`count(${subjects.id})`, + }) .from(departments) + .leftJoin(subjects, eq(departments.id, subjects.departmentId)) .where(whereClause) + .groupBy(departments.id) .orderBy(desc(departments.createdAt)) .limit(limitPerPage) .offset(offset); @@ -79,7 +86,7 @@ router.get( } ); -// Get department by ID +// Get department details with counts router.get( "/:id", authenticate, @@ -91,20 +98,291 @@ router.get( req.params ); - const department = await getDepartmentById(departmentId); + const [department] = await db + .select() + .from(departments) + .where(eq(departments.id, departmentId)); if (!department) { return res.status(404).json({ error: "Department not found" }); } - res.status(200).json({ data: department }); + const [subjectsCount, classesCount, enrolledStudentsCount] = + await Promise.all([ + db + .select({ count: sql`count(*)` }) + .from(subjects) + .where(eq(subjects.departmentId, departmentId)), + db + .select({ count: sql`count(${classes.id})` }) + .from(classes) + .leftJoin(subjects, eq(classes.subjectId, subjects.id)) + .where(eq(subjects.departmentId, departmentId)), + db + .select({ count: sql`count(distinct ${user.id})` }) + .from(user) + .leftJoin(enrollments, eq(user.id, enrollments.studentId)) + .leftJoin(classes, eq(enrollments.classId, classes.id)) + .leftJoin(subjects, eq(classes.subjectId, subjects.id)) + .where( + and( + eq(user.role, "student"), + eq(subjects.departmentId, departmentId) + ) + ), + ]); + + res.status(200).json({ + data: { + department, + totals: { + subjects: subjectsCount[0]?.count ?? 0, + classes: classesCount[0]?.count ?? 0, + enrolledStudents: enrolledStudentsCount[0]?.count ?? 0, + }, + }, + }); } catch (error) { console.error("GET /departments/:id error:", error); - res.status(500).json({ error: "Failed to fetch department" }); + res.status(500).json({ error: "Failed to fetch department details" }); } } ); +// List subjects in a department with pagination +router.get( + "/:id/subjects", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { + try { + const { id: departmentId } = parseRequest( + departmentIdParamSchema, + req.params + ); + const { page = 1, limit = 10 } = parseRequest( + departmentItemsQuerySchema, + req.query + ); + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + const countResult = await db + .select({ count: sql`count(*)` }) + .from(subjects) + .where(eq(subjects.departmentId, departmentId)); + + const totalCount = countResult[0]?.count ?? 0; + + const subjectsList = await db + .select({ + ...getTableColumns(subjects), + }) + .from(subjects) + .where(eq(subjects.departmentId, departmentId)) + .orderBy(desc(subjects.createdAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: subjectsList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + }); + } catch (error) { + console.error("GET /departments/:id/subjects error:", error); + res.status(500).json({ error: "Failed to fetch department subjects" }); + } +}); + +// List classes in a department with pagination +router.get( + "/:id/classes", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { + try { + const { id: departmentId } = parseRequest( + departmentIdParamSchema, + req.params + ); + const { page = 1, limit = 10 } = parseRequest( + departmentItemsQuerySchema, + req.query + ); + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + const countResult = await db + .select({ count: sql`count(${classes.id})` }) + .from(classes) + .leftJoin(subjects, eq(classes.subjectId, subjects.id)) + .where(eq(subjects.departmentId, departmentId)); + + const totalCount = countResult[0]?.count ?? 0; + + const classesList = await db + .select({ + ...getTableColumns(classes), + subject: { + ...getTableColumns(subjects), + }, + teacher: { + ...getTableColumns(user), + }, + }) + .from(classes) + .leftJoin(subjects, eq(classes.subjectId, subjects.id)) + .leftJoin(user, eq(classes.teacherId, user.id)) + .where(eq(subjects.departmentId, departmentId)) + .orderBy(desc(classes.createdAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: classesList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + }); + } catch (error) { + console.error("GET /departments/:id/classes error:", error); + res.status(500).json({ error: "Failed to fetch department classes" }); + } +}); + +// List users in a department by role with pagination +router.get( + "/:id/users", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { + try { + const { id: departmentId } = parseRequest( + departmentIdParamSchema, + req.params + ); + const { role, page = 1, limit = 10 } = parseRequest( + departmentUsersQuerySchema, + req.query + ); + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + const baseSelect = { + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + image: user.image, + role: user.role, + imageCldPubId: user.imageCldPubId, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + + const groupByFields = [ + user.id, + user.name, + user.email, + user.emailVerified, + user.image, + user.role, + user.imageCldPubId, + user.createdAt, + user.updatedAt, + ]; + + const countResult = + role === "teacher" + ? await db + .select({ count: sql`count(distinct ${user.id})` }) + .from(user) + .leftJoin(classes, eq(user.id, classes.teacherId)) + .leftJoin(subjects, eq(classes.subjectId, subjects.id)) + .where( + and( + eq(user.role, role), + eq(subjects.departmentId, departmentId) + ) + ) + : await db + .select({ count: sql`count(distinct ${user.id})` }) + .from(user) + .leftJoin(enrollments, eq(user.id, enrollments.studentId)) + .leftJoin(classes, eq(enrollments.classId, classes.id)) + .leftJoin(subjects, eq(classes.subjectId, subjects.id)) + .where( + and( + eq(user.role, role), + eq(subjects.departmentId, departmentId) + ) + ); + + const totalCount = countResult[0]?.count ?? 0; + + const usersList = + role === "teacher" + ? await db + .select(baseSelect) + .from(user) + .leftJoin(classes, eq(user.id, classes.teacherId)) + .leftJoin(subjects, eq(classes.subjectId, subjects.id)) + .where( + and( + eq(user.role, role), + eq(subjects.departmentId, departmentId) + ) + ) + .groupBy(...groupByFields) + .orderBy(desc(user.createdAt)) + .limit(limitPerPage) + .offset(offset) + : await db + .select(baseSelect) + .from(user) + .leftJoin(enrollments, eq(user.id, enrollments.studentId)) + .leftJoin(classes, eq(enrollments.classId, classes.id)) + .leftJoin(subjects, eq(classes.subjectId, subjects.id)) + .where( + and( + eq(user.role, role), + eq(subjects.departmentId, departmentId) + ) + ) + .groupBy(...groupByFields) + .orderBy(desc(user.createdAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: usersList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + }); + } catch (error) { + console.error("GET /departments/:id/users error:", error); + res.status(500).json({ error: "Failed to fetch department users" }); + } +}); + // Create department router.post( "/", diff --git a/src/validation/departments.ts b/src/validation/departments.ts index 1f8eab8..23232ef 100644 --- a/src/validation/departments.ts +++ b/src/validation/departments.ts @@ -32,3 +32,18 @@ export const departmentUpdateSchema = z .refine((data) => Object.values(data).some((value) => value !== undefined), { message: "At least one field must be provided", }); + +export const departmentItemsQuerySchema = z + .object({ + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }) + .strict(); + +export const departmentUsersQuerySchema = z + .object({ + role: z.enum(["teacher", "student"] as const), + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }) + .strict(); From 81c5a1def6dd5def4fb2f1ba8e5f358470880cfc Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Mon, 5 Jan 2026 18:46:46 +0530 Subject: [PATCH 13/19] update: enrollments route --- src/controllers/enrollments.ts | 29 ++- src/routes/enrollments.ts | 444 +++++++++++++++++---------------- 2 files changed, 256 insertions(+), 217 deletions(-) diff --git a/src/controllers/enrollments.ts b/src/controllers/enrollments.ts index 5d6a7e9..49022d0 100644 --- a/src/controllers/enrollments.ts +++ b/src/controllers/enrollments.ts @@ -1,7 +1,7 @@ import { eq, getTableColumns } from "drizzle-orm"; import { db } from "../db"; -import { classes, enrollments } from "../db/schema"; +import { classes, departments, enrollments, subjects } from "../db/schema"; import { user } from "../db/schema/auth"; export const getEnrollmentById = async (enrollmentId: number) => { @@ -22,3 +22,30 @@ export const getEnrollmentById = async (enrollmentId: number) => { return enrollmentRows[0]; }; + +export const getEnrollmentDetailsById = async (enrollmentId: number) => { + const enrollmentRows = await db + .select({ + ...getTableColumns(enrollments), + class: { + ...getTableColumns(classes), + }, + subject: { + ...getTableColumns(subjects), + }, + department: { + ...getTableColumns(departments), + }, + teacher: { + ...getTableColumns(user), + }, + }) + .from(enrollments) + .leftJoin(classes, eq(enrollments.classId, classes.id)) + .leftJoin(subjects, eq(classes.subjectId, subjects.id)) + .leftJoin(departments, eq(subjects.departmentId, departments.id)) + .leftJoin(user, eq(classes.teacherId, user.id)) + .where(eq(enrollments.id, enrollmentId)); + + return enrollmentRows[0]; +}; diff --git a/src/routes/enrollments.ts b/src/routes/enrollments.ts index ea60cab..627422c 100644 --- a/src/routes/enrollments.ts +++ b/src/routes/enrollments.ts @@ -4,7 +4,10 @@ import { and, desc, eq, ne, sql } from "drizzle-orm"; import { db } from "../db"; import { enrollments } from "../db/schema"; import { getClassById, getClassByInviteCode } from "../controllers/classes"; -import { getEnrollmentById } from "../controllers/enrollments"; +import { + getEnrollmentById, + getEnrollmentDetailsById, +} from "../controllers/enrollments"; import { getUserById } from "../controllers/users"; import { parseRequest } from "../lib/validation"; import { authenticate, authorizeRoles } from "../middleware/auth-middleware"; @@ -24,60 +27,61 @@ router.get( authenticate, authorizeRoles("admin", "teacher"), async (req, res) => { - try { - const { - classId, - studentId, - page = 1, - limit = 10, - } = parseRequest(enrollmentListQuerySchema, req.query); - - const filterConditions = []; - - const currentPage = Math.max(1, +page); - const limitPerPage = Math.max(1, +limit); - const offset = (currentPage - 1) * limitPerPage; - - if (classId) { - filterConditions.push(eq(enrollments.classId, classId)); - } + try { + const { + classId, + studentId, + page = 1, + limit = 10, + } = parseRequest(enrollmentListQuerySchema, req.query); - if (studentId) { - filterConditions.push(eq(enrollments.studentId, studentId)); - } + const filterConditions = []; + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + if (classId) { + filterConditions.push(eq(enrollments.classId, classId)); + } + + if (studentId) { + filterConditions.push(eq(enrollments.studentId, studentId)); + } + + const whereClause = + filterConditions.length > 0 ? and(...filterConditions) : undefined; + + const countResult = await db + .select({ count: sql`count(*)` }) + .from(enrollments) + .where(whereClause); + + const totalCount = countResult[0]?.count ?? 0; - const whereClause = - filterConditions.length > 0 ? and(...filterConditions) : undefined; - - const countResult = await db - .select({ count: sql`count(*)` }) - .from(enrollments) - .where(whereClause); - - const totalCount = countResult[0]?.count ?? 0; - - const enrollmentList = await db - .select() - .from(enrollments) - .where(whereClause) - .orderBy(desc(enrollments.enrolledAt)) - .limit(limitPerPage) - .offset(offset); - - res.status(200).json({ - data: enrollmentList, - pagination: { - page: currentPage, - limit: limitPerPage, - total: totalCount, - totalPages: Math.ceil(totalCount / limitPerPage), - }, - }); - } catch (error) { - console.error("GET /enrollments error:", error); - res.status(500).json({ error: "Failed to fetch enrollments" }); + const enrollmentList = await db + .select() + .from(enrollments) + .where(whereClause) + .orderBy(desc(enrollments.enrolledAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: enrollmentList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + }); + } catch (error) { + console.error("GET /enrollments error:", error); + res.status(500).json({ error: "Failed to fetch enrollments" }); + } } -}); +); // Get enrollment by ID router.get( @@ -85,23 +89,24 @@ router.get( authenticate, authorizeRoles("admin", "teacher"), async (req, res) => { - try { - const { id: enrollmentId } = parseRequest( - enrollmentIdParamSchema, - req.params - ); + try { + const { id: enrollmentId } = parseRequest( + enrollmentIdParamSchema, + req.params + ); - const enrollment = await getEnrollmentById(enrollmentId); + const enrollment = await getEnrollmentById(enrollmentId); - if (!enrollment) - return res.status(404).json({ error: "Enrollment not found" }); + if (!enrollment) + return res.status(404).json({ error: "Enrollment not found" }); - res.status(200).json({ data: enrollment }); - } catch (error) { - console.error("GET /enrollments/:id error:", error); - res.status(500).json({ error: "Failed to fetch enrollment" }); + res.status(200).json({ data: enrollment }); + } catch (error) { + console.error("GET /enrollments/:id error:", error); + res.status(500).json({ error: "Failed to fetch enrollment" }); + } } -}); +); // Create enrollment router.post( @@ -109,49 +114,51 @@ router.post( authenticate, authorizeRoles("admin", "teacher", "student"), async (req, res) => { - try { - const { classId } = parseRequest(enrollmentCreateSchema, req.body); - const studentId = res.locals.user?.id; - - if (!studentId) return res.status(401).json({ error: "Unauthorized" }); - - const classRecord = await getClassById(classId); - if (!classRecord) return res.status(404).json({ error: "Class not found" }); - - const student = await getUserById(studentId); - if (!student) return res.status(404).json({ error: "Student not found" }); - - const [existingEnrollment] = await db - .select({ id: enrollments.id }) - .from(enrollments) - .where( - and( - eq(enrollments.classId, classId), - eq(enrollments.studentId, studentId) - ) - ); + try { + const { classId } = parseRequest(enrollmentCreateSchema, req.body); + const studentId = res.locals.user?.id; - if (existingEnrollment) - return res - .status(409) - .json({ error: "Student already enrolled in class" }); + if (!studentId) return res.status(401).json({ error: "Unauthorized" }); - const [createdEnrollment] = await db - .insert(enrollments) - .values({ classId, studentId }) - .returning({ id: enrollments.id }); + const classRecord = await getClassById(classId); + if (!classRecord) + return res.status(404).json({ error: "Class not found" }); - if (!createdEnrollment) - return res.status(500).json({ error: "Failed to create enrollment" }); + const student = await getUserById(studentId); + if (!student) return res.status(404).json({ error: "Student not found" }); + + const [existingEnrollment] = await db + .select({ id: enrollments.id }) + .from(enrollments) + .where( + and( + eq(enrollments.classId, classId), + eq(enrollments.studentId, studentId) + ) + ); + + if (existingEnrollment) + return res + .status(409) + .json({ error: "Student already enrolled in class" }); + + const [createdEnrollment] = await db + .insert(enrollments) + .values({ classId, studentId }) + .returning({ id: enrollments.id }); + + if (!createdEnrollment) + return res.status(500).json({ error: "Failed to create enrollment" }); - const enrollment = await getEnrollmentById(createdEnrollment.id); + const enrollment = await getEnrollmentDetailsById(createdEnrollment.id); - res.status(201).json({ data: enrollment }); - } catch (error) { - console.error("POST /enrollments error:", error); - res.status(500).json({ error: "Failed to create enrollment" }); + res.status(201).json({ data: enrollment }); + } catch (error) { + console.error("POST /enrollments error:", error); + res.status(500).json({ error: "Failed to create enrollment" }); + } } -}); +); // Join class by invite code router.post( @@ -159,127 +166,131 @@ router.post( authenticate, authorizeRoles("admin", "teacher", "student"), async (req, res) => { - try { - const { inviteCode } = parseRequest(enrollmentJoinSchema, req.body); - const studentId = res.locals.user?.id; - - if (!studentId) return res.status(401).json({ error: "Unauthorized" }); - - const classRecord = await getClassByInviteCode(inviteCode); - - if (!classRecord) return res.status(404).json({ error: "Class not found" }); + try { + const { inviteCode } = parseRequest(enrollmentJoinSchema, req.body); + const studentId = res.locals.user?.id; - const student = await getUserById(studentId); - if (!student) return res.status(404).json({ error: "Student not found" }); + if (!studentId) return res.status(401).json({ error: "Unauthorized" }); - const [existingEnrollment] = await db - .select({ id: enrollments.id }) - .from(enrollments) - .where( - and( - eq(enrollments.classId, classRecord.id), - eq(enrollments.studentId, studentId) - ) - ); - - if (existingEnrollment) - return res - .status(409) - .json({ error: "Student already enrolled in class" }); - - const [createdEnrollment] = await db - .insert(enrollments) - .values({ classId: classRecord.id, studentId }) - .returning({ id: enrollments.id }); - - if (!createdEnrollment) - return res.status(500).json({ error: "Failed to create enrollment" }); - - const enrollment = await getEnrollmentById(createdEnrollment.id); + const classRecord = await getClassByInviteCode(inviteCode); - res.status(201).json({ data: enrollment }); - } catch (error) { - console.error("POST /enrollments/join error:", error); - res.status(500).json({ error: "Failed to join class" }); - } -}); - -// Update enrollment -router.put( - "/:id", - authenticate, - authorizeRoles("admin", "teacher"), - async (req, res) => { - try { - const { id: enrollmentId } = parseRequest( - enrollmentIdParamSchema, - req.params - ); - - const { classId, studentId } = parseRequest( - enrollmentUpdateSchema, - req.body - ); - - const existingEnrollment = await getEnrollmentById(enrollmentId); - if (!existingEnrollment) { - return res.status(404).json({ error: "Enrollment not found" }); - } - - if (classId) { - const classRecord = await getClassById(classId); if (!classRecord) return res.status(404).json({ error: "Class not found" }); - } - if (studentId) { const student = await getUserById(studentId); if (!student) return res.status(404).json({ error: "Student not found" }); - } - - if (classId || studentId) { - const classIdToCheck = classId ?? existingEnrollment.classId; - const studentIdToCheck = studentId ?? existingEnrollment.studentId; - const [existingEnrollmentPair] = await db + const [existingEnrollment] = await db .select({ id: enrollments.id }) .from(enrollments) .where( and( - eq(enrollments.classId, classIdToCheck), - eq(enrollments.studentId, studentIdToCheck), - ne(enrollments.id, enrollmentId) + eq(enrollments.classId, classRecord.id), + eq(enrollments.studentId, studentId) ) ); - if (existingEnrollmentPair) + if (existingEnrollment) return res .status(409) .json({ error: "Student already enrolled in class" }); - } - const updateValues: Record = {}; + const [createdEnrollment] = await db + .insert(enrollments) + .values({ classId: classRecord.id, studentId }) + .returning({ id: enrollments.id }); + + if (!createdEnrollment) + return res.status(500).json({ error: "Failed to create enrollment" }); + + const enrollment = await getEnrollmentDetailsById(createdEnrollment.id); - for (const [key, value] of Object.entries({ - classId, - studentId, - })) { - if (value) updateValues[key] = value; + res.status(201).json({ data: enrollment }); + } catch (error) { + console.error("POST /enrollments/join error:", error); + res.status(500).json({ error: "Failed to join class" }); } + } +); - await db - .update(enrollments) - .set(updateValues) - .where(eq(enrollments.id, enrollmentId)); +// Update enrollment +router.put( + "/:id", + authenticate, + authorizeRoles("admin", "teacher"), + async (req, res) => { + try { + const { id: enrollmentId } = parseRequest( + enrollmentIdParamSchema, + req.params + ); - const enrollment = await getEnrollmentById(enrollmentId); + const { classId, studentId } = parseRequest( + enrollmentUpdateSchema, + req.body + ); - res.status(200).json({ data: enrollment }); - } catch (error) { - console.error("PUT /enrollments/:id error:", error); - res.status(500).json({ error: "Failed to update enrollment" }); + const existingEnrollment = await getEnrollmentById(enrollmentId); + if (!existingEnrollment) { + return res.status(404).json({ error: "Enrollment not found" }); + } + + if (classId) { + const classRecord = await getClassById(classId); + if (!classRecord) + return res.status(404).json({ error: "Class not found" }); + } + + if (studentId) { + const student = await getUserById(studentId); + if (!student) + return res.status(404).json({ error: "Student not found" }); + } + + if (classId || studentId) { + const classIdToCheck = classId ?? existingEnrollment.classId; + const studentIdToCheck = studentId ?? existingEnrollment.studentId; + + const [existingEnrollmentPair] = await db + .select({ id: enrollments.id }) + .from(enrollments) + .where( + and( + eq(enrollments.classId, classIdToCheck), + eq(enrollments.studentId, studentIdToCheck), + ne(enrollments.id, enrollmentId) + ) + ); + + if (existingEnrollmentPair) + return res + .status(409) + .json({ error: "Student already enrolled in class" }); + } + + const updateValues: Record = {}; + + for (const [key, value] of Object.entries({ + classId, + studentId, + })) { + if (value) updateValues[key] = value; + } + + await db + .update(enrollments) + .set(updateValues) + .where(eq(enrollments.id, enrollmentId)); + + const enrollment = await getEnrollmentById(enrollmentId); + + res.status(200).json({ data: enrollment }); + } catch (error) { + console.error("PUT /enrollments/:id error:", error); + res.status(500).json({ error: "Failed to update enrollment" }); + } } -}); +); // Delete enrollment router.delete( @@ -287,25 +298,26 @@ router.delete( authenticate, authorizeRoles("admin", "teacher"), async (req, res) => { - try { - const { id: enrollmentId } = parseRequest( - enrollmentIdParamSchema, - req.params - ); - - const deletedRows = await db - .delete(enrollments) - .where(eq(enrollments.id, enrollmentId)) - .returning({ id: enrollments.id }); - - if (deletedRows.length === 0) - return res.status(404).json({ error: "Enrollment not found" }); - - res.status(200).json({ message: "Enrollment deleted" }); - } catch (error) { - console.error("DELETE /enrollments/:id error:", error); - res.status(500).json({ error: "Failed to delete enrollment" }); + try { + const { id: enrollmentId } = parseRequest( + enrollmentIdParamSchema, + req.params + ); + + const deletedRows = await db + .delete(enrollments) + .where(eq(enrollments.id, enrollmentId)) + .returning({ id: enrollments.id }); + + if (deletedRows.length === 0) + return res.status(404).json({ error: "Enrollment not found" }); + + res.status(200).json({ message: "Enrollment deleted" }); + } catch (error) { + console.error("DELETE /enrollments/:id error:", error); + res.status(500).json({ error: "Failed to delete enrollment" }); + } } -}); +); export default router; From b82f4025f638bbbff6bf917f10da49863b185577 Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Mon, 5 Jan 2026 18:49:37 +0530 Subject: [PATCH 14/19] update: subjects router --- src/routes/subjects.ts | 167 ++++++++++++++++++++++++++++++++++++- src/validation/subjects.ts | 15 ++++ 2 files changed, 179 insertions(+), 3 deletions(-) diff --git a/src/routes/subjects.ts b/src/routes/subjects.ts index 3133f05..03b15f7 100644 --- a/src/routes/subjects.ts +++ b/src/routes/subjects.ts @@ -2,11 +2,13 @@ import express from "express"; import { eq, ilike, or, and, desc, sql, getTableColumns } from "drizzle-orm"; import { db } from "../db"; -import { departments, subjects } from "../db/schema"; +import { classes, departments, enrollments, subjects, user } from "../db/schema"; import { subjectCreateSchema, subjectIdParamSchema, + subjectItemsQuerySchema, subjectUpdateSchema, + subjectUsersQuerySchema, } from "../validation/subjects"; import { getSubjectByCode, getSubjectById } from "../controllers/subjects"; import { getDepartmentById } from "../controllers/departments"; @@ -100,10 +102,22 @@ router.get( return res.status(404).json({ error: "Subject not found" }); } - res.status(200).json({ data: subject }); + const classesCount = await db + .select({ count: sql`count(*)` }) + .from(classes) + .where(eq(classes.subjectId, subjectId)); + + res.status(200).json({ + data: { + subject, + totals: { + classes: classesCount[0]?.count ?? 0, + }, + }, + }); } catch (error) { console.error("GET /subjects/:id error:", error); - res.status(500).json({ error: "Failed to fetch subject" }); + res.status(500).json({ error: "Failed to fetch subject details" }); } }); @@ -206,6 +220,153 @@ router.put( } }); +// List classes in a subject with pagination +router.get( + "/:id/classes", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { + try { + const { id: subjectId } = parseRequest(subjectIdParamSchema, req.params); + const { page = 1, limit = 10 } = parseRequest( + subjectItemsQuerySchema, + req.query + ); + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + const countResult = await db + .select({ count: sql`count(*)` }) + .from(classes) + .where(eq(classes.subjectId, subjectId)); + + const totalCount = countResult[0]?.count ?? 0; + + const classesList = await db + .select({ + ...getTableColumns(classes), + teacher: { + ...getTableColumns(user), + }, + }) + .from(classes) + .leftJoin(user, eq(classes.teacherId, user.id)) + .where(eq(classes.subjectId, subjectId)) + .orderBy(desc(classes.createdAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: classesList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + }); + } catch (error) { + console.error("GET /subjects/:id/classes error:", error); + res.status(500).json({ error: "Failed to fetch subject classes" }); + } +}); + +// List users in a subject by role with pagination +router.get( + "/:id/users", + authenticate, + authorizeRoles("admin", "teacher", "student"), + async (req, res) => { + try { + const { id: subjectId } = parseRequest(subjectIdParamSchema, req.params); + const { role, page = 1, limit = 10 } = parseRequest( + subjectUsersQuerySchema, + req.query + ); + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + const baseSelect = { + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + image: user.image, + role: user.role, + imageCldPubId: user.imageCldPubId, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + + const groupByFields = [ + user.id, + user.name, + user.email, + user.emailVerified, + user.image, + user.role, + user.imageCldPubId, + user.createdAt, + user.updatedAt, + ]; + + const countResult = + role === "teacher" + ? await db + .select({ count: sql`count(distinct ${user.id})` }) + .from(user) + .leftJoin(classes, eq(user.id, classes.teacherId)) + .where(and(eq(user.role, role), eq(classes.subjectId, subjectId))) + : await db + .select({ count: sql`count(distinct ${user.id})` }) + .from(user) + .leftJoin(enrollments, eq(user.id, enrollments.studentId)) + .leftJoin(classes, eq(enrollments.classId, classes.id)) + .where(and(eq(user.role, role), eq(classes.subjectId, subjectId))); + + const totalCount = countResult[0]?.count ?? 0; + + const usersList = + role === "teacher" + ? await db + .select(baseSelect) + .from(user) + .leftJoin(classes, eq(user.id, classes.teacherId)) + .where(and(eq(user.role, role), eq(classes.subjectId, subjectId))) + .groupBy(...groupByFields) + .orderBy(desc(user.createdAt)) + .limit(limitPerPage) + .offset(offset) + : await db + .select(baseSelect) + .from(user) + .leftJoin(enrollments, eq(user.id, enrollments.studentId)) + .leftJoin(classes, eq(enrollments.classId, classes.id)) + .where(and(eq(user.role, role), eq(classes.subjectId, subjectId))) + .groupBy(...groupByFields) + .orderBy(desc(user.createdAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: usersList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + }); + } catch (error) { + console.error("GET /subjects/:id/users error:", error); + res.status(500).json({ error: "Failed to fetch subject users" }); + } +}); + // Delete a subject by ID router.delete( "/:id", diff --git a/src/validation/subjects.ts b/src/validation/subjects.ts index 627dae1..109f16f 100644 --- a/src/validation/subjects.ts +++ b/src/validation/subjects.ts @@ -35,3 +35,18 @@ export const subjectUpdateSchema = z .refine((data) => Object.values(data).some((value) => value !== undefined), { message: "At least one field must be provided", }); + +export const subjectItemsQuerySchema = z + .object({ + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }) + .strict(); + +export const subjectUsersQuerySchema = z + .object({ + role: z.enum(["teacher", "student"] as const), + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }) + .strict(); From f90d78a63bf9a3cc8791e0370e0629f59661293f Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Mon, 5 Jan 2026 18:51:58 +0530 Subject: [PATCH 15/19] update: user router --- src/routes/users.ts | 244 +++++++++++++++++++++++++++++++++++++++- src/validation/users.ts | 7 ++ 2 files changed, 249 insertions(+), 2 deletions(-) diff --git a/src/routes/users.ts b/src/routes/users.ts index 7f31b4e..1804712 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -1,14 +1,16 @@ import express from "express"; -import { and, desc, eq, ilike, sql } from "drizzle-orm"; +import { and, desc, eq, getTableColumns, ilike, or, sql } from "drizzle-orm"; import { db } from "../db"; import { user } from "../db/schema/auth"; +import { classes, departments, enrollments, subjects } from "../db/schema"; import { getUserByEmail, getUserById } from "../controllers/users"; import { parseRequest } from "../lib/validation"; import { authenticate, authorizeRoles } from "../middleware/auth-middleware"; import { userCreateSchema, userIdParamSchema, + userItemsQuerySchema, userListQuerySchema, userUpdateSchema, } from "../validation/users"; @@ -38,7 +40,9 @@ router.get("/", async (req, res) => { } if (search) { - filterConditions.push(ilike(user.name, `%${search}%`)); + filterConditions.push( + or(ilike(user.name, `%${search}%`), ilike(user.email, `%${search}%`)) + ); } const whereClause = @@ -98,6 +102,242 @@ router.get("/:id", async (req, res) => { } }); +// List departments associated with a user +router.get("/:id/departments", async (req, res) => { + try { + const { id: userId } = parseRequest(userIdParamSchema, req.params); + const { page = 1, limit = 10 } = parseRequest( + userItemsQuerySchema, + req.query + ); + + const [userRecord] = await db + .select({ id: user.id, role: user.role }) + .from(user) + .where(eq(user.id, userId)); + + if (!userRecord) { + return res.status(404).json({ error: "User not found" }); + } + + if (userRecord.role !== "teacher" && userRecord.role !== "student") { + return res.status(200).json({ + data: [], + pagination: { + page: 1, + limit: 0, + total: 0, + totalPages: 0, + }, + }); + } + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + const countResult = + userRecord.role === "teacher" + ? await db + .select({ count: sql`count(distinct ${departments.id})` }) + .from(departments) + .leftJoin(subjects, eq(subjects.departmentId, departments.id)) + .leftJoin(classes, eq(classes.subjectId, subjects.id)) + .where(eq(classes.teacherId, userId)) + : await db + .select({ count: sql`count(distinct ${departments.id})` }) + .from(departments) + .leftJoin(subjects, eq(subjects.departmentId, departments.id)) + .leftJoin(classes, eq(classes.subjectId, subjects.id)) + .leftJoin(enrollments, eq(enrollments.classId, classes.id)) + .where(eq(enrollments.studentId, userId)); + + const totalCount = countResult[0]?.count ?? 0; + + const departmentsList = + userRecord.role === "teacher" + ? await db + .select({ + ...getTableColumns(departments), + }) + .from(departments) + .leftJoin(subjects, eq(subjects.departmentId, departments.id)) + .leftJoin(classes, eq(classes.subjectId, subjects.id)) + .where(eq(classes.teacherId, userId)) + .groupBy( + departments.id, + departments.code, + departments.name, + departments.description, + departments.createdAt, + departments.updatedAt + ) + .orderBy(desc(departments.createdAt)) + .limit(limitPerPage) + .offset(offset) + : await db + .select({ + ...getTableColumns(departments), + }) + .from(departments) + .leftJoin(subjects, eq(subjects.departmentId, departments.id)) + .leftJoin(classes, eq(classes.subjectId, subjects.id)) + .leftJoin(enrollments, eq(enrollments.classId, classes.id)) + .where(eq(enrollments.studentId, userId)) + .groupBy( + departments.id, + departments.code, + departments.name, + departments.description, + departments.createdAt, + departments.updatedAt + ) + .orderBy(desc(departments.createdAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: departmentsList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + }); + } catch (error) { + console.error("GET /users/:id/departments error:", error); + res.status(500).json({ error: "Failed to fetch user departments" }); + } +}); + +// List subjects associated with a user +router.get("/:id/subjects", async (req, res) => { + try { + const { id: userId } = parseRequest(userIdParamSchema, req.params); + const { page = 1, limit = 10 } = parseRequest( + userItemsQuerySchema, + req.query + ); + + const [userRecord] = await db + .select({ id: user.id, role: user.role }) + .from(user) + .where(eq(user.id, userId)); + + if (!userRecord) { + return res.status(404).json({ error: "User not found" }); + } + + if (userRecord.role !== "teacher" && userRecord.role !== "student") { + return res.status(200).json({ + data: [], + pagination: { + page: 1, + limit: 0, + total: 0, + totalPages: 0, + }, + }); + } + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + const countResult = + userRecord.role === "teacher" + ? await db + .select({ count: sql`count(distinct ${subjects.id})` }) + .from(subjects) + .leftJoin(classes, eq(classes.subjectId, subjects.id)) + .where(eq(classes.teacherId, userId)) + : await db + .select({ count: sql`count(distinct ${subjects.id})` }) + .from(subjects) + .leftJoin(classes, eq(classes.subjectId, subjects.id)) + .leftJoin(enrollments, eq(enrollments.classId, classes.id)) + .where(eq(enrollments.studentId, userId)); + + const totalCount = countResult[0]?.count ?? 0; + + const subjectsList = + userRecord.role === "teacher" + ? await db + .select({ + ...getTableColumns(subjects), + department: { + ...getTableColumns(departments), + }, + }) + .from(subjects) + .leftJoin(departments, eq(subjects.departmentId, departments.id)) + .leftJoin(classes, eq(classes.subjectId, subjects.id)) + .where(eq(classes.teacherId, userId)) + .groupBy( + subjects.id, + subjects.departmentId, + subjects.name, + subjects.code, + subjects.description, + subjects.createdAt, + subjects.updatedAt, + departments.id, + departments.code, + departments.name, + departments.description, + departments.createdAt, + departments.updatedAt + ) + .orderBy(desc(subjects.createdAt)) + .limit(limitPerPage) + .offset(offset) + : await db + .select({ + ...getTableColumns(subjects), + department: { + ...getTableColumns(departments), + }, + }) + .from(subjects) + .leftJoin(departments, eq(subjects.departmentId, departments.id)) + .leftJoin(classes, eq(classes.subjectId, subjects.id)) + .leftJoin(enrollments, eq(enrollments.classId, classes.id)) + .where(eq(enrollments.studentId, userId)) + .groupBy( + subjects.id, + subjects.departmentId, + subjects.name, + subjects.code, + subjects.description, + subjects.createdAt, + subjects.updatedAt, + departments.id, + departments.code, + departments.name, + departments.description, + departments.createdAt, + departments.updatedAt + ) + .orderBy(desc(subjects.createdAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: subjectsList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + }); + } catch (error) { + console.error("GET /users/:id/subjects error:", error); + res.status(500).json({ error: "Failed to fetch user subjects" }); + } +}); + // Create user router.post("/", async (req, res) => { try { diff --git a/src/validation/users.ts b/src/validation/users.ts index df8c9d1..f4d826b 100644 --- a/src/validation/users.ts +++ b/src/validation/users.ts @@ -42,3 +42,10 @@ export const userUpdateSchema = z .refine((data) => Object.values(data).some((value) => value !== undefined), { message: "At least one field must be provided", }); + +export const userItemsQuerySchema = z + .object({ + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }) + .strict(); From d68ce37c9136fa00a5a0fdfbb7d82fc7f0622b08 Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Mon, 5 Jan 2026 18:53:38 +0530 Subject: [PATCH 16/19] feat: stats router --- src/index.ts | 2 + src/routes/stats.ts | 142 ++++++++++++++++++++++++++++++++++++++++ src/validation/stats.ts | 7 ++ 3 files changed, 151 insertions(+) create mode 100644 src/routes/stats.ts create mode 100644 src/validation/stats.ts diff --git a/src/index.ts b/src/index.ts index 50e315f..22d63c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import usersRouter from "./routes/users"; import classesRouter from "./routes/classes"; import enrollmentsRouter from "./routes/enrollments"; import departmentsRouter from "./routes/departments"; +import statsRouter from "./routes/stats"; const app = express(); const PORT = 8000; @@ -30,6 +31,7 @@ app.use("/api/subjects", subjectsRouter); app.use("/api/classes", classesRouter); app.use("/api/enrollments", enrollmentsRouter); app.use("/api/departments", departmentsRouter); +app.use("/api/stats", statsRouter); app.get("/", (req, res) => { res.send("Backend server is running!"); diff --git a/src/routes/stats.ts b/src/routes/stats.ts new file mode 100644 index 0000000..486bdf8 --- /dev/null +++ b/src/routes/stats.ts @@ -0,0 +1,142 @@ +import express from "express"; +import { desc, eq, getTableColumns, sql } from "drizzle-orm"; + +import { db } from "../db"; +import { classes, departments, subjects, user } from "../db/schema"; +import { parseRequest } from "../lib/validation"; +import { authenticate, authorizeRoles } from "../middleware/auth-middleware"; +import { statsLatestQuerySchema } from "../validation/stats"; + +const router = express.Router(); + +router.use(authenticate, authorizeRoles("admin", "teacher", "student")); + +// Overview counts for core entities +router.get("/overview", async (_req, res) => { + try { + const [ + usersCount, + teachersCount, + adminsCount, + subjectsCount, + departmentsCount, + classesCount, + ] = await Promise.all([ + db.select({ count: sql`count(*)` }).from(user), + db + .select({ count: sql`count(*)` }) + .from(user) + .where(eq(user.role, "teacher")), + db + .select({ count: sql`count(*)` }) + .from(user) + .where(eq(user.role, "admin")), + db.select({ count: sql`count(*)` }).from(subjects), + db.select({ count: sql`count(*)` }).from(departments), + db.select({ count: sql`count(*)` }).from(classes), + ]); + + res.status(200).json({ + data: { + users: usersCount[0]?.count ?? 0, + teachers: teachersCount[0]?.count ?? 0, + admins: adminsCount[0]?.count ?? 0, + subjects: subjectsCount[0]?.count ?? 0, + departments: departmentsCount[0]?.count ?? 0, + classes: classesCount[0]?.count ?? 0, + }, + }); + } catch (error) { + console.error("GET /stats/overview error:", error); + res.status(500).json({ error: "Failed to fetch overview stats" }); + } +}); + +// Latest activity summaries +router.get("/latest", async (req, res) => { + try { + const { limit = 5 } = parseRequest(statsLatestQuerySchema, req.query); + const limitPerPage = Math.max(1, +limit); + + const [latestClasses, latestTeachers] = await Promise.all([ + db + .select({ + ...getTableColumns(classes), + subject: { + ...getTableColumns(subjects), + }, + teacher: { + ...getTableColumns(user), + }, + }) + .from(classes) + .leftJoin(subjects, eq(classes.subjectId, subjects.id)) + .leftJoin(user, eq(classes.teacherId, user.id)) + .orderBy(desc(classes.createdAt)) + .limit(limitPerPage), + db + .select() + .from(user) + .where(eq(user.role, "teacher")) + .orderBy(desc(user.createdAt)) + .limit(limitPerPage), + ]); + + res.status(200).json({ + data: { + latestClasses, + latestTeachers, + }, + }); + } catch (error) { + console.error("GET /stats/latest error:", error); + res.status(500).json({ error: "Failed to fetch latest stats" }); + } +}); + +// Aggregates for charts +router.get("/charts", async (_req, res) => { + try { + const [usersByRole, subjectsByDepartment, classesBySubject] = + await Promise.all([ + db + .select({ + role: user.role, + total: sql`count(*)`, + }) + .from(user) + .groupBy(user.role), + db + .select({ + departmentId: departments.id, + departmentName: departments.name, + totalSubjects: sql`count(${subjects.id})`, + }) + .from(departments) + .leftJoin(subjects, eq(subjects.departmentId, departments.id)) + .groupBy(departments.id), + db + .select({ + subjectId: subjects.id, + subjectName: subjects.name, + totalClasses: sql`count(${classes.id})`, + }) + .from(subjects) + .leftJoin(classes, eq(classes.subjectId, subjects.id)) + .groupBy(subjects.id), + ]); + + res.status(200).json({ + data: { + usersByRole, + subjectsByDepartment, + classesBySubject, + }, + }); + } catch (error) { + console.error("GET /stats/charts error:", error); + res.status(500).json({ error: "Failed to fetch chart stats" }); + } +}); + +export default router; diff --git a/src/validation/stats.ts b/src/validation/stats.ts new file mode 100644 index 0000000..9886b12 --- /dev/null +++ b/src/validation/stats.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const statsLatestQuerySchema = z + .object({ + limit: z.coerce.number().int().min(1).max(100).optional(), + }) + .strict(); From 7bc21207f4a49b1a664ed2142713cac28d793039 Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Mon, 5 Jan 2026 19:02:27 +0530 Subject: [PATCH 17/19] fix: seed and schema --- ...tidy_mentor.sql => 0000_oval_gauntlet.sql} | 74 +- drizzle/0001_goofy_nehzno.sql | 56 -- drizzle/meta/0000_snapshot.json | 468 +++++++++- drizzle/meta/0001_snapshot.json | 878 ------------------ drizzle/meta/_journal.json | 11 +- src/db/schema/app.ts | 64 +- src/db/schema/auth.ts | 99 +- src/routes/classes.ts | 574 ++++++------ src/routes/enrollments.ts | 2 +- src/seed/data.json | 623 ++++++++++--- src/seed/seed.ts | 303 +++--- 11 files changed, 1573 insertions(+), 1579 deletions(-) rename drizzle/{0000_tidy_mentor.sql => 0000_oval_gauntlet.sql} (55%) delete mode 100644 drizzle/0001_goofy_nehzno.sql delete mode 100644 drizzle/meta/0001_snapshot.json diff --git a/drizzle/0000_tidy_mentor.sql b/drizzle/0000_oval_gauntlet.sql similarity index 55% rename from drizzle/0000_tidy_mentor.sql rename to drizzle/0000_oval_gauntlet.sql index 3eb417a..bec9099 100644 --- a/drizzle/0000_tidy_mentor.sql +++ b/drizzle/0000_oval_gauntlet.sql @@ -1,16 +1,17 @@ CREATE TYPE "public"."class_status" AS ENUM('active', 'inactive', 'archived');--> statement-breakpoint +CREATE TYPE "public"."role" AS ENUM('student', 'teacher', 'admin');--> statement-breakpoint CREATE TABLE "classes" ( "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "classes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), - "name" varchar(255) NOT NULL, - "invite_code" varchar(20) NOT NULL, "subject_id" integer NOT NULL, "teacher_id" text NOT NULL, - "description" text, + "invite_code" varchar(50) NOT NULL, + "name" varchar(255) NOT NULL, + "banner_cld_pub_id" text, "banner_url" text, - "imageCldPubId" text, - "capacity" integer DEFAULT 50, + "capacity" integer DEFAULT 50 NOT NULL, + "description" text, "status" "class_status" DEFAULT 'active' NOT NULL, - "schedules" jsonb DEFAULT '[]'::jsonb NOT NULL, + "schedules" jsonb NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL, "updated_at" timestamp DEFAULT now() NOT NULL, CONSTRAINT "classes_invite_code_unique" UNIQUE("invite_code") @@ -30,7 +31,7 @@ CREATE TABLE "enrollments" ( "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "enrollments_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), "student_id" text NOT NULL, "class_id" integer NOT NULL, - "enrolled_at" timestamp DEFAULT now() NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, "updated_at" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint @@ -45,13 +46,68 @@ CREATE TABLE "subjects" ( CONSTRAINT "subjects_code_unique" UNIQUE("code") ); --> statement-breakpoint +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "id_token" text, + "password" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp NOT NULL, + "ip_address" text, + "user_agent" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean NOT NULL, + "image" text, + "role" "role" DEFAULT 'student' NOT NULL, + "image_cld_pub_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint ALTER TABLE "classes" ADD CONSTRAINT "classes_subject_id_subjects_id_fk" FOREIGN KEY ("subject_id") REFERENCES "public"."subjects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "classes" ADD CONSTRAINT "classes_teacher_id_user_id_fk" FOREIGN KEY ("teacher_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint ALTER TABLE "enrollments" ADD CONSTRAINT "enrollments_student_id_user_id_fk" FOREIGN KEY ("student_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "enrollments" ADD CONSTRAINT "enrollments_class_id_classes_id_fk" FOREIGN KEY ("class_id") REFERENCES "public"."classes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "subjects" ADD CONSTRAINT "subjects_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint CREATE INDEX "classes_subject_id_idx" ON "classes" USING btree ("subject_id");--> statement-breakpoint CREATE INDEX "classes_teacher_id_idx" ON "classes" USING btree ("teacher_id");--> statement-breakpoint -CREATE INDEX "enrollments_class_id_idx" ON "enrollments" USING btree ("class_id");--> statement-breakpoint CREATE INDEX "enrollments_student_id_idx" ON "enrollments" USING btree ("student_id");--> statement-breakpoint -CREATE UNIQUE INDEX "enrollments_student_class_unq" ON "enrollments" USING btree ("student_id","class_id"); \ No newline at end of file +CREATE INDEX "enrollments_class_id_idx" ON "enrollments" USING btree ("class_id");--> statement-breakpoint +CREATE INDEX "enrollments_student_class_unique" ON "enrollments" USING btree ("student_id","class_id");--> statement-breakpoint +CREATE INDEX "account_user_id_idx" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "account_provider_account_unique" ON "account" USING btree ("provider_id","account_id");--> statement-breakpoint +CREATE INDEX "session_user_id_idx" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "session_token_unique" ON "session" USING btree ("token");--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier"); \ No newline at end of file diff --git a/drizzle/0001_goofy_nehzno.sql b/drizzle/0001_goofy_nehzno.sql deleted file mode 100644 index 2024f4c..0000000 --- a/drizzle/0001_goofy_nehzno.sql +++ /dev/null @@ -1,56 +0,0 @@ -CREATE TYPE "public"."role" AS ENUM('admin', 'teacher', 'student');--> statement-breakpoint -CREATE TABLE "account" ( - "id" text PRIMARY KEY NOT NULL, - "account_id" text NOT NULL, - "provider_id" text NOT NULL, - "user_id" text NOT NULL, - "access_token" text, - "refresh_token" text, - "id_token" text, - "access_token_expires_at" timestamp, - "refresh_token_expires_at" timestamp, - "scope" text, - "password" text, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "session" ( - "id" text PRIMARY KEY NOT NULL, - "expires_at" timestamp NOT NULL, - "token" text NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - "ip_address" text, - "user_agent" text, - "user_id" text NOT NULL, - CONSTRAINT "session_token_unique" UNIQUE("token") -); ---> statement-breakpoint -CREATE TABLE "user" ( - "id" text PRIMARY KEY NOT NULL, - "name" text NOT NULL, - "email" text NOT NULL, - "email_verified" boolean DEFAULT false NOT NULL, - "image" text, - "imageCldPubId" text, - "role" "role" DEFAULT 'student' NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "user_email_unique" UNIQUE("email") -); ---> statement-breakpoint -CREATE TABLE "verification" ( - "id" text PRIMARY KEY NOT NULL, - "identifier" text NOT NULL, - "value" text NOT NULL, - "expires_at" timestamp NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL, - "updated_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier"); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 3400c7a..b3877cb 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "24f3a1d5-fecb-4872-ab75-df89b3434660", + "id": "75303106-e7fe-4c95-8df2-91a7dc8004e1", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -25,18 +25,6 @@ "cycle": false } }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "invite_code": { - "name": "invite_code", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true - }, "subject_id": { "name": "subject_id", "type": "integer", @@ -49,20 +37,26 @@ "primaryKey": false, "notNull": true }, - "description": { - "name": "description", - "type": "text", + "invite_code": { + "name": "invite_code", + "type": "varchar(50)", "primaryKey": false, - "notNull": false + "notNull": true }, - "banner_url": { - "name": "banner_url", + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "banner_cld_pub_id": { + "name": "banner_cld_pub_id", "type": "text", "primaryKey": false, "notNull": false }, - "imageCldPubId": { - "name": "imageCldPubId", + "banner_url": { + "name": "banner_url", "type": "text", "primaryKey": false, "notNull": false @@ -71,9 +65,15 @@ "name": "capacity", "type": "integer", "primaryKey": false, - "notNull": false, + "notNull": true, "default": 50 }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, "status": { "name": "status", "type": "class_status", @@ -86,8 +86,7 @@ "name": "schedules", "type": "jsonb", "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" + "notNull": true }, "created_at": { "name": "created_at", @@ -281,8 +280,8 @@ "primaryKey": false, "notNull": true }, - "enrolled_at": { - "name": "enrolled_at", + "created_at": { + "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, @@ -297,11 +296,11 @@ } }, "indexes": { - "enrollments_class_id_idx": { - "name": "enrollments_class_id_idx", + "enrollments_student_id_idx": { + "name": "enrollments_student_id_idx", "columns": [ { - "expression": "class_id", + "expression": "student_id", "isExpression": false, "asc": true, "nulls": "last" @@ -312,11 +311,11 @@ "method": "btree", "with": {} }, - "enrollments_student_id_idx": { - "name": "enrollments_student_id_idx", + "enrollments_class_id_idx": { + "name": "enrollments_class_id_idx", "columns": [ { - "expression": "student_id", + "expression": "class_id", "isExpression": false, "asc": true, "nulls": "last" @@ -327,8 +326,8 @@ "method": "btree", "with": {} }, - "enrollments_student_class_unq": { - "name": "enrollments_student_class_unq", + "enrollments_student_class_unique": { + "name": "enrollments_student_class_unique", "columns": [ { "expression": "student_id", @@ -343,7 +342,7 @@ "nulls": "last" } ], - "isUnique": true, + "isUnique": false, "concurrently": false, "method": "btree", "with": {} @@ -472,6 +471,396 @@ "policies": {}, "checkConstraints": {}, "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_provider_account_unique": { + "name": "account_provider_account_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'student'" + }, + "image_cld_pub_id": { + "name": "image_cld_pub_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false } }, "enums": { @@ -483,6 +872,15 @@ "inactive", "archived" ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "student", + "teacher", + "admin" + ] } }, "schemas": {}, diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json deleted file mode 100644 index b774db0..0000000 --- a/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,878 +0,0 @@ -{ - "id": "d0773d01-8abf-4db0-873e-88183aa6d7f0", - "prevId": "24f3a1d5-fecb-4872-ab75-df89b3434660", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.classes": { - "name": "classes", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "identity": { - "type": "always", - "name": "classes_id_seq", - "schema": "public", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "invite_code": { - "name": "invite_code", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true - }, - "subject_id": { - "name": "subject_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "teacher_id": { - "name": "teacher_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "banner_url": { - "name": "banner_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "imageCldPubId": { - "name": "imageCldPubId", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "capacity": { - "name": "capacity", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 50 - }, - "status": { - "name": "status", - "type": "class_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'active'" - }, - "schedules": { - "name": "schedules", - "type": "jsonb", - "primaryKey": false, - "notNull": true, - "default": "'[]'::jsonb" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "classes_subject_id_idx": { - "name": "classes_subject_id_idx", - "columns": [ - { - "expression": "subject_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "classes_teacher_id_idx": { - "name": "classes_teacher_id_idx", - "columns": [ - { - "expression": "teacher_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "classes_subject_id_subjects_id_fk": { - "name": "classes_subject_id_subjects_id_fk", - "tableFrom": "classes", - "tableTo": "subjects", - "columnsFrom": [ - "subject_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "classes_teacher_id_user_id_fk": { - "name": "classes_teacher_id_user_id_fk", - "tableFrom": "classes", - "tableTo": "user", - "columnsFrom": [ - "teacher_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "classes_invite_code_unique": { - "name": "classes_invite_code_unique", - "nullsNotDistinct": false, - "columns": [ - "invite_code" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.departments": { - "name": "departments", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "identity": { - "type": "always", - "name": "departments_id_seq", - "schema": "public", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } - }, - "code": { - "name": "code", - "type": "varchar(50)", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "departments_code_unique": { - "name": "departments_code_unique", - "nullsNotDistinct": false, - "columns": [ - "code" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.enrollments": { - "name": "enrollments", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "identity": { - "type": "always", - "name": "enrollments_id_seq", - "schema": "public", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } - }, - "student_id": { - "name": "student_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "class_id": { - "name": "class_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "enrolled_at": { - "name": "enrolled_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "enrollments_class_id_idx": { - "name": "enrollments_class_id_idx", - "columns": [ - { - "expression": "class_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "enrollments_student_id_idx": { - "name": "enrollments_student_id_idx", - "columns": [ - { - "expression": "student_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "enrollments_student_class_unq": { - "name": "enrollments_student_class_unq", - "columns": [ - { - "expression": "student_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "class_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "enrollments_student_id_user_id_fk": { - "name": "enrollments_student_id_user_id_fk", - "tableFrom": "enrollments", - "tableTo": "user", - "columnsFrom": [ - "student_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "enrollments_class_id_classes_id_fk": { - "name": "enrollments_class_id_classes_id_fk", - "tableFrom": "enrollments", - "tableTo": "classes", - "columnsFrom": [ - "class_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.subjects": { - "name": "subjects", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "identity": { - "type": "always", - "name": "subjects_id_seq", - "schema": "public", - "increment": "1", - "startWith": "1", - "minValue": "1", - "maxValue": "2147483647", - "cache": "1", - "cycle": false - } - }, - "department_id": { - "name": "department_id", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "code": { - "name": "code", - "type": "varchar(50)", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "subjects_department_id_departments_id_fk": { - "name": "subjects_department_id_departments_id_fk", - "tableFrom": "subjects", - "tableTo": "departments", - "columnsFrom": [ - "department_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "restrict", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "subjects_code_unique": { - "name": "subjects_code_unique", - "nullsNotDistinct": false, - "columns": [ - "code" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.account": { - "name": "account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "account_userId_idx": { - "name": "account_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "session_userId_idx": { - "name": "session_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": [ - "token" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "imageCldPubId": { - "name": "imageCldPubId", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "role": { - "name": "role", - "type": "role", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'student'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "verification_identifier_idx": { - "name": "verification_identifier_idx", - "columns": [ - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.class_status": { - "name": "class_status", - "schema": "public", - "values": [ - "active", - "inactive", - "archived" - ] - }, - "public.role": { - "name": "role", - "schema": "public", - "values": [ - "admin", - "teacher", - "student" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d9bf635..8d4df1d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,15 +5,8 @@ { "idx": 0, "version": "7", - "when": 1767098070770, - "tag": "0000_tidy_mentor", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1767098118358, - "tag": "0001_goofy_nehzno", + "when": 1767619736454, + "tag": "0000_oval_gauntlet", "breakpoints": true } ] diff --git a/src/db/schema/app.ts b/src/db/schema/app.ts index 9e8eba6..ff65ab1 100644 --- a/src/db/schema/app.ts +++ b/src/db/schema/app.ts @@ -1,24 +1,16 @@ import { relations } from "drizzle-orm"; import { - index, integer, jsonb, + index, pgEnum, pgTable, text, timestamp, - uniqueIndex, varchar, } from "drizzle-orm/pg-core"; - import { user } from "./auth"; -export const classStatusEnum = pgEnum("class_status", [ - "active", - "inactive", - "archived", -]); - const timestamps = { createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") @@ -27,6 +19,12 @@ const timestamps = { .notNull(), }; +export const classStatusEnum = pgEnum("class_status", [ + "active", + "inactive", + "archived", +]); + export const departments = pgTable("departments", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), code: varchar("code", { length: 50 }).notNull().unique(), @@ -54,53 +52,53 @@ export const classes = pgTable( "classes", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), - name: varchar("name", { length: 255 }).notNull(), - inviteCode: varchar("invite_code", { length: 20 }).unique().notNull(), + subjectId: integer("subject_id") - .references(() => subjects.id, { onDelete: "cascade" }) - .notNull(), + .notNull() + .references(() => subjects.id, { onDelete: "cascade" }), teacherId: text("teacher_id") .notNull() .references(() => user.id, { onDelete: "restrict" }), - description: text("description"), + + inviteCode: varchar("invite_code", { length: 50 }).notNull().unique(), + name: varchar("name", { length: 255 }).notNull(), + bannerCldPubId: text("banner_cld_pub_id"), bannerUrl: text("banner_url"), - bannerCldPubId: text("imageCldPubId"), - capacity: integer("capacity").default(50), + capacity: integer("capacity").notNull().default(50), + description: text("description"), status: classStatusEnum("status").notNull().default("active"), - schedules: jsonb("schedules").$type().default([]).notNull(), + schedules: jsonb("schedules").$type().notNull(), ...timestamps, }, - (table) => [ - index("classes_subject_id_idx").on(table.subjectId), - index("classes_teacher_id_idx").on(table.teacherId), - ] + (table) => ({ + subjectIdIdx: index("classes_subject_id_idx").on(table.subjectId), + teacherIdIdx: index("classes_teacher_id_idx").on(table.teacherId), + }) ); export const enrollments = pgTable( "enrollments", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + studentId: text("student_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), classId: integer("class_id") - .references(() => classes.id, { onDelete: "cascade" }) - .notNull(), - enrolledAt: timestamp("enrolled_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .defaultNow() - .$onUpdate(() => new Date()) - .notNull(), + .notNull() + .references(() => classes.id, { onDelete: "cascade" }), + + ...timestamps, }, - (table) => [ - index("enrollments_class_id_idx").on(table.classId), - index("enrollments_student_id_idx").on(table.studentId), - uniqueIndex("enrollments_student_class_unq").on( + (table) => ({ + studentIdIdx: index("enrollments_student_id_idx").on(table.studentId), + classIdIdx: index("enrollments_class_id_idx").on(table.classId), + studentClassUnique: index("enrollments_student_class_unique").on( table.studentId, table.classId ), - ] + }) ); export const departmentsRelations = relations(departments, ({ many }) => ({ diff --git a/src/db/schema/auth.ts b/src/db/schema/auth.ts index 6988d53..4fd7032 100644 --- a/src/db/schema/auth.ts +++ b/src/db/schema/auth.ts @@ -6,69 +6,77 @@ import { pgTable, text, timestamp, - varchar, + uniqueIndex, } from "drizzle-orm/pg-core"; -export const roleEnum = pgEnum("role", ["admin", "teacher", "student"]); - -export const user = pgTable("user", { - id: text("id").primaryKey(), - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: boolean("email_verified").default(false).notNull(), - image: text("image"), - imageCldPubId: text("imageCldPubId"), - role: roleEnum("role").default("student").notNull(), +const timestamps = { createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() .$onUpdate(() => new Date()) .notNull(), +}; + +export const roleEnum = pgEnum("role", ["student", "teacher", "admin"]); + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull(), + emailVerified: boolean("email_verified").notNull(), + image: text("image"), + role: roleEnum("role").notNull().default("student"), + imageCldPubId: text("image_cld_pub_id"), + + ...timestamps, }); export const session = pgTable( "session", { id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id), + token: text("token").notNull(), expiresAt: timestamp("expires_at").notNull(), - token: text("token").notNull().unique(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .defaultNow() - .$onUpdate(() => new Date()) - .notNull(), ipAddress: text("ip_address"), userAgent: text("user_agent"), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), + + ...timestamps, }, - (table) => [index("session_userId_idx").on(table.userId)] + (table) => ({ + userIdIdx: index("session_user_id_idx").on(table.userId), + tokenUnique: uniqueIndex("session_token_unique").on(table.token), + }) ); export const account = pgTable( "account", { id: text("id").primaryKey(), - accountId: text("account_id").notNull(), - providerId: text("provider_id").notNull(), userId: text("user_id") .notNull() - .references(() => user.id, { onDelete: "cascade" }), + .references(() => user.id), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), accessToken: text("access_token"), refreshToken: text("refresh_token"), - idToken: text("id_token"), accessTokenExpiresAt: timestamp("access_token_expires_at"), refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), scope: text("scope"), + idToken: text("id_token"), password: text("password"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .defaultNow() - .$onUpdate(() => new Date()) - .notNull(), + + ...timestamps, }, - (table) => [index("account_userId_idx").on(table.userId)] + (table) => ({ + userIdIdx: index("account_user_id_idx").on(table.userId), + accountUnique: uniqueIndex("account_provider_account_unique").on( + table.providerId, + table.accountId + ), + }) ); export const verification = pgTable( @@ -78,30 +86,41 @@ export const verification = pgTable( identifier: text("identifier").notNull(), value: text("value").notNull(), expiresAt: timestamp("expires_at").notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at") - .defaultNow() - .$onUpdate(() => new Date()) - .notNull(), + + ...timestamps, }, - (table) => [index("verification_identifier_idx").on(table.identifier)] + (table) => ({ + identifierIdx: index("verification_identifier_idx").on(table.identifier), + }) ); -export const userRelations = relations(user, ({ many }) => ({ +export const usersRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), })); -export const sessionRelations = relations(session, ({ one }) => ({ +export const sessionsRelations = relations(session, ({ one }) => ({ user: one(user, { fields: [session.userId], references: [user.id], }), })); -export const accountRelations = relations(account, ({ one }) => ({ +export const accountsRelations = relations(account, ({ one }) => ({ user: one(user, { fields: [account.userId], references: [user.id], }), })); + +export type User = typeof user.$inferSelect; +export type NewUser = typeof user.$inferInsert; + +export type Session = typeof session.$inferSelect; +export type NewSession = typeof session.$inferInsert; + +export type Account = typeof account.$inferSelect; +export type NewAccount = typeof account.$inferInsert; + +export type Verification = typeof verification.$inferSelect; +export type NewVerification = typeof verification.$inferInsert; diff --git a/src/routes/classes.ts b/src/routes/classes.ts index af729c7..4cf52d9 100644 --- a/src/routes/classes.ts +++ b/src/routes/classes.ts @@ -25,75 +25,76 @@ router.get( authenticate, authorizeRoles("admin", "teacher", "student"), async (req, res) => { - try { - const { - search, - subjectId, - teacherId, - status, - page = 1, - limit = 10, - } = parseRequest(classListQuerySchema, req.query); - - const filterConditions = []; - - const currentPage = Math.max(1, +page); - const limitPerPage = Math.max(1, +limit); - const offset = (currentPage - 1) * limitPerPage; - - if (search) { - filterConditions.push( - or( - ilike(classes.name, `%${search}%`), - ilike(classes.inviteCode, `%${search}%`) - ) - ); - } + try { + const { + search, + subjectId, + teacherId, + status, + page = 1, + limit = 10, + } = parseRequest(classListQuerySchema, req.query); + + const filterConditions = []; + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + if (search) { + filterConditions.push( + or( + ilike(classes.name, `%${search}%`), + ilike(classes.inviteCode, `%${search}%`) + ) + ); + } - if (subjectId) { - filterConditions.push(eq(classes.subjectId, subjectId)); - } + if (subjectId) { + filterConditions.push(eq(classes.subjectId, subjectId)); + } - if (teacherId) { - filterConditions.push(eq(classes.teacherId, teacherId)); - } + if (teacherId) { + filterConditions.push(eq(classes.teacherId, teacherId)); + } - if (status) { - filterConditions.push(eq(classes.status, status)); - } + if (status) { + filterConditions.push(eq(classes.status, status)); + } - const whereClause = - filterConditions.length > 0 ? and(...filterConditions) : undefined; - - const countResult = await db - .select({ count: sql`count(*)` }) - .from(classes) - .where(whereClause); - - const totalCount = countResult[0]?.count ?? 0; - - const classesList = await db - .select() - .from(classes) - .where(whereClause) - .orderBy(desc(classes.createdAt)) - .limit(limitPerPage) - .offset(offset); - - res.status(200).json({ - data: classesList, - pagination: { - page: currentPage, - limit: limitPerPage, - total: totalCount, - totalPages: Math.ceil(totalCount / limitPerPage), - }, - }); - } catch (error) { - console.error("GET /classes error:", error); - res.status(500).json({ error: "Failed to fetch classes" }); + const whereClause = + filterConditions.length > 0 ? and(...filterConditions) : undefined; + + const countResult = await db + .select({ count: sql`count(*)` }) + .from(classes) + .where(whereClause); + + const totalCount = countResult[0]?.count ?? 0; + + const classesList = await db + .select() + .from(classes) + .where(whereClause) + .orderBy(desc(classes.createdAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: classesList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + }); + } catch (error) { + console.error("GET /classes error:", error); + res.status(500).json({ error: "Failed to fetch classes" }); + } } -}); +); // Get class by invite code router.get( @@ -101,19 +102,21 @@ router.get( authenticate, authorizeRoles("admin", "teacher", "student"), async (req, res) => { - try { - const { code } = parseRequest(classInviteParamSchema, req.params); + try { + const { code } = parseRequest(classInviteParamSchema, req.params); - const classRecord = await getClassByInviteCode(code); + const classRecord = await getClassByInviteCode(code); - if (!classRecord) return res.status(404).json({ error: "Class not found" }); + if (!classRecord) + return res.status(404).json({ error: "Class not found" }); - res.status(200).json({ data: classRecord }); - } catch (error) { - console.error("GET /classes/invite/:code error:", error); - res.status(500).json({ error: "Failed to fetch class" }); + res.status(200).json({ data: classRecord }); + } catch (error) { + console.error("GET /classes/invite/:code error:", error); + res.status(500).json({ error: "Failed to fetch class" }); + } } -}); +); // List users in a class by role with pagination router.get( @@ -121,91 +124,95 @@ router.get( authenticate, authorizeRoles("admin", "teacher", "student"), async (req, res) => { - try { - const { id: classId } = parseRequest(classIdParamSchema, req.params); - const { role, page = 1, limit = 10 } = parseRequest( - classUsersQuerySchema, - req.query - ); - - const currentPage = Math.max(1, +page); - const limitPerPage = Math.max(1, +limit); - const offset = (currentPage - 1) * limitPerPage; - - const baseSelect = { - id: user.id, - name: user.name, - email: user.email, - emailVerified: user.emailVerified, - image: user.image, - role: user.role, - imageCldPubId: user.imageCldPubId, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }; - - const groupByFields = [ - user.id, - user.name, - user.email, - user.emailVerified, - user.image, - user.role, - user.imageCldPubId, - user.createdAt, - user.updatedAt, - ]; - - const countResult = - role === "teacher" - ? await db - .select({ count: sql`count(distinct ${user.id})` }) - .from(user) - .leftJoin(classes, eq(user.id, classes.teacherId)) - .where(and(eq(user.role, role), eq(classes.id, classId))) - : await db - .select({ count: sql`count(distinct ${user.id})` }) - .from(user) - .leftJoin(enrollments, eq(user.id, enrollments.studentId)) - .where(and(eq(user.role, role), eq(enrollments.classId, classId))); - - const totalCount = countResult[0]?.count ?? 0; - - const usersList = - role === "teacher" - ? await db - .select(baseSelect) - .from(user) - .leftJoin(classes, eq(user.id, classes.teacherId)) - .where(and(eq(user.role, role), eq(classes.id, classId))) - .groupBy(...groupByFields) - .orderBy(desc(user.createdAt)) - .limit(limitPerPage) - .offset(offset) - : await db - .select(baseSelect) - .from(user) - .leftJoin(enrollments, eq(user.id, enrollments.studentId)) - .where(and(eq(user.role, role), eq(enrollments.classId, classId))) - .groupBy(...groupByFields) - .orderBy(desc(user.createdAt)) - .limit(limitPerPage) - .offset(offset); - - res.status(200).json({ - data: usersList, - pagination: { - page: currentPage, - limit: limitPerPage, - total: totalCount, - totalPages: Math.ceil(totalCount / limitPerPage), - }, - }); - } catch (error) { - console.error("GET /classes/:id/users error:", error); - res.status(500).json({ error: "Failed to fetch class users" }); + try { + const { id: classId } = parseRequest(classIdParamSchema, req.params); + const { + role, + page = 1, + limit = 10, + } = parseRequest(classUsersQuerySchema, req.query); + + const currentPage = Math.max(1, +page); + const limitPerPage = Math.max(1, +limit); + const offset = (currentPage - 1) * limitPerPage; + + const baseSelect = { + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + image: user.image, + role: user.role, + imageCldPubId: user.imageCldPubId, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + + const groupByFields = [ + user.id, + user.name, + user.email, + user.emailVerified, + user.image, + user.role, + user.imageCldPubId, + user.createdAt, + user.updatedAt, + ]; + + const countResult = + role === "teacher" + ? await db + .select({ count: sql`count(distinct ${user.id})` }) + .from(user) + .leftJoin(classes, eq(user.id, classes.teacherId)) + .where(and(eq(user.role, role), eq(classes.id, classId))) + : await db + .select({ count: sql`count(distinct ${user.id})` }) + .from(user) + .leftJoin(enrollments, eq(user.id, enrollments.studentId)) + .where( + and(eq(user.role, role), eq(enrollments.classId, classId)) + ); + + const totalCount = countResult[0]?.count ?? 0; + + const usersList = + role === "teacher" + ? await db + .select(baseSelect) + .from(user) + .leftJoin(classes, eq(user.id, classes.teacherId)) + .where(and(eq(user.role, role), eq(classes.id, classId))) + .groupBy(...groupByFields) + .orderBy(desc(user.createdAt)) + .limit(limitPerPage) + .offset(offset) + : await db + .select(baseSelect) + .from(user) + .leftJoin(enrollments, eq(user.id, enrollments.studentId)) + .where(and(eq(user.role, role), eq(enrollments.classId, classId))) + .groupBy(...groupByFields) + .orderBy(desc(user.createdAt)) + .limit(limitPerPage) + .offset(offset); + + res.status(200).json({ + data: usersList, + pagination: { + page: currentPage, + limit: limitPerPage, + total: totalCount, + totalPages: Math.ceil(totalCount / limitPerPage), + }, + }); + } catch (error) { + console.error("GET /classes/:id/users error:", error); + res.status(500).json({ error: "Failed to fetch class users" }); + } } -}); +); // Get class by ID router.get( @@ -213,21 +220,22 @@ router.get( authenticate, authorizeRoles("admin", "teacher", "student"), async (req, res) => { - try { - const { id: classId } = parseRequest(classIdParamSchema, req.params); + try { + const { id: classId } = parseRequest(classIdParamSchema, req.params); - const classRecord = await getClassById(classId); + const classRecord = await getClassById(classId); - if (!classRecord) { - return res.status(404).json({ error: "Class not found" }); - } + if (!classRecord) { + return res.status(404).json({ error: "Class not found" }); + } - res.status(200).json({ data: classRecord }); - } catch (error) { - console.error("GET /classes/:id error:", error); - res.status(500).json({ error: "Failed to fetch class" }); + res.status(200).json({ data: classRecord }); + } catch (error) { + console.error("GET /classes/:id error:", error); + res.status(500).json({ error: "Failed to fetch class" }); + } } -}); +); // Create class router.post( @@ -235,69 +243,70 @@ router.post( authenticate, authorizeRoles("admin", "teacher"), async (req, res) => { - try { - const { - subjectId, - name, - teacherId, - bannerCldPubId, - bannerUrl, - capacity, - description, - status, - } = parseRequest(classCreateSchema, req.body); - - const subject = await getSubjectById(subjectId); - if (!subject) { - return res.status(404).json({ error: "Subject not found" }); - } - - const teacher = await getUserById(teacherId); - if (!teacher) return res.status(404).json({ error: "Teacher not found" }); - - let inviteCode: string | undefined; - for (let attempt = 0; attempt < 5; attempt += 1) { - const candidate = Math.random().toString(36).substring(2, 9); - const existingInvite = await getClassByInviteCode(candidate); - if (!existingInvite) { - inviteCode = candidate; - break; - } - } - - if (!inviteCode) { - return res - .status(500) - .json({ error: "Failed to generate invite code" }); - } - - const [createdClass] = await db - .insert(classes) - .values({ + try { + const { subjectId, - inviteCode, name, teacherId, bannerCldPubId, bannerUrl, capacity, description, - schedules: [], status, - }) - .returning({ id: classes.id }); + } = parseRequest(classCreateSchema, req.body); - if (!createdClass) - return res.status(500).json({ error: "Failed to create class" }); + const subject = await getSubjectById(subjectId); + if (!subject) { + return res.status(404).json({ error: "Subject not found" }); + } + + const teacher = await getUserById(teacherId); + if (!teacher) return res.status(404).json({ error: "Teacher not found" }); + + let inviteCode: string | undefined; + for (let attempt = 0; attempt < 5; attempt += 1) { + const candidate = Math.random().toString(36).substring(2, 9); + const existingInvite = await getClassByInviteCode(candidate); + if (!existingInvite) { + inviteCode = candidate; + break; + } + } - const classRecord = await getClassById(createdClass.id); + if (!inviteCode) { + return res + .status(500) + .json({ error: "Failed to generate invite code" }); + } - res.status(201).json({ data: classRecord }); - } catch (error) { - console.error("POST /classes error:", error); - res.status(500).json({ error: "Failed to create class" }); + const [createdClass] = await db + .insert(classes) + .values({ + subjectId, + inviteCode, + name, + teacherId, + bannerCldPubId, + bannerUrl, + capacity, + description, + schedules: [], + status, + }) + .returning({ id: classes.id }); + + if (!createdClass) + return res.status(500).json({ error: "Failed to create class" }); + + const classRecord = await getClassById(createdClass.id); + + res.status(201).json({ data: classRecord }); + } catch (error) { + console.error("POST /classes error:", error); + res.status(500).json({ error: "Failed to create class" }); + } } -}); +); // Update class router.put( @@ -305,62 +314,64 @@ router.put( authenticate, authorizeRoles("admin", "teacher"), async (req, res) => { - try { - const { id: classId } = parseRequest(classIdParamSchema, req.params); - const { - subjectId, - inviteCode, - bannerCldPubId, - bannerUrl, - capacity, - description, - name, - schedules, - status, - } = parseRequest(classUpdateSchema, req.body); - - const existingClass = await getClassById(classId); - if (!existingClass) - return res.status(404).json({ error: "Class not found" }); - - if (subjectId) { - const subject = await getSubjectById(subjectId); - if (!subject) return res.status(404).json({ error: "Subject not found" }); - } + try { + const { id: classId } = parseRequest(classIdParamSchema, req.params); + const { + subjectId, + inviteCode, + bannerCldPubId, + bannerUrl, + capacity, + description, + name, + schedules, + status, + } = parseRequest(classUpdateSchema, req.body); - if (inviteCode) { - const existingInvite = await getClassByInviteCode(inviteCode); + const existingClass = await getClassById(classId); + if (!existingClass) + return res.status(404).json({ error: "Class not found" }); - if (existingInvite && existingInvite.id !== classId) - return res.status(409).json({ error: "Invite code already exists" }); - } + if (subjectId) { + const subject = await getSubjectById(subjectId); + if (!subject) + return res.status(404).json({ error: "Subject not found" }); + } - const updateValues: Record = {}; - - for (const [key, value] of Object.entries({ - subjectId, - inviteCode, - bannerCldPubId, - bannerUrl, - capacity, - description, - name, - schedules, - status, - })) { - if (value) updateValues[key] = value; - } + if (inviteCode) { + const existingInvite = await getClassByInviteCode(inviteCode); - await db.update(classes).set(updateValues).where(eq(classes.id, classId)); + if (existingInvite && existingInvite.id !== classId) + return res.status(409).json({ error: "Invite code already exists" }); + } - const classRecord = await getClassById(classId); + const updateValues: Record = {}; - res.status(200).json({ data: classRecord }); - } catch (error) { - console.error("PUT /classes/:id error:", error); - res.status(500).json({ error: "Failed to update class" }); + for (const [key, value] of Object.entries({ + subjectId, + inviteCode, + bannerCldPubId, + bannerUrl, + capacity, + description, + name, + schedules, + status, + })) { + if (value) updateValues[key] = value; + } + + await db.update(classes).set(updateValues).where(eq(classes.id, classId)); + + const classRecord = await getClassById(classId); + + res.status(200).json({ data: classRecord }); + } catch (error) { + console.error("PUT /classes/:id error:", error); + res.status(500).json({ error: "Failed to update class" }); + } } -}); +); // Delete class router.delete( @@ -368,22 +379,23 @@ router.delete( authenticate, authorizeRoles("admin", "teacher"), async (req, res) => { - try { - const { id: classId } = parseRequest(classIdParamSchema, req.params); + try { + const { id: classId } = parseRequest(classIdParamSchema, req.params); - const deletedRows = await db - .delete(classes) - .where(eq(classes.id, classId)) - .returning({ id: classes.id }); + const deletedRows = await db + .delete(classes) + .where(eq(classes.id, classId)) + .returning({ id: classes.id }); - if (deletedRows.length === 0) - return res.status(404).json({ error: "Class not found" }); + if (deletedRows.length === 0) + return res.status(404).json({ error: "Class not found" }); - res.status(200).json({ message: "Class deleted" }); - } catch (error) { - console.error("DELETE /classes/:id error:", error); - res.status(500).json({ error: "Failed to delete class" }); + res.status(200).json({ message: "Class deleted" }); + } catch (error) { + console.error("DELETE /classes/:id error:", error); + res.status(500).json({ error: "Failed to delete class" }); + } } -}); +); export default router; diff --git a/src/routes/enrollments.ts b/src/routes/enrollments.ts index 627422c..23e097b 100644 --- a/src/routes/enrollments.ts +++ b/src/routes/enrollments.ts @@ -63,7 +63,7 @@ router.get( .select() .from(enrollments) .where(whereClause) - .orderBy(desc(enrollments.enrolledAt)) + .orderBy(desc(enrollments.createdAt)) .limit(limitPerPage) .offset(offset); diff --git a/src/seed/data.json b/src/seed/data.json index 1ea2ccf..5fcb9bf 100644 --- a/src/seed/data.json +++ b/src/seed/data.json @@ -1,181 +1,572 @@ { + "users": [ + { + "id": "admin_1", + "name": "Ava Collins", + "email": "ava.admin@classroom.dev", + "role": "admin", + "password": "Admin#1234", + "image": "https://images.unsplash.com/photo-1502685104226-ee32379fefbe?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "teacher_1", + "name": "Liam Patel", + "email": "liam.patel@classroom.dev", + "role": "teacher", + "password": "Teach#1234", + "image": "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "teacher_2", + "name": "Noah Kim", + "email": "noah.kim@classroom.dev", + "role": "teacher", + "password": "Teach#5678", + "image": "https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "teacher_3", + "name": "Mia Lopez", + "email": "mia.lopez@classroom.dev", + "role": "teacher", + "password": "Teach#9012", + "image": "https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "teacher_4", + "name": "Ethan Brooks", + "email": "ethan.brooks@classroom.dev", + "role": "teacher", + "password": "Teach#3456", + "image": "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "teacher_5", + "name": "Priya Nair", + "email": "priya.nair@classroom.dev", + "role": "teacher", + "password": "Teach#7890", + "image": "https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "student_1", + "name": "Arjun Singh", + "email": "arjun.singh@classroom.dev", + "role": "student", + "password": "Student#123", + "image": "https://images.unsplash.com/photo-1545996124-0501ebae84d0?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "student_2", + "name": "Emily Chen", + "email": "emily.chen@classroom.dev", + "role": "student", + "password": "Student#234", + "image": "https://images.unsplash.com/photo-1527980965255-d3b416303d12?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "student_3", + "name": "Zoe Parker", + "email": "zoe.parker@classroom.dev", + "role": "student", + "password": "Student#345", + "image": "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "student_4", + "name": "Jacob Miller", + "email": "jacob.miller@classroom.dev", + "role": "student", + "password": "Student#456", + "image": "https://images.unsplash.com/photo-1517841905240-472988babdf9?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "student_5", + "name": "Sara James", + "email": "sara.james@classroom.dev", + "role": "student", + "password": "Student#567", + "image": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "student_6", + "name": "Daniel Wright", + "email": "daniel.wright@classroom.dev", + "role": "student", + "password": "Student#678", + "image": "https://images.unsplash.com/photo-1525134479668-1bee5c7c6845?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "student_7", + "name": "Nina Alvarez", + "email": "nina.alvarez@classroom.dev", + "role": "student", + "password": "Student#789", + "image": "https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "student_8", + "name": "Omar Farouk", + "email": "omar.farouk@classroom.dev", + "role": "student", + "password": "Student#890", + "image": "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "student_9", + "name": "Grace Wilson", + "email": "grace.wilson@classroom.dev", + "role": "student", + "password": "Student#901", + "image": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "student_10", + "name": "Leo Andersen", + "email": "leo.andersen@classroom.dev", + "role": "student", + "password": "Student#012", + "image": "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "student_11", + "name": "Hannah Ross", + "email": "hannah.ross@classroom.dev", + "role": "student", + "password": "Student#135", + "image": "https://images.unsplash.com/photo-1527980965255-d3b416303d12?auto=format&fit=crop&w=400&q=80" + }, + { + "id": "student_12", + "name": "Ibrahim Yusuf", + "email": "ibrahim.yusuf@classroom.dev", + "role": "student", + "password": "Student#246", + "image": "https://images.unsplash.com/photo-1545996124-0501ebae84d0?auto=format&fit=crop&w=400&q=80" + } + ], "departments": [ { "code": "CS", "name": "Computer Science", - "description": "Core computing curriculum" + "description": "Programming, data structures, and systems fundamentals." }, { "code": "MATH", "name": "Mathematics", - "description": "Pure and applied math" + "description": "Foundations of calculus, algebra, and statistics." }, { - "code": "ENG", - "name": "English", - "description": "Literature and composition" + "code": "BIO", + "name": "Biology", + "description": "Life sciences and laboratory methods." }, { - "code": "PHY", - "name": "Physics", - "description": "Classical and modern physics" + "code": "BUS", + "name": "Business", + "description": "Management, finance, and entrepreneurship." } ], "subjects": [ { "code": "CS101", "name": "Intro to Programming", - "description": "Programming fundamentals", + "description": "Python basics and computational thinking.", "departmentCode": "CS" }, + { + "code": "CS102", + "name": "Programming Fundamentals", + "description": "Control flow, functions, and debugging.", + "departmentCode": "CS" + }, + { + "code": "CS201", + "name": "Web Development", + "description": "Modern frontend and backend fundamentals.", + "departmentCode": "CS" + }, + { + "code": "CS202", + "name": "Databases", + "description": "SQL, schema design, and data modeling.", + "departmentCode": "CS" + }, + { + "code": "CS301", + "name": "Data Structures", + "description": "Lists, trees, graphs, and complexity.", + "departmentCode": "CS" + }, + { + "code": "CS401", + "name": "Software Engineering", + "description": "Team workflows, testing, and delivery.", + "departmentCode": "CS" + }, + { + "code": "MATH101", + "name": "Calculus I", + "description": "Limits, derivatives, and integrals.", + "departmentCode": "MATH" + }, + { + "code": "MATH102", + "name": "Calculus II", + "description": "Series, sequences, and applications.", + "departmentCode": "MATH" + }, { "code": "MATH201", "name": "Linear Algebra", - "description": "Vectors and matrices", + "description": "Vectors, matrices, and transformations.", "departmentCode": "MATH" }, { - "code": "ENG110", - "name": "Composition I", - "description": "Academic writing essentials", - "departmentCode": "ENG" + "code": "MATH301", + "name": "Statistics", + "description": "Probability, inference, and modeling.", + "departmentCode": "MATH" }, { - "code": "PHY150", - "name": "General Physics", - "description": "Mechanics and thermodynamics", - "departmentCode": "PHY" - } - ], - "users": [ + "code": "MATH302", + "name": "Discrete Mathematics", + "description": "Logic, sets, and combinatorics.", + "departmentCode": "MATH" + }, + { + "code": "BIO101", + "name": "General Biology", + "description": "Cells, genetics, and ecosystems.", + "departmentCode": "BIO" + }, + { + "code": "BIO201", + "name": "Human Anatomy", + "description": "Systems, structures, and functions.", + "departmentCode": "BIO" + }, { - "id": "user_admin_1", - "name": "Admin User", - "email": "admin@classroom.test", - "emailVerified": true, - "role": "admin" + "code": "BIO202", + "name": "Microbiology", + "description": "Microbes, immunity, and lab techniques.", + "departmentCode": "BIO" }, { - "id": "user_teacher_1", - "name": "Teacher One", - "email": "teacher1@classroom.test", - "emailVerified": true, - "role": "teacher" + "code": "BIO301", + "name": "Genetics", + "description": "Genes, inheritance, and biotech.", + "departmentCode": "BIO" }, { - "id": "user_teacher_2", - "name": "Teacher Two", - "email": "teacher2@classroom.test", - "emailVerified": true, - "role": "teacher" + "code": "BUS101", + "name": "Intro to Business", + "description": "Business models, markets, and operations.", + "departmentCode": "BUS" }, { - "id": "user_student_1", - "name": "Student One", - "email": "student1@classroom.test", - "emailVerified": true, - "role": "student" + "code": "BUS201", + "name": "Marketing Essentials", + "description": "Branding, campaigns, and analytics.", + "departmentCode": "BUS" }, { - "id": "user_student_2", - "name": "Student Two", - "email": "student2@classroom.test", - "emailVerified": true, - "role": "student" + "code": "BUS301", + "name": "Finance Basics", + "description": "Budgets, cash flow, and valuation.", + "departmentCode": "BUS" } ], "classes": [ { - "name": "CS101 - Section A", - "inviteCode": "CS101A", - "subjectCode": "CS101", - "teacherId": "user_teacher_1", - "description": "Morning section", + "name": "Intro to Programming - Section A", + "description": "Hands-on coding labs and weekly challenges.", "capacity": 35, "status": "active", - "schedules": [ - { - "day": "Mon", - "startTime": "09:00", - "endTime": "10:30" - }, - { - "day": "Wed", - "startTime": "09:00", - "endTime": "10:30" - } - ] - }, - { - "name": "CS101 - Section B", - "inviteCode": "CS101B", + "inviteCode": "cs101-a", "subjectCode": "CS101", - "teacherId": "user_teacher_2", - "description": "Afternoon section", + "teacherId": "teacher_1", + "bannerUrl": "https://images.unsplash.com/photo-1509062522246-3755977927d7?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Programming Fundamentals - Section B", + "description": "Practice sessions with weekly quizzes.", + "capacity": 30, + "status": "active", + "inviteCode": "cs102-b", + "subjectCode": "CS102", + "teacherId": "teacher_4", + "bannerUrl": "https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Web Development Bootcamp", + "description": "Build full-stack apps and deploy them.", "capacity": 40, "status": "active", - "schedules": [ - { - "day": "Tue", - "startTime": "14:00", - "endTime": "15:30" - }, - { - "day": "Thu", - "startTime": "14:00", - "endTime": "15:30" - } - ] - }, - { - "name": "MATH201 - Section A", - "inviteCode": "M201A", - "subjectCode": "MATH201", - "teacherId": "user_teacher_1", - "description": "Evening section", + "inviteCode": "cs201-bootcamp", + "subjectCode": "CS201", + "teacherId": "teacher_2", + "bannerUrl": "https://images.unsplash.com/photo-1498079022511-d15614cb1c02?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Databases - Practical", + "description": "Schema design and query workshops.", + "capacity": 32, + "status": "active", + "inviteCode": "cs202-practical", + "subjectCode": "CS202", + "teacherId": "teacher_5", + "bannerUrl": "https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Data Structures - Section A", + "description": "Implementation deep dives and coding drills.", + "capacity": 28, + "status": "active", + "inviteCode": "cs301-a", + "subjectCode": "CS301", + "teacherId": "teacher_3", + "bannerUrl": "https://images.unsplash.com/photo-1523240795612-9a054b0db644?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Software Engineering Studio", + "description": "Agile sprints and peer reviews.", + "capacity": 36, + "status": "active", + "inviteCode": "cs401-studio", + "subjectCode": "CS401", + "teacherId": "teacher_1", + "bannerUrl": "https://images.unsplash.com/photo-1485217988980-11786ced9454?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Calculus I - Morning", + "description": "Problem solving sessions and review quizzes.", "capacity": 30, "status": "active", - "schedules": [ - { - "day": "Mon", - "startTime": "17:00", - "endTime": "18:30" - } - ] + "inviteCode": "math101-morning", + "subjectCode": "MATH101", + "teacherId": "teacher_3", + "bannerUrl": "https://images.unsplash.com/photo-1523240795612-9a054b0db644?auto=format&fit=crop&w=1200&q=80" }, { - "name": "ENG110 - Section A", - "inviteCode": "ENG110A", - "subjectCode": "ENG110", - "teacherId": "user_teacher_2", - "description": "Writing workshop", + "name": "Calculus II - Evening", + "description": "Advanced integration and applications.", + "capacity": 30, + "status": "active", + "inviteCode": "math102-evening", + "subjectCode": "MATH102", + "teacherId": "teacher_4", + "bannerUrl": "https://images.unsplash.com/photo-1509062522246-3755977927d7?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Linear Algebra - Section A", + "description": "Matrix methods with weekly labs.", + "capacity": 32, + "status": "active", + "inviteCode": "math201-a", + "subjectCode": "MATH201", + "teacherId": "teacher_1", + "bannerUrl": "https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Statistics - Applied", + "description": "Data analysis with real-world datasets.", + "capacity": 34, + "status": "active", + "inviteCode": "math301-applied", + "subjectCode": "MATH301", + "teacherId": "teacher_3", + "bannerUrl": "https://images.unsplash.com/photo-1498079022511-d15614cb1c02?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Discrete Mathematics - Workshop", + "description": "Logic puzzles and proofs.", + "capacity": 26, + "status": "active", + "inviteCode": "math302-workshop", + "subjectCode": "MATH302", + "teacherId": "teacher_5", + "bannerUrl": "https://images.unsplash.com/photo-1485217988980-11786ced9454?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "General Biology Lab", + "description": "Lab-focused biology with weekly experiments.", "capacity": 25, "status": "active", - "schedules": [ - { - "day": "Wed", - "startTime": "11:00", - "endTime": "12:30" - } - ] + "inviteCode": "bio101-lab", + "subjectCode": "BIO101", + "teacherId": "teacher_1", + "bannerUrl": "https://images.unsplash.com/photo-1503676260728-1c00da094a0b?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Human Anatomy - Section A", + "description": "Detailed anatomy with guided labs.", + "capacity": 28, + "status": "active", + "inviteCode": "bio201-a", + "subjectCode": "BIO201", + "teacherId": "teacher_4", + "bannerUrl": "https://images.unsplash.com/photo-1523240795612-9a054b0db644?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Microbiology - Intensive", + "description": "Microbes, immunology, and lab safety.", + "capacity": 24, + "status": "active", + "inviteCode": "bio202-intensive", + "subjectCode": "BIO202", + "teacherId": "teacher_5", + "bannerUrl": "https://images.unsplash.com/photo-1485217988980-11786ced9454?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Genetics - Research Focus", + "description": "Genetics projects and case studies.", + "capacity": 26, + "status": "active", + "inviteCode": "bio301-research", + "subjectCode": "BIO301", + "teacherId": "teacher_1", + "bannerUrl": "https://images.unsplash.com/photo-1498079022511-d15614cb1c02?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Intro to Business - Evening", + "description": "Case studies and team presentations.", + "capacity": 45, + "status": "active", + "inviteCode": "bus101-evening", + "subjectCode": "BUS101", + "teacherId": "teacher_2", + "bannerUrl": "https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Marketing Essentials - Lab", + "description": "Campaign planning and analytics tools.", + "capacity": 38, + "status": "active", + "inviteCode": "bus201-lab", + "subjectCode": "BUS201", + "teacherId": "teacher_4", + "bannerUrl": "https://images.unsplash.com/photo-1509062522246-3755977927d7?auto=format&fit=crop&w=1200&q=80" + }, + { + "name": "Finance Basics - Section A", + "description": "Budgeting, cash flow, and valuation.", + "capacity": 36, + "status": "active", + "inviteCode": "bus301-a", + "subjectCode": "BUS301", + "teacherId": "teacher_1", + "bannerUrl": "https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?auto=format&fit=crop&w=1200&q=80" } ], "enrollments": [ { - "classInviteCode": "CS101A", - "studentId": "user_student_1" + "classInviteCode": "cs101-a", + "studentId": "student_1" + }, + { + "classInviteCode": "cs101-a", + "studentId": "student_2" + }, + { + "classInviteCode": "cs102-b", + "studentId": "student_3" + }, + { + "classInviteCode": "cs201-bootcamp", + "studentId": "student_4" + }, + { + "classInviteCode": "cs202-practical", + "studentId": "student_5" + }, + { + "classInviteCode": "cs301-a", + "studentId": "student_6" + }, + { + "classInviteCode": "cs401-studio", + "studentId": "student_1" + }, + { + "classInviteCode": "math101-morning", + "studentId": "student_2" + }, + { + "classInviteCode": "math201-a", + "studentId": "student_3" + }, + { + "classInviteCode": "math301-applied", + "studentId": "student_4" + }, + { + "classInviteCode": "bio101-lab", + "studentId": "student_5" + }, + { + "classInviteCode": "bio202-intensive", + "studentId": "student_6" + }, + { + "classInviteCode": "bus101-evening", + "studentId": "student_1" + }, + { + "classInviteCode": "bus201-lab", + "studentId": "student_2" + }, + { + "classInviteCode": "bus301-a", + "studentId": "student_3" + }, + { + "classInviteCode": "cs102-b", + "studentId": "student_7" + }, + { + "classInviteCode": "cs201-bootcamp", + "studentId": "student_8" + }, + { + "classInviteCode": "cs301-a", + "studentId": "student_9" + }, + { + "classInviteCode": "math102-evening", + "studentId": "student_10" + }, + { + "classInviteCode": "math302-workshop", + "studentId": "student_11" + }, + { + "classInviteCode": "bio201-a", + "studentId": "student_12" + }, + { + "classInviteCode": "bus201-lab", + "studentId": "student_7" + }, + { + "classInviteCode": "bus301-a", + "studentId": "student_8" + }, + { + "classInviteCode": "cs401-studio", + "studentId": "student_9" }, { - "classInviteCode": "CS101B", - "studentId": "user_student_2" + "classInviteCode": "bio301-research", + "studentId": "student_10" }, { - "classInviteCode": "M201A", - "studentId": "user_student_1" + "classInviteCode": "math301-applied", + "studentId": "student_11" }, { - "classInviteCode": "ENG110A", - "studentId": "user_student_2" + "classInviteCode": "cs202-practical", + "studentId": "student_12" } ] -} +} \ No newline at end of file diff --git a/src/seed/seed.ts b/src/seed/seed.ts index 9c1ca96..cefabff 100644 --- a/src/seed/seed.ts +++ b/src/seed/seed.ts @@ -1,61 +1,80 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { inArray } from "drizzle-orm"; import { db } from "../db"; -import { departments, subjects, classes, enrollments } from "../db/schema"; -import { user } from "../db/schema/auth"; +import { + account, + classes, + departments, + enrollments, + session, + subjects, + user, +} from "../db/schema"; + +type SeedUser = { + id: string; + name: string; + email: string; + role: "student" | "teacher" | "admin"; + password: string; + image: string; +}; + +type SeedDepartment = { + code: string; + name: string; + description: string; +}; + +type SeedSubject = { + code: string; + name: string; + description: string; + departmentCode: string; +}; + +type SeedClass = { + name: string; + description: string; + capacity: number; + status: "active" | "inactive" | "archived"; + inviteCode: string; + subjectCode: string; + teacherId: string; + bannerUrl: string; +}; + +type SeedEnrollment = { + classInviteCode: string; + studentId: string; +}; type SeedData = { - departments: Array<{ - code: string; - name: string; - description?: string | null; - }>; - subjects: Array<{ - code: string; - name: string; - description?: string | null; - departmentCode: string; - }>; - users: Array<{ - id: string; - name: string; - email: string; - emailVerified?: boolean; - image?: string | null; - imageCldPubId?: string | null; - role?: "admin" | "teacher" | "student"; - }>; - classes: Array<{ - name: string; - inviteCode: string; - subjectCode: string; - teacherId: string; - description?: string | null; - bannerUrl?: string | null; - bannerCldPubId?: string | null; - capacity?: number; - status?: "active" | "inactive" | "archived"; - schedules?: Array<{ - day: string; - startTime: string; - endTime: string; - }>; - }>; - enrollments: Array<{ - classInviteCode: string; - studentId: string; - }>; + users: SeedUser[]; + departments: SeedDepartment[]; + subjects: SeedSubject[]; + classes: SeedClass[]; + enrollments: SeedEnrollment[]; }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const loadSeedData = async (): Promise => { - const filePath = path.join(__dirname, "data.json"); - const fileContents = await readFile(filePath, "utf8"); - return JSON.parse(fileContents) as SeedData; + const dataPath = path.join(__dirname, "data.json"); + const raw = await readFile(dataPath, "utf-8"); + return JSON.parse(raw) as SeedData; +}; + +const ensureMapValue = (map: Map, key: string, label: string) => { + const value = map.get(key); + if (!value) { + throw new Error(`Missing ${label} for key: ${key}`); + } + return value; }; const seed = async () => { @@ -65,89 +84,131 @@ const seed = async () => { await db.delete(classes); await db.delete(subjects); await db.delete(departments); + await db.delete(session); + await db.delete(account); await db.delete(user); - const departmentRows = await db - .insert(departments) - .values( - data.departments.map((department) => ({ - code: department.code, - name: department.name, - description: department.description ?? null, - })) - ) - .returning({ id: departments.id, code: departments.code }); - - const departmentIdByCode = new Map( - departmentRows.map((department) => [department.code, department.id]) - ); + if (data.users.length) { + await db + .insert(user) + .values( + data.users.map((seedUser) => ({ + id: seedUser.id, + name: seedUser.name, + email: seedUser.email, + emailVerified: true, + image: seedUser.image, + role: seedUser.role, + })) + ) + .onConflictDoNothing({ target: user.id }); + + await db + .insert(account) + .values( + data.users.map((seedUser) => ({ + id: `acc_${seedUser.id}`, + userId: seedUser.id, + accountId: seedUser.email, + providerId: "credentials", + password: seedUser.password, + })) + ) + .onConflictDoNothing({ target: [account.providerId, account.accountId] }); + } - const subjectRows = await db - .insert(subjects) - .values( - data.subjects.map((subject) => ({ - code: subject.code, - name: subject.name, - description: subject.description ?? null, - departmentId: departmentIdByCode.get(subject.departmentCode)!, - })) - ) - .returning({ id: subjects.id, code: subjects.code }); - - const subjectIdByCode = new Map( - subjectRows.map((subject) => [subject.code, subject.id]) - ); + if (data.departments.length) { + await db + .insert(departments) + .values( + data.departments.map((dept) => ({ + code: dept.code, + name: dept.name, + description: dept.description, + })) + ) + .onConflictDoNothing({ target: departments.code }); + } - await db - .insert(user) - .values( - data.users.map((seedUser) => ({ - id: seedUser.id, - name: seedUser.name, - email: seedUser.email, - emailVerified: seedUser.emailVerified ?? false, - image: seedUser.image ?? null, - imageCldPubId: seedUser.imageCldPubId ?? null, - role: seedUser.role ?? "student", - })) - ) - .returning({ id: user.id }); - - const classRows = await db - .insert(classes) - .values( - data.classes.map((classItem) => ({ - name: classItem.name, - inviteCode: classItem.inviteCode, - subjectId: subjectIdByCode.get(classItem.subjectCode)!, - teacherId: classItem.teacherId, - description: classItem.description ?? null, - bannerUrl: classItem.bannerUrl ?? null, - bannerCldPubId: classItem.bannerCldPubId ?? null, - capacity: classItem.capacity ?? 50, - status: classItem.status ?? "active", - schedules: classItem.schedules ?? [], - })) - ) - .returning({ id: classes.id, inviteCode: classes.inviteCode }); - - const classIdByInvite = new Map( - classRows.map((classItem) => [classItem.inviteCode, classItem.id]) + const departmentCodes = data.departments.map((dept) => dept.code); + const departmentRows = + departmentCodes.length === 0 + ? [] + : await db + .select({ id: departments.id, code: departments.code }) + .from(departments) + .where(inArray(departments.code, departmentCodes)); + const departmentMap = new Map( + departmentRows.map((row) => [row.code, row.id]) ); - if (data.enrollments.length > 0) { - await db.insert(enrollments).values( - data.enrollments.map((enrollment) => ({ - classId: classIdByInvite.get(enrollment.classInviteCode)!, - studentId: enrollment.studentId, - })) - ); + if (data.subjects.length) { + const subjectsToInsert = data.subjects.map((subject) => ({ + code: subject.code, + name: subject.name, + description: subject.description, + departmentId: ensureMapValue( + departmentMap, + subject.departmentCode, + "department" + ), + })); + + await db + .insert(subjects) + .values(subjectsToInsert) + .onConflictDoNothing({ target: subjects.code }); + } + + const subjectCodes = data.subjects.map((subject) => subject.code); + const subjectRows = + subjectCodes.length === 0 + ? [] + : await db + .select({ id: subjects.id, code: subjects.code }) + .from(subjects) + .where(inArray(subjects.code, subjectCodes)); + const subjectMap = new Map(subjectRows.map((row) => [row.code, row.id])); + + if (data.classes.length) { + const classesToInsert = data.classes.map((classItem) => ({ + name: classItem.name, + description: classItem.description, + capacity: classItem.capacity, + status: classItem.status, + inviteCode: classItem.inviteCode, + subjectId: ensureMapValue(subjectMap, classItem.subjectCode, "subject"), + teacherId: classItem.teacherId, + bannerUrl: classItem.bannerUrl, + bannerCldPubId: null, + schedules: [], + })); + + await db + .insert(classes) + .values(classesToInsert) + .onConflictDoNothing({ target: classes.inviteCode }); } - console.log("Seed completed."); + const classInviteCodes = data.classes.map( + (classItem) => classItem.inviteCode + ); + const classRows = + classInviteCodes.length === 0 + ? [] + : await db + .select({ id: classes.id, inviteCode: classes.inviteCode }) + .from(classes) + .where(inArray(classes.inviteCode, classInviteCodes)); + const classMap = new Map(classRows.map((row) => [row.inviteCode, row.id])); }; -seed().catch((error) => { - console.error("Seed failed:", error); - process.exitCode = 1; -}); +seed() + .then(() => { + console.log("Seed completed."); + process.exit(0); + }) + .catch((error) => { + console.error("Seed failed:", error); + process.exit(1); + }); From 6722455b4cebc0cb14c06d3e6be09965e9481fea Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Mon, 5 Jan 2026 19:18:01 +0530 Subject: [PATCH 18/19] comment: authorize roles --- src/routes/classes.ts | 14 +++++++------- src/routes/departments.ts | 16 ++++++++-------- src/routes/enrollments.ts | 12 ++++++------ src/routes/stats.ts | 2 +- src/routes/subjects.ts | 14 +++++++------- src/routes/users.ts | 2 +- 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/routes/classes.ts b/src/routes/classes.ts index 4cf52d9..92ad318 100644 --- a/src/routes/classes.ts +++ b/src/routes/classes.ts @@ -23,7 +23,7 @@ const router = express.Router(); router.get( "/", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { @@ -100,7 +100,7 @@ router.get( router.get( "/invite/:code", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { code } = parseRequest(classInviteParamSchema, req.params); @@ -122,7 +122,7 @@ router.get( router.get( "/:id/users", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { id: classId } = parseRequest(classIdParamSchema, req.params); @@ -218,7 +218,7 @@ router.get( router.get( "/:id", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { id: classId } = parseRequest(classIdParamSchema, req.params); @@ -241,7 +241,7 @@ router.get( router.post( "/", authenticate, - authorizeRoles("admin", "teacher"), + /* authorizeRoles("admin", "teacher"), */ async (req, res) => { try { const { @@ -312,7 +312,7 @@ router.post( router.put( "/:id", authenticate, - authorizeRoles("admin", "teacher"), + /* authorizeRoles("admin", "teacher"), */ async (req, res) => { try { const { id: classId } = parseRequest(classIdParamSchema, req.params); @@ -377,7 +377,7 @@ router.put( router.delete( "/:id", authenticate, - authorizeRoles("admin", "teacher"), + /* authorizeRoles("admin", "teacher"), */ async (req, res) => { try { const { id: classId } = parseRequest(classIdParamSchema, req.params); diff --git a/src/routes/departments.ts b/src/routes/departments.ts index b346caf..4d6a1d6 100644 --- a/src/routes/departments.ts +++ b/src/routes/departments.ts @@ -24,7 +24,7 @@ const router = express.Router(); router.get( "/", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { search, page = 1, limit = 10 } = parseRequest( @@ -90,7 +90,7 @@ router.get( router.get( "/:id", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { id: departmentId } = parseRequest( @@ -153,7 +153,7 @@ router.get( router.get( "/:id/subjects", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { id: departmentId } = parseRequest( @@ -205,7 +205,7 @@ router.get( router.get( "/:id/classes", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { id: departmentId } = parseRequest( @@ -266,7 +266,7 @@ router.get( router.get( "/:id/users", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { id: departmentId } = parseRequest( @@ -387,7 +387,7 @@ router.get( router.post( "/", authenticate, - authorizeRoles("admin"), + /* authorizeRoles("admin"), */ async (req, res) => { try { const { name, code, description } = parseRequest( @@ -427,7 +427,7 @@ router.post( router.put( "/:id", authenticate, - authorizeRoles("admin"), + /* authorizeRoles("admin"), */ async (req, res) => { try { const { id: departmentId } = parseRequest( @@ -480,7 +480,7 @@ router.put( router.delete( "/:id", authenticate, - authorizeRoles("admin"), + /* authorizeRoles("admin"), */ async (req, res) => { try { const { id: departmentId } = parseRequest( diff --git a/src/routes/enrollments.ts b/src/routes/enrollments.ts index 23e097b..c74ace4 100644 --- a/src/routes/enrollments.ts +++ b/src/routes/enrollments.ts @@ -25,7 +25,7 @@ const router = express.Router(); router.get( "/", authenticate, - authorizeRoles("admin", "teacher"), + /* authorizeRoles("admin", "teacher"), */ async (req, res) => { try { const { @@ -87,7 +87,7 @@ router.get( router.get( "/:id", authenticate, - authorizeRoles("admin", "teacher"), + /* authorizeRoles("admin", "teacher"), */ async (req, res) => { try { const { id: enrollmentId } = parseRequest( @@ -112,7 +112,7 @@ router.get( router.post( "/", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { classId } = parseRequest(enrollmentCreateSchema, req.body); @@ -164,7 +164,7 @@ router.post( router.post( "/join", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { inviteCode } = parseRequest(enrollmentJoinSchema, req.body); @@ -217,7 +217,7 @@ router.post( router.put( "/:id", authenticate, - authorizeRoles("admin", "teacher"), + /* authorizeRoles("admin", "teacher"), */ async (req, res) => { try { const { id: enrollmentId } = parseRequest( @@ -296,7 +296,7 @@ router.put( router.delete( "/:id", authenticate, - authorizeRoles("admin", "teacher"), + /* authorizeRoles("admin", "teacher"), */ async (req, res) => { try { const { id: enrollmentId } = parseRequest( diff --git a/src/routes/stats.ts b/src/routes/stats.ts index 486bdf8..854229d 100644 --- a/src/routes/stats.ts +++ b/src/routes/stats.ts @@ -9,7 +9,7 @@ import { statsLatestQuerySchema } from "../validation/stats"; const router = express.Router(); -router.use(authenticate, authorizeRoles("admin", "teacher", "student")); +router.use(authenticate /*, authorizeRoles("admin", "teacher", "student") */); // Overview counts for core entities router.get("/overview", async (_req, res) => { diff --git a/src/routes/subjects.ts b/src/routes/subjects.ts index 03b15f7..a31febb 100644 --- a/src/routes/subjects.ts +++ b/src/routes/subjects.ts @@ -21,7 +21,7 @@ const router = express.Router(); router.get( "/", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { search, department, page = 1, limit = 10 } = req.query; @@ -91,7 +91,7 @@ router.get( router.get( "/:id", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { id: subjectId } = parseRequest(subjectIdParamSchema, req.params); @@ -125,7 +125,7 @@ router.get( router.post( "/", authenticate, - authorizeRoles("admin", "teacher"), + /* authorizeRoles("admin", "teacher"), */ async (req, res) => { try { const { departmentId, name, code, description } = parseRequest( @@ -168,7 +168,7 @@ router.post( router.put( "/:id", authenticate, - authorizeRoles("admin", "teacher"), + /* authorizeRoles("admin", "teacher"), */ async (req, res) => { try { const { id: subjectId } = parseRequest(subjectIdParamSchema, req.params); @@ -224,7 +224,7 @@ router.put( router.get( "/:id/classes", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { id: subjectId } = parseRequest(subjectIdParamSchema, req.params); @@ -277,7 +277,7 @@ router.get( router.get( "/:id/users", authenticate, - authorizeRoles("admin", "teacher", "student"), + /* authorizeRoles("admin", "teacher", "student"), */ async (req, res) => { try { const { id: subjectId } = parseRequest(subjectIdParamSchema, req.params); @@ -371,7 +371,7 @@ router.get( router.delete( "/:id", authenticate, - authorizeRoles("admin", "teacher"), + /* authorizeRoles("admin", "teacher"), */ async (req, res) => { try { const { id: subjectId } = parseRequest(subjectIdParamSchema, req.params); diff --git a/src/routes/users.ts b/src/routes/users.ts index 1804712..42787bb 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -17,7 +17,7 @@ import { const router = express.Router(); -router.use(authenticate, authorizeRoles("admin")); +router.use(authenticate /*, authorizeRoles("admin") */); // Get all users with optional role filter, search by name, and pagination router.get("/", async (req, res) => { From edf2dc66394912a951879ccca4264924b967ed0b Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Mon, 5 Jan 2026 19:30:35 +0530 Subject: [PATCH 19/19] feat: switch to postgres driver --- package-lock.json | 45 ++++++++++++++++++++++++++++++++++++++++++--- package.json | 2 +- src/db/index.ts | 6 +++--- src/seed/seed.ts | 14 +++++++------- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8c585e0..767f19c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,12 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@neondatabase/serverless": "^1.0.2", "better-auth": "^1.4.9", "cors": "^2.8.5", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", "express": "^5.2.1", + "postgres": "^3.4.5", "zod": "^4.2.1" }, "devDependencies": { @@ -956,6 +956,8 @@ "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.2.tgz", "integrity": "sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/node": "^22.15.30", "@types/pg": "^8.8.0" @@ -969,6 +971,8 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -977,7 +981,9 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@noble/ciphers": { "version": "2.1.1", @@ -1076,6 +1082,7 @@ "version": "25.0.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz", "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==", + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -1086,6 +1093,8 @@ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -2594,6 +2603,8 @@ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", "license": "ISC", + "optional": true, + "peer": true, "engines": { "node": ">=4.0.0" } @@ -2602,13 +2613,17 @@ "version": "1.10.3", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", @@ -2620,11 +2635,26 @@ "node": ">=4" } }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=4" } @@ -2634,6 +2664,8 @@ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2643,6 +2675,8 @@ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2652,6 +2686,8 @@ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "xtend": "^4.0.0" }, @@ -2961,6 +2997,7 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, "license": "MIT" }, "node_modules/unpipe": { @@ -2992,6 +3029,8 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=0.4" } diff --git a/package.json b/package.json index e38b644..f2515cc 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,12 @@ "license": "ISC", "type": "module", "dependencies": { - "@neondatabase/serverless": "^1.0.2", "better-auth": "^1.4.9", "cors": "^2.8.5", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", "express": "^5.2.1", + "postgres": "^3.4.5", "zod": "^4.2.1" }, "devDependencies": { diff --git a/src/db/index.ts b/src/db/index.ts index ec4e997..fdf0a42 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,10 +1,10 @@ import "dotenv/config"; -import { drizzle } from "drizzle-orm/neon-http"; -import { neon } from "@neondatabase/serverless"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; if (!process.env.DATABASE_URL) { throw new Error("DATABASE_URL is not defined"); } -const sql = neon(process.env.DATABASE_URL); +const sql = postgres(process.env.DATABASE_URL, { ssl: "require" }); export const db = drizzle(sql); diff --git a/src/seed/seed.ts b/src/seed/seed.ts index cefabff..e930641 100644 --- a/src/seed/seed.ts +++ b/src/seed/seed.ts @@ -80,13 +80,13 @@ const ensureMapValue = (map: Map, key: string, label: string) => { const seed = async () => { const data = await loadSeedData(); - await db.delete(enrollments); - await db.delete(classes); - await db.delete(subjects); - await db.delete(departments); - await db.delete(session); - await db.delete(account); - await db.delete(user); + // await db.delete(enrollments); + // await db.delete(classes); + // await db.delete(subjects); + // await db.delete(departments); + // await db.delete(session); + // await db.delete(account); + // await db.delete(user); if (data.users.length) { await db