diff --git a/README.md b/README.md index bc81749..ccd21e3 100644 --- a/README.md +++ b/README.md @@ -50,59 +50,72 @@ AskEasy is built for that moment. It gives every lecture a live Q&A room where t ### How the pieces connect -| Component | Role | -|-----------|------| -| **Custom server (`server.ts`)** | Single Node.js process that boots both Next.js and Socket.IO on the same port. Strips Shibboleth headers from non-localhost connections to prevent spoofing. | -| **Next.js App Router** | Serves all pages and REST API routes (`/api/*`). Server Components fetch from PostgreSQL via Prisma; API routes handle auth, course/session management, and slide uploads. | -| **Socket.IO** | Handles all real-time events (questions, answers, upvotes, slide page changes). Uses a Redis adapter so multiple app instances share the same pub/sub channel. | -| **PostgreSQL + Prisma** | Single source of truth for all persistent data. Prisma handles the schema, migrations, and typed queries. | -| **Redis** | Three jobs: Socket.IO pub/sub adapter, rate-limit counters (per-user sliding windows), and ephemeral answer-mode state (24-hour TTL). | -| **Apache + mod_shib** *(prod only)* | Terminates TLS, enforces Shibboleth SSO, and injects `utorid`/`mail`/`cn` headers before proxying to the app. | +| Component | Role | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Custom server (`server.ts`)** | Single Node.js process that boots both Next.js and Socket.IO on the same port. Strips Shibboleth headers from non-localhost connections to prevent spoofing. | +| **Next.js App Router** | Serves all pages and REST API routes (`/api/*`). Server Components fetch from PostgreSQL via Prisma; API routes handle auth, course/session management, and slide uploads. | +| **Socket.IO** | Handles all real-time events (questions, answers, upvotes, slide page changes). Uses a Redis adapter so multiple app instances share the same pub/sub channel. | +| **PostgreSQL + Prisma** | Single source of truth for all persistent data. Prisma handles the schema, migrations, and typed queries. | +| **Redis** | Three jobs: Socket.IO pub/sub adapter, rate-limit counters (per-user sliding windows), and ephemeral answer-mode state (24-hour TTL). | +| **Apache + mod_shib** _(prod only)_ | Terminates TLS, enforces Shibboleth SSO, and injects `utorid`/`mail`/`cn` headers before proxying to the app. | --- ## Tech Stack -| Layer | Technology | -|-------|-----------| -| Frontend | Next.js 16, React 19, Tailwind CSS 4, Radix UI | -| Backend | Next.js API routes + custom Node.js HTTP server | -| Real-time | Socket.IO with Redis adapter | -| Database | PostgreSQL 16 (via Prisma ORM) | -| Cache / Pub-sub | Redis 7 | -| Auth | iron-session + Shibboleth header-based SSO | -| Testing | Vitest, Testing Library | -| Containerization | Docker & Docker Compose | +| Layer | Technology | +| ---------------- | ----------------------------------------------- | +| Frontend | Next.js 16, React 19, Tailwind CSS 4, Radix UI | +| Backend | Next.js API routes + custom Node.js HTTP server | +| Real-time | Socket.IO with Redis adapter | +| Database | PostgreSQL 16 (via Prisma ORM) | +| Cache / Pub-sub | Redis 7 | +| Auth | iron-session + Shibboleth header-based SSO | +| Testing | Vitest, Testing Library | +| Containerization | Docker & Docker Compose | --- ## Environment Variables -### `.env` — used by Docker Compose and production +Both files below are gitignored and live in the project root. **Production does not use them** — the server reads its own file at `/home/easy/secrets/prod.env`, which is created by hand and never touched by a deploy. + +### `.env` — base values ```bash # PostgreSQL -DATABASE_URL=postgresql://postgres:@postgres:5432/ask_easy POSTGRES_USER=postgres -POSTGRES_PASSWORD= +POSTGRES_PASSWORD= POSTGRES_DB=ask_easy # Redis -REDIS_URL=redis://:@redis:6379 -REDIS_PASSWORD= +REDIS_PASSWORD= # Session encryption key — generate with: openssl rand -hex 32 SESSION_SECRET=<64-char-hex> # Cron job auth (for /api/cron/cleanup-sessions) CRON_SECRET= + +# Roles — comma-separated UTORids, case-insensitive +PROFESSOR_WHITELIST=utorid1,utorid2 +ADMIN_WHITELIST=utorid1 +``` + +`DATABASE_URL` and `REDIS_URL` are **not** listed here. Docker Compose builds them from the values above so the passwords have a single source of truth: + +```yaml +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} +REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379 ``` -### `.env.local` — local dev only (overrides hosts to `localhost`) +### `.env.local` — local dev only + +Loaded after `.env` and overrides it. Needed because `pnpm dev` runs the app outside Docker, so it must reach the containers on `localhost` rather than by service name. ```bash -DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ask_easy -REDIS_URL=redis://:changeme@localhost:6379 +DATABASE_URL=postgresql://postgres:@localhost:5432/ask_easy +REDIS_URL=redis://:@localhost:6379 # Fake SSO identity for local login DEV_UTORID=yourutorid @@ -111,20 +124,25 @@ DEV_EMAIL=your.email@mail.utoronto.ca DEV_ROLE=PROFESSOR # or STUDENT ``` -> **Note:** In Docker Compose the database and Redis hosts are the service names (`postgres`, `redis`). In `pnpm dev` they must be `localhost` because the app runs outside Docker. - -| Variable | Required | Description | -|----------|:--------:|-------------| -| `DATABASE_URL` | Yes | Prisma connection string | -| `POSTGRES_USER` / `PASSWORD` / `DB` | Yes | Postgres container credentials | -| `REDIS_URL` | Yes | Redis connection (include password if set) | -| `REDIS_PASSWORD` | Yes (Docker) | Passed to the Redis container | -| `SESSION_SECRET` | Yes | 64-char hex key for iron-session cookie encryption | -| `CRON_SECRET` | Prod | Bearer token for the cleanup-sessions cron endpoint | -| `DEV_UTORID` | Dev | Fake UTORid injected when Shibboleth is not present | -| `DEV_NAME` | Dev | Display name for the fake dev user | -| `DEV_EMAIL` | Dev | Email for the fake dev user | -| `DEV_ROLE` | Dev | `PROFESSOR` or `STUDENT` — overrides whitelist lookup | +> **The `DEV_*` variables must never be set in production.** The auth route falls back to `DEV_UTORID` whenever the Shibboleth header is missing, so setting them on the server would allow unauthenticated logins. + +| Variable | Required | Description | +| ----------------------------------- | :------: | --------------------------------------------------------------------------- | +| `POSTGRES_USER` / `PASSWORD` / `DB` | Yes | Postgres credentials | +| `REDIS_PASSWORD` | Yes | Passed to the Redis container as `--requirepass` | +| `SESSION_SECRET` | Yes | Key for iron-session cookie encryption. Changing it logs everyone out. | +| `PROFESSOR_WHITELIST` | Yes | UTORids granted the PROFESSOR role on login. Everyone else is a STUDENT. | +| `ADMIN_WHITELIST` | Yes | UTORids granted `/dashboard` access. Empty means nobody can administer. | +| `CRON_SECRET` | Yes | Bearer token for the cleanup-sessions cron endpoint | +| `DATABASE_URL` | Dev | Only in `.env.local`; Compose derives it otherwise | +| `REDIS_URL` | Dev | Only in `.env.local`; Compose derives it otherwise | +| `DEV_UTORID` | Dev | Fake UTORid injected when Shibboleth is not present | +| `DEV_NAME` | Dev | Display name for the fake dev user | +| `DEV_EMAIL` | Dev | Email for the fake dev user; defaults to `@mail.utoronto.ca` | +| `DEV_ROLE` | Dev | `PROFESSOR` or `STUDENT` — overrides whitelist lookup | +| `SOCKET_IO_USE_REDIS` | No | Set to `"false"` to disable the Socket.IO Redis adapter. Enabled otherwise. | + +Whitelists are read once at startup and cached, so restart the app after changing them. --- @@ -146,13 +164,9 @@ pnpm install ### 2. Configure environment -Copy the example and edit as needed: +Create `.env` and `.env.local` in the project root using the templates in [Environment Variables](#environment-variables) above. Both are gitignored, so a fresh clone has neither. -```bash -cp .env .env.local -``` - -Set `DEV_UTORID`, `DEV_NAME`, and `DEV_ROLE` in `.env.local` to control which user you log in as during development. Set `DEV_ROLE=PROFESSOR` to access course management features. +Put your own UTORid in `PROFESSOR_WHITELIST` and `ADMIN_WHITELIST`, and set `DEV_UTORID` to the same value so your fake dev login picks up those roles. ### 3. Start the database and Redis @@ -163,9 +177,12 @@ docker-compose up -d postgres redis ### 4. Set up the database schema ```bash -pnpm db:setup # generates Prisma client and pushes schema +pnpm db:generate # build the Prisma client from schema.prisma +pnpm prisma migrate deploy # apply all migrations ``` +> **Do not use `pnpm db:push` or `pnpm db:setup`.** They change your database directly without creating a migration file, so the change never reaches anyone else or production. Schema changes always go through `pnpm db:migrate`, and the generated migration must be committed. + ### 5. Start the dev server ```bash @@ -174,63 +191,70 @@ pnpm dev Open [http://localhost:3000](http://localhost:3000). The app auto-reloads on changes. ---- +### Switching branches -## Running in Production +`git checkout` only updates tracked files. The Prisma client (`src/generated/`) and `node_modules/` are gitignored, so they keep whatever the previous branch left behind. Run this after switching to any branch that touches `prisma/schema.prisma` or `package.json`: -Production uses a pre-built Docker image from Docker Hub layered with the `docker-compose.prod.yml` override. This mounts the auth route, server entry, whitelist, and uploads directory from the host so they can be updated without rebuilding the image. +```bash +pnpm install # lockfile may differ between branches +pnpm db:generate # regenerate the Prisma client from this branch's schema +``` -### 1. Configure `.env` +Then **restart `pnpm dev`** — the running server holds the old client in memory. -Create `.env` in the project root with production values (see [Environment Variables](#environment-variables) above). Use the Docker service names as hosts: +If the branch adds or removes migrations, also apply them: -``` -DATABASE_URL=postgresql://postgres:@postgres:5432/ask_easy -REDIS_URL=redis://:@redis:6379 +```bash +pnpm prisma migrate deploy +pnpm prisma migrate status # should report "Database schema is up to date" ``` -### 2. Pull and start +If `migrate status` reports migrations applied to the database but missing locally (common when switching between branches with different migration history), reset the local database: ```bash -docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d +docker-compose down -v +docker-compose up -d postgres redis +pnpm prisma migrate deploy ``` -This starts three containers: `app` (Next.js on port 3000, bound to `127.0.0.1`), `postgres`, and `redis`. The app is not publicly exposed — Apache sits in front of it. +#### Symptoms of skipping this -### 3. Apply database migrations +| Error | Cause | Fix | +| --------------------------------------------------------------------------------------- | --------------------------------------------- | -------------------------------------- | +| `The column X does not exist in the current database` | Prisma client is from another branch's schema | `pnpm db:generate`, restart dev server | +| `Cannot find module ...` | Dependencies differ between branches | `pnpm install` | +| `migration ... applied to the database but missing from the local migrations directory` | Migration history differs | Reset the local database as above | -```bash -docker exec ask_easy-app-1 npx prisma migrate deploy -``` +> **Always use `pnpm db:migrate` for schema changes, never `pnpm db:push`.** `db:push` updates your database without creating a migration file, so the change is invisible to everyone else and never reaches production. A missing migration will not surface until a database is rebuilt from scratch. -### 4. Set up Apache + Shibboleth (first time) +--- -Speak to UofT IT Admin. +## Deployment -### Updating the running app +Merging to `main` deploys automatically. GitHub Actions builds and tests the code, pushes the image to Docker Hub, and a self-hosted runner on the VM pulls it, applies migrations, and restarts the app container. -```bash -docker-compose -f docker-compose.yml -f docker-compose.prod.yml pull app -docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d app -``` +Pull requests run the same build and tests but do not deploy. The workflow is [.github/workflows/cicd.yml](.github/workflows/cicd.yml). --- ## Available Scripts -| Script | Description | -|--------|-------------| -| `pnpm dev` | Start development server with hot reload | -| `pnpm build` | Build for production | -| `pnpm start` | Start production server | -| `pnpm lint` | Run ESLint | -| `pnpm format` | Format code with Prettier | -| `pnpm test` | Run unit tests (Vitest) | -| `pnpm test:integration` | Run integration tests | -| `pnpm db:setup` | Generate Prisma client + push schema | -| `pnpm db:migrate` | Run database migrations | -| `pnpm db:studio` | Open Prisma Studio GUI | -| `pnpm db:seed` | Reset database (clears all tables — destructive) | +| Script | Description | +| ---------------------------- | -------------------------------------------------- | +| `pnpm dev` | Start development server with hot reload | +| `pnpm build` | Build for production | +| `pnpm start` | Start production server | +| `pnpm lint` | Run ESLint | +| `pnpm format` | Format code with Prettier | +| `pnpm test` | Run unit tests (Vitest) | +| `pnpm test:integration` | Run integration tests | +| `pnpm db:generate` | Generate the Prisma client from `schema.prisma` | +| `pnpm db:migrate` | Create and apply a migration after a schema change | +| `pnpm prisma migrate deploy` | Apply existing migrations without creating one | +| `pnpm db:studio` | Open Prisma Studio GUI | +| `pnpm db:seed` | Reset database (clears all tables — destructive) | + +`pnpm db:push` and `pnpm db:setup` exist but should not be used — see the warning in [step 4](#4-set-up-the-database-schema). --- @@ -253,11 +277,9 @@ prisma/ ├── schema.prisma # Database schema ├── migrations/ # Migration history └── seed.ts # Resets all tables (dev use only) -whitelist.txt -admin_whitelist.txt ``` -Check out docs/admin_whitelist.txt for more details on setting up admin permissions. +Professor and admin permissions are set with the `PROFESSOR_WHITELIST` and `ADMIN_WHITELIST` environment variables — see [docs/ADMIN-GUIDE.md](docs/ADMIN-GUIDE.md) for details. --- diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..bc48b33 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,17 @@ +# Local development only. +# +# Compose loads this file automatically when no -f flags are passed: +# docker-compose up -d -> docker-compose.yml + this file +# +# Production passes explicit -f flags, so this file is never read there: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d +# +# The DEV_* variables let you log in without Shibboleth. They must never +# reach production, where the auth route would fall back to them whenever +# the Shibboleth header is missing. +services: + app: + environment: + - DEV_UTORID=${DEV_UTORID:-} + - DEV_NAME=${DEV_NAME:-} + - DEV_ROLE=${DEV_ROLE:-} diff --git a/docker-compose.yml b/docker-compose.yml index 3cdfb3e..9bd0809 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,9 +11,7 @@ services: - SESSION_SECRET=${SESSION_SECRET} - PROFESSOR_WHITELIST=${PROFESSOR_WHITELIST} - ADMIN_WHITELIST=${ADMIN_WHITELIST} - - DEV_UTORID=${DEV_UTORID} - - DEV_NAME=${DEV_NAME} - - DEV_ROLE=${DEV_ROLE} + # DEV_* live in docker-compose.override.yml so production never sees them. depends_on: postgres: condition: service_healthy diff --git a/prisma/migrations/20260212013152_add_slideset_model/migration.sql b/prisma/migrations/20260212013152_add_slideset_model/migration.sql deleted file mode 100644 index b3b68e5..0000000 --- a/prisma/migrations/20260212013152_add_slideset_model/migration.sql +++ /dev/null @@ -1,60 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `contentUrl` on the `Slide` table. All the data in the column will be lost. - - You are about to drop the column `sessionId` on the `Slide` table. All the data in the column will be lost. - - You are about to drop the column `slideNumber` on the `Slide` table. All the data in the column will be lost. - - A unique constraint covering the columns `[slideSetId,pageNumber]` on the table `Slide` will be added. If there are existing duplicate values, this will fail. - - Added the required column `pageNumber` to the `Slide` table without a default value. This is not possible if the table is not empty. - - Added the required column `slideSetId` to the `Slide` table without a default value. This is not possible if the table is not empty. - -*/ --- DropForeignKey -ALTER TABLE "Slide" DROP CONSTRAINT "Slide_sessionId_fkey"; - --- DropIndex -DROP INDEX "Slide_sessionId_idx"; - --- AlterTable -ALTER TABLE "Slide" DROP COLUMN "contentUrl", -DROP COLUMN "sessionId", -DROP COLUMN "slideNumber", -ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, -ADD COLUMN "pageNumber" INTEGER NOT NULL, -ADD COLUMN "slideSetId" TEXT NOT NULL; - --- CreateTable -CREATE TABLE "SlideSet" ( - "id" TEXT NOT NULL, - "sessionId" TEXT NOT NULL, - "filename" TEXT NOT NULL, - "storageKey" TEXT NOT NULL, - "pageCount" INTEGER NOT NULL, - "fileSize" INTEGER NOT NULL, - "uploadedBy" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "SlideSet_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "SlideSet_sessionId_idx" ON "SlideSet"("sessionId"); - --- CreateIndex -CREATE INDEX "SlideSet_uploadedBy_idx" ON "SlideSet"("uploadedBy"); - --- CreateIndex -CREATE INDEX "Slide_slideSetId_idx" ON "Slide"("slideSetId"); - --- CreateIndex -CREATE UNIQUE INDEX "Slide_slideSetId_pageNumber_key" ON "Slide"("slideSetId", "pageNumber"); - --- AddForeignKey -ALTER TABLE "SlideSet" ADD CONSTRAINT "SlideSet_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "SlideSet" ADD CONSTRAINT "SlideSet_uploadedBy_fkey" FOREIGN KEY ("uploadedBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Slide" ADD CONSTRAINT "Slide_slideSetId_fkey" FOREIGN KEY ("slideSetId") REFERENCES "SlideSet"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260221054403_add_answer_anonymity/migration.sql b/prisma/migrations/20260221054403_add_answer_anonymity/migration.sql deleted file mode 100644 index 4811250..0000000 --- a/prisma/migrations/20260221054403_add_answer_anonymity/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Answer" ADD COLUMN "isAnonymous" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20260311223920_remove_slide_table/migration.sql b/prisma/migrations/20260311223920_remove_slide_table/migration.sql deleted file mode 100644 index 863b0e8..0000000 --- a/prisma/migrations/20260311223920_remove_slide_table/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Drop index on Question.slideId -DROP INDEX IF EXISTS "Question_slideId_idx"; - --- Remove slideId column from Question -ALTER TABLE "Question" DROP COLUMN IF EXISTS "slideId"; - --- Drop Slide table -DROP TABLE IF EXISTS "Slide"; - --- Remove slides relation from SlideSet (no column to drop, relation was via Slide table) diff --git a/prisma/migrations/20260312015943_add_answer_upvotes/migration.sql b/prisma/migrations/20260312015943_add_answer_upvotes/migration.sql deleted file mode 100644 index b1948e9..0000000 --- a/prisma/migrations/20260312015943_add_answer_upvotes/migration.sql +++ /dev/null @@ -1,26 +0,0 @@ --- AlterTable -ALTER TABLE "Answer" ADD COLUMN "upvoteCount" INTEGER NOT NULL DEFAULT 0; - --- CreateTable -CREATE TABLE "AnswerUpvote" ( - "id" TEXT NOT NULL, - "answerId" TEXT NOT NULL, - "userId" TEXT NOT NULL, - - CONSTRAINT "AnswerUpvote_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "AnswerUpvote_answerId_idx" ON "AnswerUpvote"("answerId"); - --- CreateIndex -CREATE INDEX "AnswerUpvote_userId_idx" ON "AnswerUpvote"("userId"); - --- CreateIndex -CREATE UNIQUE INDEX "AnswerUpvote_answerId_userId_key" ON "AnswerUpvote"("answerId", "userId"); - --- AddForeignKey -ALTER TABLE "AnswerUpvote" ADD CONSTRAINT "AnswerUpvote_answerId_fkey" FOREIGN KEY ("answerId") REFERENCES "Answer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AnswerUpvote" ADD CONSTRAINT "AnswerUpvote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260312021207_add_cascade_deletes/migration.sql b/prisma/migrations/20260312021207_add_cascade_deletes/migration.sql deleted file mode 100644 index 177a6d9..0000000 --- a/prisma/migrations/20260312021207_add_cascade_deletes/migration.sql +++ /dev/null @@ -1,17 +0,0 @@ --- DropForeignKey -ALTER TABLE "Answer" DROP CONSTRAINT "Answer_questionId_fkey"; - --- DropForeignKey -ALTER TABLE "AnswerUpvote" DROP CONSTRAINT "AnswerUpvote_answerId_fkey"; - --- DropForeignKey -ALTER TABLE "QuestionUpvote" DROP CONSTRAINT "QuestionUpvote_questionId_fkey"; - --- AddForeignKey -ALTER TABLE "Answer" ADD CONSTRAINT "Answer_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "AnswerUpvote" ADD CONSTRAINT "AnswerUpvote_answerId_fkey" FOREIGN KEY ("answerId") REFERENCES "Answer"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "QuestionUpvote" ADD CONSTRAINT "QuestionUpvote_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260129181920_init/migration.sql b/prisma/migrations/20260806120000_init/migration.sql similarity index 77% rename from prisma/migrations/20260129181920_init/migration.sql rename to prisma/migrations/20260806120000_init/migration.sql index 8737081..ad53987 100644 --- a/prisma/migrations/20260129181920_init/migration.sql +++ b/prisma/migrations/20260806120000_init/migration.sql @@ -53,6 +53,7 @@ CREATE TABLE "Session" ( "isSubmissionsEnabled" BOOLEAN NOT NULL DEFAULT false, "startTime" TIMESTAMP(3), "endTime" TIMESTAMP(3), + "lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -60,20 +61,24 @@ CREATE TABLE "Session" ( ); -- CreateTable -CREATE TABLE "Slide" ( +CREATE TABLE "SlideSet" ( "id" TEXT NOT NULL, "sessionId" TEXT NOT NULL, - "slideNumber" INTEGER NOT NULL, - "contentUrl" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "storageKey" TEXT NOT NULL, + "pageCount" INTEGER NOT NULL, + "fileSize" INTEGER NOT NULL, + "uploadedBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, - CONSTRAINT "Slide_pkey" PRIMARY KEY ("id") + CONSTRAINT "SlideSet_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "Question" ( "id" TEXT NOT NULL, "sessionId" TEXT NOT NULL, - "slideId" TEXT, "authorId" TEXT, "content" TEXT NOT NULL, "isAnonymous" BOOLEAN NOT NULL DEFAULT false, @@ -91,12 +96,23 @@ CREATE TABLE "Answer" ( "questionId" TEXT NOT NULL, "authorId" TEXT NOT NULL, "content" TEXT NOT NULL, + "isAnonymous" BOOLEAN NOT NULL DEFAULT false, "isAccepted" BOOLEAN NOT NULL DEFAULT false, + "upvoteCount" INTEGER NOT NULL DEFAULT 0, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "Answer_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "AnswerUpvote" ( + "id" TEXT NOT NULL, + "answerId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "AnswerUpvote_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "QuestionUpvote" ( "id" TEXT NOT NULL, @@ -143,13 +159,13 @@ CREATE INDEX "Session_createdById_idx" ON "Session"("createdById"); CREATE INDEX "Session_status_idx" ON "Session"("status"); -- CreateIndex -CREATE INDEX "Slide_sessionId_idx" ON "Slide"("sessionId"); +CREATE INDEX "SlideSet_sessionId_idx" ON "SlideSet"("sessionId"); -- CreateIndex -CREATE INDEX "Question_sessionId_idx" ON "Question"("sessionId"); +CREATE INDEX "SlideSet_uploadedBy_idx" ON "SlideSet"("uploadedBy"); -- CreateIndex -CREATE INDEX "Question_slideId_idx" ON "Question"("slideId"); +CREATE INDEX "Question_sessionId_idx" ON "Question"("sessionId"); -- CreateIndex CREATE INDEX "Question_authorId_idx" ON "Question"("authorId"); @@ -169,6 +185,15 @@ CREATE INDEX "Answer_questionId_idx" ON "Answer"("questionId"); -- CreateIndex CREATE INDEX "Answer_authorId_idx" ON "Answer"("authorId"); +-- CreateIndex +CREATE INDEX "AnswerUpvote_answerId_idx" ON "AnswerUpvote"("answerId"); + +-- CreateIndex +CREATE INDEX "AnswerUpvote_userId_idx" ON "AnswerUpvote"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AnswerUpvote_answerId_userId_key" ON "AnswerUpvote"("answerId", "userId"); + -- CreateIndex CREATE INDEX "QuestionUpvote_questionId_idx" ON "QuestionUpvote"("questionId"); @@ -194,25 +219,32 @@ ALTER TABLE "Session" ADD CONSTRAINT "Session_courseId_fkey" FOREIGN KEY ("cours ALTER TABLE "Session" ADD CONSTRAINT "Session_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "Slide" ADD CONSTRAINT "Slide_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "SlideSet" ADD CONSTRAINT "SlideSet_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "Question" ADD CONSTRAINT "Question_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "SlideSet" ADD CONSTRAINT "SlideSet_uploadedBy_fkey" FOREIGN KEY ("uploadedBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "Question" ADD CONSTRAINT "Question_slideId_fkey" FOREIGN KEY ("slideId") REFERENCES "Slide"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Question" ADD CONSTRAINT "Question_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "Question" ADD CONSTRAINT "Question_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "Answer" ADD CONSTRAINT "Answer_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Answer" ADD CONSTRAINT "Answer_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "Answer" ADD CONSTRAINT "Answer_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "QuestionUpvote" ADD CONSTRAINT "QuestionUpvote_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "AnswerUpvote" ADD CONSTRAINT "AnswerUpvote_answerId_fkey" FOREIGN KEY ("answerId") REFERENCES "Answer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AnswerUpvote" ADD CONSTRAINT "AnswerUpvote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuestionUpvote" ADD CONSTRAINT "QuestionUpvote_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "QuestionUpvote" ADD CONSTRAINT "QuestionUpvote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/src/app/api/questions/[questionId]/answers/route.ts b/src/app/api/questions/[questionId]/answers/route.ts new file mode 100644 index 0000000..af092a6 --- /dev/null +++ b/src/app/api/questions/[questionId]/answers/route.ts @@ -0,0 +1,197 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import { + validateAnswerContent, + checkAnswerRateLimit, + validateQuestionForAnswers, +} from "@/lib/answerValidation"; +import { getQuestionAnswers } from "@/services/answerService"; +import { getCurrentUser } from "@/lib/auth"; +import { redisCache } from "@/lib/redis"; +import { answerMode as answerModeKey } from "@/lib/redisKeys"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface RouteParams { + params: Promise<{ questionId: string }>; +} + +interface AnswerCreateBody { + content: string; + isAnonymous?: boolean; +} + +// --------------------------------------------------------------------------- +// GET /api/questions/[questionId]/answers +// --------------------------------------------------------------------------- + +/** + * Retrieves answers for a given question with cursor-based pagination. + * + * Query params: + * - cursor (optional) — answer id to paginate from + * - limit (optional) — page size (default 20, max 50) + * + * Returns answers sorted by: accepted first, then createdAt asc. + * Anonymous answer authors are hidden from students. + */ +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { questionId } = await params; + + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const cursor = searchParams.get("cursor") ?? undefined; + const limitParam = searchParams.get("limit"); + const limit = limitParam ? parseInt(limitParam, 10) : undefined; + + const result = await getQuestionAnswers(questionId, user.userId, { cursor, limit }); + + if (!result.ok) { + return NextResponse.json({ error: result.error.message }, { status: result.error.status }); + } + + return NextResponse.json(result.data); + } catch (error) { + console.error("[Answers API] Failed to fetch answers:", error); + return NextResponse.json( + { error: "An error occurred while fetching answers." }, + { status: 500 } + ); + } +} + +// --------------------------------------------------------------------------- +// POST /api/questions/[questionId]/answers +// --------------------------------------------------------------------------- + +/** + * Creates a new answer for the given question. + * + * Request body: + * - content: string (required, 1-1000 characters) + * - isAnonymous: boolean (optional, default false) + * + * Validations: + * 1. Authenticated user from session cookie + * 2. Question exists and belongs to an active session + * 3. Content length bounds + * 4. Rate limit (15 answers per 60 seconds per user) + */ +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const { questionId } = await params; + + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + } + + let body: AnswerCreateBody; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const questionValidation = await validateQuestionForAnswers(questionId); + if (!questionValidation.valid) { + const statusCode = questionValidation.error === "Question not found." ? 404 : 403; + return NextResponse.json({ error: questionValidation.error }, { status: statusCode }); + } + + const sessionId = questionValidation.question!.sessionId; + + // Enrollment check — verify the user is enrolled in the session's course + const sessionRecord = await prisma.session.findUnique({ + where: { id: sessionId }, + select: { courseId: true }, + }); + if (sessionRecord) { + const enrollment = await prisma.courseEnrollment.findUnique({ + where: { userId_courseId: { userId: user.userId, courseId: sessionRecord.courseId } }, + select: { role: true }, + }); + if (!enrollment && user.role !== "PROFESSOR") { + return NextResponse.json( + { error: "You are not enrolled in this session." }, + { status: 403 } + ); + } + + // Answer mode check — mirror the socket-layer restriction + const mode = await redisCache.get(answerModeKey(sessionId)); + if (mode === "instructors_only") { + const isQuestionAuthor = questionValidation.question!.authorId === user.userId; + if (!isQuestionAuthor) { + const effectiveRole = enrollment?.role ?? "STUDENT"; + if (effectiveRole === "STUDENT") { + return NextResponse.json( + { error: "The professor has restricted answers to TAs and professors only." }, + { status: 403 } + ); + } + } + } + } + + const contentValidation = validateAnswerContent(body.content); + if (!contentValidation.valid) { + return NextResponse.json({ error: contentValidation.error }, { status: 400 }); + } + + const isRateLimited = await checkAnswerRateLimit(user.userId); + if (isRateLimited) { + return NextResponse.json( + { error: "Rate limit exceeded. Please wait before submitting another answer." }, + { status: 429 } + ); + } + + const answer = await prisma.answer.create({ + data: { + questionId, + authorId: user.userId, + content: body.content.trim(), + isAnonymous: body.isAnonymous ?? false, + }, + include: { + author: { + select: { + id: true, + name: true, + role: true, + }, + }, + }, + }); + + return NextResponse.json( + { + id: answer.id, + questionId: answer.questionId, + content: answer.content, + authorId: answer.author.id, + authorName: answer.author.name, + authorRole: answer.author.role, + isAccepted: answer.isAccepted, + isAnonymous: answer.isAnonymous, + createdAt: answer.createdAt, + }, + { status: 201 } + ); + } catch (error) { + console.error("[Answers API] Failed to create answer:", error); + return NextResponse.json( + { error: "An error occurred while creating your answer." }, + { status: 500 } + ); + } +} diff --git a/src/app/api/sessions/[sessionId]/questions/route.ts b/src/app/api/sessions/[sessionId]/questions/route.ts new file mode 100644 index 0000000..56277a3 --- /dev/null +++ b/src/app/api/sessions/[sessionId]/questions/route.ts @@ -0,0 +1,258 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getCurrentUser, getCurrentUserId } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { + buildQuestionsWhere, + getQuestionsOrderBy, + parseQuestionsQueryParams, +} from "@/lib/questionFilters"; +import { + validateQuestionContent, + validateVisibility, + validateSessionForQuestions, +} from "@/lib/questionValidation"; +import { getSessionMembership } from "@/lib/sessionService"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface RouteParams { + params: Promise<{ sessionId: string }>; +} + +interface QuestionCreateBody { + content: string; + visibility?: "PUBLIC" | "INSTRUCTOR_ONLY"; + isAnonymous?: boolean; +} + +// --------------------------------------------------------------------------- +// GET /api/sessions/[sessionId]/questions +// --------------------------------------------------------------------------- + +/** + * Retrieves questions for a session with cursor-based pagination, filters, and role-based visibility. + * + * Requires authentication (placeholder: x-user-id or Authorization Bearer header). + * User must be enrolled in the session's course. + * + * Query parameters: + * - limit: number (default 20, max 50) + * - cursor: id of last question from previous page + * - search: partial case-insensitive match on content + * - status: OPEN | ANSWERED | RESOLVED + * - sortBy: newest | votes (default newest) + * - includeTotal: true to include total matching count + */ +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { sessionId } = await params; + + const userId = await getCurrentUserId(); + if (!userId) { + return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + } + + const membership = await getSessionMembership(sessionId, userId); + if (!membership.valid || !membership.role) { + const statusCode = membership.statusCode ?? 403; + return NextResponse.json({ error: membership.error ?? "Forbidden." }, { status: statusCode }); + } + + const role = membership.role; + const { searchParams } = new URL(request.url); + const queryParams = parseQuestionsQueryParams(searchParams); + const where = buildQuestionsWhere(sessionId, role, { + search: queryParams.search, + status: queryParams.status, + }); + + let total: number | undefined; + if (queryParams.includeTotal) { + total = await prisma.question.count({ where }); + } + + const take = queryParams.limit + 1; + const orderBy = getQuestionsOrderBy(queryParams.sortBy); + const include = { + author: { select: { id: true, name: true, role: true, utorid: true } }, + _count: { select: { answers: true } }, + answers: { where: { isAccepted: true }, select: { id: true }, take: 1 }, + } as const; + + const questions = + queryParams.cursor !== null + ? await prisma.question.findMany({ + where, + orderBy, + take, + cursor: { id: queryParams.cursor }, + skip: 1, + include, + }) + : await prisma.question.findMany({ + where, + orderBy, + take, + include, + }); + + const hasMore = questions.length > queryParams.limit; + const page = hasMore ? questions.slice(0, queryParams.limit) : questions; + const nextCursor = hasMore && page.length > 0 ? page[page.length - 1].id : null; + + const canRevealAnonymous = role === "TA" || role === "PROFESSOR"; + + const transformedQuestions = page.map((q) => ({ + id: q.id, + content: q.content, + visibility: q.visibility, + status: q.status, + isAnonymous: q.isAnonymous, + upvoteCount: q.upvoteCount, + answerCount: q._count.answers, + hasAcceptedAnswer: q.answers.length > 0, + acceptedAnswerId: q.answers[0]?.id ?? null, + createdAt: q.createdAt, + author: q.isAnonymous && !canRevealAnonymous ? null : q.author, + })); + + const payload: { + sessionId: string; + questions: typeof transformedQuestions; + nextCursor: string | null; + count: number; + total?: number; + } = { + sessionId, + questions: transformedQuestions, + nextCursor, + count: transformedQuestions.length, + }; + if (total !== undefined) { + payload.total = total; + } + + return NextResponse.json(payload); + } catch (error) { + console.error("[Questions API] Failed to fetch questions:", error); + return NextResponse.json( + { error: "An error occurred while fetching questions." }, + { status: 500 } + ); + } +} + +// --------------------------------------------------------------------------- +// POST /api/sessions/[sessionId]/questions +// --------------------------------------------------------------------------- + +/** + * Creates a new question in the given session. + * + * Request body: + * - content: string (required, 5-500 characters) + * - authorId: string (required) + * - visibility: "PUBLIC" | "INSTRUCTOR_ONLY" (optional, defaults to PUBLIC) + * - isAnonymous: boolean (optional, defaults to false) + * + * Validations: + * 1. Content length bounds + * 2. Visibility is valid if provided + * 3. Rate limit (10 questions per 60 seconds per user) + * 4. Session exists and has submissions enabled + */ +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const { sessionId } = await params; + + // Parse request body + let body: QuestionCreateBody; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + // Get authenticated user — authorId comes from the session, not the request body + const authUser = await getCurrentUser(); + if (!authUser) { + return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + } + const authorId = authUser.userId; + + // 2. Verify the user is enrolled in the session's course + const membership = await getSessionMembership(sessionId, authorId); + if (!membership.valid) { + const statusCode = membership.statusCode ?? 403; + return NextResponse.json({ error: membership.error ?? "Forbidden." }, { status: statusCode }); + } + + // 4. Validate content using shared validation + const contentValidation = validateQuestionContent(body.content); + if (!contentValidation.valid) { + return NextResponse.json({ error: contentValidation.error }, { status: 400 }); + } + + // 5. Validate visibility using shared validation + const visibilityValidation = validateVisibility(body.visibility); + if (!visibilityValidation.valid) { + return NextResponse.json({ error: visibilityValidation.error }, { status: 400 }); + } + + // 6. Validate session using shared validation (submissions enabled check) + const sessionValidation = await validateSessionForQuestions(sessionId); + if (!sessionValidation.valid) { + const statusCode = sessionValidation.error === "Session not found." ? 404 : 403; + return NextResponse.json({ error: sessionValidation.error }, { status: statusCode }); + } + + // 7. Create the question and record activity on the session atomically + const [question] = await prisma.$transaction([ + prisma.question.create({ + data: { + sessionId, + authorId, + content: body.content.trim(), + visibility: body.visibility ?? "PUBLIC", + isAnonymous: body.isAnonymous ?? false, + }, + include: { + author: { + select: { + id: true, + name: true, + }, + }, + }, + }), + prisma.session.update({ + where: { id: sessionId }, + data: { lastActivityAt: new Date() }, + }), + ]); + + // 7. Return the created question (respecting anonymity) + return NextResponse.json( + { + id: question.id, + content: question.content, + visibility: question.visibility, + status: question.status, + isAnonymous: question.isAnonymous, + upvoteCount: question.upvoteCount, + createdAt: question.createdAt, + author: question.isAnonymous ? null : question.author, + }, + { status: 201 } + ); + } catch (error) { + console.error("[Questions API] Failed to create question:", error); + return NextResponse.json( + { error: "An error occurred while creating your question." }, + { status: 500 } + ); + } +} diff --git a/src/app/api/sessions/join/[code]/route.ts b/src/app/api/sessions/join/[code]/route.ts new file mode 100644 index 0000000..b46590a --- /dev/null +++ b/src/app/api/sessions/join/[code]/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { + checkSessionJoinLookupRateLimit, + checkSessionJoinRegisterRateLimit, +} from "@/lib/sessionJoinValidation"; +import { lookupSessionByCode, joinSession } from "@/lib/sessionJoin"; +import { getCurrentUser } from "@/lib/auth"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface RouteParams { + params: Promise<{ code: string }>; +} + +// --------------------------------------------------------------------------- +// GET /api/sessions/join/[code] +// --------------------------------------------------------------------------- + +/** + * Looks up a session by join code (case-insensitive). + * + * Returns: + * - 200: Session found + * - 401: Not authenticated + * - 404: Session not found + * - 429: Rate limit exceeded + * - 500: Server error + */ +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { code } = await params; + + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + } + + // Check rate limit (30 lookups per minute) + const isRateLimited = await checkSessionJoinLookupRateLimit(user.userId); + if (isRateLimited) { + return NextResponse.json( + { error: "Rate limit exceeded. Please wait before looking up another session." }, + { status: 429 } + ); + } + + // Look up session by code + const result = await lookupSessionByCode(code); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: result.statusCode || 500 }); + } + + return NextResponse.json({ session: result.session }); + } catch (error) { + console.error("[Session Join API] Failed to lookup session:", error); + return NextResponse.json( + { error: "An error occurred while looking up the session." }, + { status: 500 } + ); + } +} + +// --------------------------------------------------------------------------- +// POST /api/sessions/join/[code] +// --------------------------------------------------------------------------- + +/** + * Joins a session by creating a CourseEnrollment for the session's course. + * + * Returns: + * - 201: Successfully joined + * - 401: Not authenticated + * - 404: Session not found + * - 409: Already enrolled in course + * - 410: Session has ended + * - 429: Rate limit exceeded + * - 500: Server error + */ +export async function POST(_request: NextRequest, { params }: RouteParams) { + try { + const { code } = await params; + + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + } + + // Check rate limit (10 registrations per minute) + const isRateLimited = await checkSessionJoinRegisterRateLimit(user.userId); + if (isRateLimited) { + return NextResponse.json( + { error: "Rate limit exceeded. Please wait before joining another session." }, + { status: 429 } + ); + } + + // Join session + const result = await joinSession(code, user.userId); + + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: result.statusCode || 500 }); + } + + return NextResponse.json( + { + enrollment: result.enrollment, + session: result.session, + }, + { status: 201 } + ); + } catch (error) { + console.error("[Session Join API] Failed to join session:", error); + return NextResponse.json( + { error: "An error occurred while joining the session." }, + { status: 500 } + ); + } +}