diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a6146a --- /dev/null +++ b/README.md @@ -0,0 +1,608 @@ +# 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 +- 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 +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 +``` diff --git a/drizzle/0000_oval_gauntlet.sql b/drizzle/0000_oval_gauntlet.sql new file mode 100644 index 0000000..bec9099 --- /dev/null +++ b/drizzle/0000_oval_gauntlet.sql @@ -0,0 +1,113 @@ +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), + "subject_id" integer NOT NULL, + "teacher_id" text NOT NULL, + "invite_code" varchar(50) NOT NULL, + "name" varchar(255) NOT NULL, + "banner_cld_pub_id" text, + "banner_url" text, + "capacity" integer DEFAULT 50 NOT NULL, + "description" text, + "status" "class_status" DEFAULT 'active' 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") +); +--> 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, + "created_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 +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_student_id_idx" ON "enrollments" USING btree ("student_id");--> statement-breakpoint +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/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/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 5e175d9..b3877cb 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,9 +1,182 @@ { - "id": "3db9c73c-ea2e-4df9-95bb-16127ddfc5ce", + "id": "75303106-e7fe-4c95-8df2-91a7dc8004e1", "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 + } + }, + "subject_id": { + "name": "subject_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "teacher_id": { + "name": "teacher_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invite_code": { + "name": "invite_code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "class_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "schedules": { + "name": "schedules", + "type": "jsonb", + "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": { + "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 +247,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 + }, + "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": { + "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_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_class_unique": { + "name": "enrollments_student_class_unique", + "columns": [ + { + "expression": "student_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "class_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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": "", @@ -163,9 +471,418 @@ "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": { + "public.class_status": { + "name": "class_status", + "schema": "public", + "values": [ + "active", + "inactive", + "archived" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "student", + "teacher", + "admin" + ] } }, - "enums": {}, "schemas": {}, "sequences": {}, "roles": {}, diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ce05aff..8d4df1d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1765822907774, - "tag": "0000_rainy_inertia", + "when": 1767619736454, + "tag": "0000_oval_gauntlet", "breakpoints": true } ] diff --git a/package-lock.json b/package-lock.json index a665b77..767f19c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "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" + "express": "^5.2.1", + "postgres": "^3.4.5", + "zod": "^4.2.1" }, "devDependencies": { "@types/cors": "^2.8.19", @@ -24,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": { @@ -36,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", @@ -421,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": { @@ -460,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", @@ -914,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" @@ -927,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" } @@ -935,6 +981,38 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "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": { @@ -1004,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" @@ -1014,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": "*", @@ -1068,6 +1149,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", @@ -1096,7 +1293,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": { @@ -1207,6 +1404,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", @@ -1232,7 +1435,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", @@ -1690,7 +1893,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": { @@ -1916,7 +2119,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": { @@ -1958,7 +2161,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" @@ -2129,7 +2332,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" @@ -2231,6 +2434,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", @@ -2292,6 +2513,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", @@ -2367,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" } @@ -2375,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", @@ -2393,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" } @@ -2407,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" } @@ -2416,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" } @@ -2425,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" }, @@ -2488,12 +2751,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", @@ -2553,6 +2822,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", @@ -2635,7 +2910,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" @@ -2645,7 +2920,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", @@ -2722,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": { @@ -2753,9 +3029,20 @@ "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" } + }, + "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..f2515cc 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,22 @@ "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", + "db:push": "drizzle-kit push", + "seed": "tsx src/seed/seed.ts" }, "keywords": [], "author": "", "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" + "express": "^5.2.1", + "postgres": "^3.4.5", + "zod": "^4.2.1" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/src/controllers/classes.ts b/src/controllers/classes.ts new file mode 100644 index 0000000..f931007 --- /dev/null +++ b/src/controllers/classes.ts @@ -0,0 +1,37 @@ +import { eq, getTableColumns } from "drizzle-orm"; + +import { db } from "../db"; +import { classes, departments, 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), + }, + 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)); + + 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/departments.ts b/src/controllers/departments.ts new file mode 100644 index 0000000..e2eabe4 --- /dev/null +++ b/src/controllers/departments.ts @@ -0,0 +1,22 @@ +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]; +}; + +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/controllers/enrollments.ts b/src/controllers/enrollments.ts new file mode 100644 index 0000000..49022d0 --- /dev/null +++ b/src/controllers/enrollments.ts @@ -0,0 +1,51 @@ +import { eq, getTableColumns } from "drizzle-orm"; + +import { db } from "../db"; +import { classes, departments, enrollments, subjects } 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]; +}; + +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/controllers/subjects.ts b/src/controllers/subjects.ts new file mode 100644 index 0000000..e2f9132 --- /dev/null +++ b/src/controllers/subjects.ts @@ -0,0 +1,28 @@ +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]; +}; + +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 new file mode 100644 index 0000000..025de3d --- /dev/null +++ b/src/controllers/users.ts @@ -0,0 +1,16 @@ +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]; +}; + +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/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/db/schema/app.ts b/src/db/schema/app.ts index 1eae645..ff65ab1 100644 --- a/src/db/schema/app.ts +++ b/src/db/schema/app.ts @@ -1,12 +1,15 @@ import { relations } from "drizzle-orm"; import { integer, + jsonb, + index, pgEnum, pgTable, text, timestamp, varchar, } from "drizzle-orm/pg-core"; +import { user } from "./auth"; const timestamps = { createdAt: timestamp("created_at").defaultNow().notNull(), @@ -16,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(), @@ -39,6 +48,59 @@ export const subjects = pgTable("subjects", { ...timestamps, }); +export const classes = pgTable( + "classes", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + + subjectId: integer("subject_id") + .notNull() + .references(() => subjects.id, { onDelete: "cascade" }), + teacherId: text("teacher_id") + .notNull() + .references(() => user.id, { onDelete: "restrict" }), + + inviteCode: varchar("invite_code", { length: 50 }).notNull().unique(), + name: varchar("name", { length: 255 }).notNull(), + bannerCldPubId: text("banner_cld_pub_id"), + bannerUrl: text("banner_url"), + capacity: integer("capacity").notNull().default(50), + description: text("description"), + status: classStatusEnum("status").notNull().default("active"), + schedules: jsonb("schedules").$type().notNull(), + + ...timestamps, + }, + (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") + .notNull() + .references(() => classes.id, { onDelete: "cascade" }), + + ...timestamps, + }, + (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 }) => ({ subjects: many(subjects), })); @@ -48,6 +110,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 +141,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/db/schema/auth.ts b/src/db/schema/auth.ts new file mode 100644 index 0000000..4fd7032 --- /dev/null +++ b/src/db/schema/auth.ts @@ -0,0 +1,126 @@ +import { relations } from "drizzle-orm"; +import { + boolean, + index, + pgEnum, + pgTable, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; + +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(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + + ...timestamps, + }, + (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(), + userId: text("user_id") + .notNull() + .references(() => user.id), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + idToken: text("id_token"), + password: text("password"), + + ...timestamps, + }, + (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( + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + + ...timestamps, + }, + (table) => ({ + identifierIdx: index("verification_identifier_idx").on(table.identifier), + }) +); + +export const usersRelations = relations(user, ({ many }) => ({ + sessions: many(session), + accounts: many(account), +})); + +export const sessionsRelations = relations(session, ({ one }) => ({ + user: one(user, { + fields: [session.userId], + references: [user.id], + }), +})); + +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/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/index.ts b/src/index.ts index a762666..22d63c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,15 @@ 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"; +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; @@ -14,9 +22,16 @@ app.use( }) ); +app.all("/api/auth/*splat", toNodeHandler(auth)); + app.use(express.json()); +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.use("/api/stats", statsRouter); app.get("/", (req, res) => { res.send("Backend server is running!"); diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..e76d1c3 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,32 @@ +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, + }, + 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/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/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 new file mode 100644 index 0000000..92ad318 --- /dev/null +++ b/src/routes/classes.ts @@ -0,0 +1,401 @@ +import express from "express"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; + +import { db } from "../db"; +import { classes, enrollments, user } from "../db/schema"; +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, + classInviteParamSchema, + classListQuerySchema, + classUpdateSchema, + classUsersQuerySchema, +} from "../validation/classes"; + +const router = express.Router(); + +// Get all classes with optional filters and pagination +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}%`) + ) + ); + } + + 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", + authenticate, + /* authorizeRoles("admin", "teacher", "student"), */ + async (req, res) => { + try { + const { code } = parseRequest(classInviteParamSchema, req.params); + + const classRecord = await getClassByInviteCode(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" }); + } + } +); + +// 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", + authenticate, + /* authorizeRoles("admin", "teacher", "student"), */ + 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( + "/", + 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({ + 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( + "/:id", + 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" }); + } + + if (inviteCode) { + const existingInvite = await getClassByInviteCode(inviteCode); + + if (existingInvite && existingInvite.id !== classId) + return res.status(409).json({ error: "Invite code already exists" }); + } + + const updateValues: Record = {}; + + 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( + "/:id", + authenticate, + /* authorizeRoles("admin", "teacher"), */ + 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/departments.ts b/src/routes/departments.ts new file mode 100644 index 0000000..4d6a1d6 --- /dev/null +++ b/src/routes/departments.ts @@ -0,0 +1,508 @@ +import express from "express"; +import { and, desc, eq, getTableColumns, ilike, or, sql } from "drizzle-orm"; + +import { db } from "../db"; +import { classes, departments, enrollments, subjects, user } from "../db/schema"; +import { + getDepartmentByCode, + getDepartmentById, +} from "../controllers/departments"; +import { parseRequest } from "../lib/validation"; +import { authenticate, authorizeRoles } from "../middleware/auth-middleware"; +import { + departmentCreateSchema, + departmentIdParamSchema, + departmentItemsQuerySchema, + departmentListQuerySchema, + departmentUpdateSchema, + departmentUsersQuerySchema, +} 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({ + ...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); + + 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 details with counts +router.get( + "/:id", + authenticate, + /* authorizeRoles("admin", "teacher", "student"), */ + async (req, res) => { + try { + const { id: departmentId } = parseRequest( + departmentIdParamSchema, + req.params + ); + + const [department] = await db + .select() + .from(departments) + .where(eq(departments.id, departmentId)); + + if (!department) { + return res.status(404).json({ error: "Department not found" }); + } + + 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 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( + "/", + 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 new file mode 100644 index 0000000..c74ace4 --- /dev/null +++ b/src/routes/enrollments.ts @@ -0,0 +1,323 @@ +import express from "express"; +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, + getEnrollmentDetailsById, +} from "../controllers/enrollments"; +import { getUserById } from "../controllers/users"; +import { parseRequest } from "../lib/validation"; +import { authenticate, authorizeRoles } from "../middleware/auth-middleware"; +import { + enrollmentCreateSchema, + enrollmentIdParamSchema, + enrollmentJoinSchema, + enrollmentListQuerySchema, + enrollmentUpdateSchema, +} from "../validation/enrollments"; + +const router = express.Router(); + +// Get all enrollments with optional filters and pagination +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)); + } + + 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.createdAt)) + .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", + authenticate, + /* authorizeRoles("admin", "teacher"), */ + 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( + "/", + 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) + ) + ); + + 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 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" }); + } + } +); + +// Join class by invite code +router.post( + "/join", + 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" }); + + 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 getEnrollmentDetailsById(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", + 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 + .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", + 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" }); + } + } +); + +export default router; diff --git a/src/routes/stats.ts b/src/routes/stats.ts new file mode 100644 index 0000000..854229d --- /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/routes/subjects.ts b/src/routes/subjects.ts index 28739dc..a31febb 100644 --- a/src/routes/subjects.ts +++ b/src/routes/subjects.ts @@ -2,12 +2,27 @@ 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"; +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; @@ -72,4 +87,308 @@ router.get("/", async (req, res) => { } }); +// Get a single subject by ID +router.get( + "/:id", + authenticate, + /* authorizeRoles("admin", "teacher", "student"), */ + 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" }); + } + + 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 details" }); + } +}); + +// Create a new subject +router.post( + "/", + authenticate, + /* authorizeRoles("admin", "teacher"), */ + 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 getSubjectByCode(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", + authenticate, + /* authorizeRoles("admin", "teacher"), */ + 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 (code) { + const existingSubjectWithCode = await getSubjectByCode(code); + + if (existingSubjectWithCode && existingSubjectWithCode.id !== subjectId) + return res.status(409).json({ error: "Subject code already exists" }); + + updateValues.code = code; + } + + for (const [key, value] of Object.entries({ name, description })) { + if (value) updateValues[key] = value; + } + + 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" }); + } +}); + +// 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", + authenticate, + /* authorizeRoles("admin", "teacher"), */ + 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/routes/users.ts b/src/routes/users.ts new file mode 100644 index 0000000..42787bb --- /dev/null +++ b/src/routes/users.ts @@ -0,0 +1,455 @@ +import express from "express"; +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"; + +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 { + 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( + or(ilike(user.name, `%${search}%`), ilike(user.email, `%${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" }); + } +}); + +// 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 { + const payload = parseRequest(userCreateSchema, req.body); + + 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) + 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 { name, email, image, imageCldPubId, role } = 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 (email) { + const existingEmail = await getUserByEmail(email); + + if (existingEmail && existingEmail.id !== userId) + 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(updateValues) + .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/seed/data.json b/src/seed/data.json new file mode 100644 index 0000000..5fcb9bf --- /dev/null +++ b/src/seed/data.json @@ -0,0 +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": "Programming, data structures, and systems fundamentals." + }, + { + "code": "MATH", + "name": "Mathematics", + "description": "Foundations of calculus, algebra, and statistics." + }, + { + "code": "BIO", + "name": "Biology", + "description": "Life sciences and laboratory methods." + }, + { + "code": "BUS", + "name": "Business", + "description": "Management, finance, and entrepreneurship." + } + ], + "subjects": [ + { + "code": "CS101", + "name": "Intro to Programming", + "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, matrices, and transformations.", + "departmentCode": "MATH" + }, + { + "code": "MATH301", + "name": "Statistics", + "description": "Probability, inference, and modeling.", + "departmentCode": "MATH" + }, + { + "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" + }, + { + "code": "BIO202", + "name": "Microbiology", + "description": "Microbes, immunity, and lab techniques.", + "departmentCode": "BIO" + }, + { + "code": "BIO301", + "name": "Genetics", + "description": "Genes, inheritance, and biotech.", + "departmentCode": "BIO" + }, + { + "code": "BUS101", + "name": "Intro to Business", + "description": "Business models, markets, and operations.", + "departmentCode": "BUS" + }, + { + "code": "BUS201", + "name": "Marketing Essentials", + "description": "Branding, campaigns, and analytics.", + "departmentCode": "BUS" + }, + { + "code": "BUS301", + "name": "Finance Basics", + "description": "Budgets, cash flow, and valuation.", + "departmentCode": "BUS" + } + ], + "classes": [ + { + "name": "Intro to Programming - Section A", + "description": "Hands-on coding labs and weekly challenges.", + "capacity": 35, + "status": "active", + "inviteCode": "cs101-a", + "subjectCode": "CS101", + "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", + "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", + "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": "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", + "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": "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": "bio301-research", + "studentId": "student_10" + }, + { + "classInviteCode": "math301-applied", + "studentId": "student_11" + }, + { + "classInviteCode": "cs202-practical", + "studentId": "student_12" + } + ] +} \ No newline at end of file diff --git a/src/seed/seed.ts b/src/seed/seed.ts new file mode 100644 index 0000000..e930641 --- /dev/null +++ b/src/seed/seed.ts @@ -0,0 +1,214 @@ +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 { + 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 = { + 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 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 () => { + 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); + + 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] }); + } + + 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 }); + } + + 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.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 }); + } + + 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() + .then(() => { + console.log("Seed completed."); + process.exit(0); + }) + .catch((error) => { + console.error("Seed failed:", error); + process.exit(1); + }); diff --git a/src/type.d.ts b/src/type.d.ts index 49e9869..3c97135 100644 --- a/src/type.d.ts +++ b/src/type.d.ts @@ -1,5 +1,16 @@ -type Schedule = { +interface Schedule { day: string; startTime: string; endTime: string; -}; +} + +declare namespace Express { + interface Locals { + user?: { + id?: string; + role?: "admin" | "teacher" | "student"; + [key: string]: unknown; + }; + session?: unknown; + } +} diff --git a/src/validation/classes.ts b/src/validation/classes.ts new file mode 100644 index 0000000..1e74492 --- /dev/null +++ b/src/validation/classes.ts @@ -0,0 +1,73 @@ +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), + 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(), + }) + .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(); + +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/departments.ts b/src/validation/departments.ts new file mode 100644 index 0000000..23232ef --- /dev/null +++ b/src/validation/departments.ts @@ -0,0 +1,49 @@ +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", + }); + +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(); diff --git a/src/validation/enrollments.ts b/src/validation/enrollments.ts new file mode 100644 index 0000000..1fc87d2 --- /dev/null +++ b/src/validation/enrollments.ts @@ -0,0 +1,38 @@ +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(), + }) + .strict(); + +export const enrollmentJoinSchema = z + .object({ + inviteCode: 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", + }); 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(); diff --git a/src/validation/subjects.ts b/src/validation/subjects.ts new file mode 100644 index 0000000..109f16f --- /dev/null +++ b/src/validation/subjects.ts @@ -0,0 +1,52 @@ +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", + }); + +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(); diff --git a/src/validation/users.ts b/src/validation/users.ts new file mode 100644 index 0000000..f4d826b --- /dev/null +++ b/src/validation/users.ts @@ -0,0 +1,51 @@ +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", + }); + +export const userItemsQuerySchema = z + .object({ + page: z.coerce.number().int().min(1).optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + }) + .strict();