├── Procfile ├── .gitignore ├── jest.config.js ├── prisma ├── migrations │ ├── migration_lock.toml │ ├── 20210318162338_add_feedback │ │ └── migration.sql │ └── 20201214130854_init │ │ └── migration.sql ├── schema.prisma └── seed.ts ├── src ├── index.ts ├── plugins │ ├── status.ts │ ├── prisma.ts │ ├── email.ts │ ├── users-enrollment.ts │ ├── tests.ts │ ├── courses.ts │ ├── test-results.ts │ ├── users.ts │ └── auth.ts ├── seed-users.ts ├── server.ts └── auth-helpers.ts ├── .example.env ├── tsconfig.json ├── tests ├── prisma.test.ts ├── status.test.ts ├── test-helpers.ts ├── users-enrollment.test.ts ├── test-results.test.ts ├── tests.test.ts ├── users.test.ts └── courses.test.ts ├── docker-compose.yml ├── package.json ├── .github └── workflows │ └── grading-app.yaml └── README.md /Procfile: -------------------------------------------------------------------------------- 1 | release: npx prisma migrate deploy 2 | 3 | web: npm start -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .env* 4 | yarn-error.log 5 | .vscode 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | } 5 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createServer, startServer } from './server' 2 | 3 | createServer() 4 | .then(startServer) 5 | .catch((err) => { 6 | console.log(err) 7 | }) -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | # Get from the SendGrid console 2 | SENDGRID_API_KEY= 3 | # Generate JWT_SECRET using the following command: node -e "console.log(require('crypto').randomBytes(256).toString('base64'));" 4 | JWT_SECRET= -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "outDir": "dist", 5 | "target": "ES2018", 6 | "module": "commonjs", 7 | "strict": true, 8 | "lib": ["esnext"], 9 | "esModuleInterop": true 10 | }, 11 | "exclude": ["dist", "prisma", "tests"] 12 | } 13 | -------------------------------------------------------------------------------- /tests/prisma.test.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | describe('example test with Prisma Client', () => { 4 | let prisma = new PrismaClient() 5 | 6 | beforeAll(async () => { 7 | await prisma.$connect() 8 | }) 9 | afterAll(async () => { 10 | await prisma.$disconnect() 11 | }) 12 | test('test query', async () => { 13 | const data = await prisma.user.findMany({ take: 1, select: { id: true } }) 14 | expect(data).toBeTruthy() 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/plugins/status.ts: -------------------------------------------------------------------------------- 1 | import Hapi from '@hapi/hapi' 2 | 3 | const plugin: Hapi.Plugin = { 4 | name: 'app/status', 5 | register: async function (server: Hapi.Server) { 6 | server.route({ 7 | // default status endpoint 8 | method: 'GET', 9 | path: '/', 10 | handler: (_, h: Hapi.ResponseToolkit) => 11 | h.response({ up: true }).code(200), 12 | options: { 13 | auth: false, 14 | }, 15 | }) 16 | }, 17 | } 18 | 19 | export default plugin 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | # In Docker 4 | # - TEST_POSTGRES_URI=postgres://prisma:prisma@postgres/ 5 | # Outside Docker 6 | # - TEST_POSTGRES_URI=postgres://prisma:prisma@localhost:5432/ 7 | 8 | postgres: 9 | image: postgres:14.1 10 | restart: always 11 | environment: 12 | - POSTGRES_USER=prisma 13 | - POSTGRES_PASSWORD=prisma 14 | volumes: 15 | - postgres:/var/lib/postgresql/data 16 | ports: 17 | - '5432:5432' 18 | 19 | volumes: 20 | postgres: 21 | -------------------------------------------------------------------------------- /prisma/migrations/20210318162338_add_feedback/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "CourseFeedback" ( 3 | "id" SERIAL NOT NULL, 4 | "feedback" TEXT NOT NULL, 5 | "studentId" INTEGER NOT NULL, 6 | "courseId" INTEGER NOT NULL, 7 | 8 | PRIMARY KEY ("id") 9 | ); 10 | 11 | -- AddForeignKey 12 | ALTER TABLE "CourseFeedback" ADD FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 13 | 14 | -- AddForeignKey 15 | ALTER TABLE "CourseFeedback" ADD FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE; 16 | -------------------------------------------------------------------------------- /tests/status.test.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from '../src/server' 2 | import Hapi from '@hapi/hapi' 3 | 4 | describe('Status plugin', () => { 5 | let server: Hapi.Server 6 | 7 | beforeAll(async () => { 8 | server = await createServer() 9 | }) 10 | 11 | afterAll(async () => { 12 | await server.stop() 13 | }) 14 | 15 | test('status endpoint returns 200', async () => { 16 | const res = await server.inject({ 17 | method: 'GET', 18 | url: '/', 19 | }) 20 | expect(res.statusCode).toEqual(200) 21 | const response = JSON.parse(res.payload) 22 | expect(response.up).toEqual(true) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /src/plugins/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | import Hapi from '@hapi/hapi' 3 | 4 | // Module augmentation to add shared application state 5 | // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/33809#issuecomment-472103564 6 | declare module '@hapi/hapi' { 7 | interface ServerApplicationState { 8 | prisma: PrismaClient 9 | } 10 | } 11 | 12 | // plugin to instantiate Prisma Client 13 | const prismaPlugin: Hapi.Plugin = { 14 | name: 'prisma', 15 | register: async function (server: Hapi.Server) { 16 | const prisma = new PrismaClient({ 17 | // Uncomment 👇 for logs 18 | // log: ['error', 'warn', 'query'], 19 | }) 20 | 21 | server.app.prisma = prisma 22 | 23 | // Close DB connection after the server's connection listeners are stopped 24 | // Related issue: https://github.com/hapijs/hapi/issues/2839 25 | server.ext({ 26 | type: 'onPostStop', 27 | method: async (server: Hapi.Server) => { 28 | server.app.prisma.$disconnect() 29 | }, 30 | }) 31 | }, 32 | } 33 | 34 | export default prismaPlugin 35 | -------------------------------------------------------------------------------- /src/seed-users.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | // Instantiate Prisma Client 4 | const prisma = new PrismaClient() 5 | 6 | // A `main` function so that we can use async/await 7 | async function main() { 8 | const testUser = await prisma.user.upsert({ 9 | create: { 10 | email: 'test@prisma.io', 11 | firstName: 'Grace', 12 | lastName: 'Bell', 13 | }, 14 | update: { 15 | firstName: 'Grace', 16 | lastName: 'Bell', 17 | }, 18 | where: { 19 | email: 'test@prisma.io', 20 | }, 21 | }) 22 | const testAdmin = await prisma.user.upsert({ 23 | create: { 24 | email: 'test-admin@prisma.io', 25 | firstName: 'Raini', 26 | lastName: 'Goenka', 27 | isAdmin: true, 28 | }, 29 | update: { 30 | firstName: 'Raini', 31 | lastName: 'Goenka', 32 | isAdmin: true, 33 | }, 34 | where: { 35 | email: 'test-admin@prisma.io', 36 | }, 37 | }) 38 | 39 | console.log( 40 | `Created test user\tid: ${testUser.id} | email: ${testUser.email} `, 41 | ) 42 | console.log( 43 | `Created test admin\tid: ${testAdmin.id} | email: ${testAdmin.email} `, 44 | ) 45 | } 46 | 47 | main() 48 | .catch((e: Error) => { 49 | console.error(e) 50 | process.exit(1) 51 | }) 52 | .finally(async () => { 53 | // Disconnect Prisma Client 54 | await prisma.$disconnect() 55 | }) 56 | -------------------------------------------------------------------------------- /src/plugins/email.ts: -------------------------------------------------------------------------------- 1 | import Hapi from '@hapi/hapi' 2 | import Joi from 'joi' 3 | import Boom from '@hapi/boom' 4 | import sendgrid from '@sendgrid/mail' 5 | 6 | // Module augmentation to add shared application state 7 | // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/33809#issuecomment-472103564 8 | declare module '@hapi/hapi' { 9 | interface ServerApplicationState { 10 | sendEmailToken(email: string, token: string): Promise 11 | } 12 | } 13 | 14 | const emailPlugin = { 15 | name: 'app/email', 16 | register: async function (server: Hapi.Server) { 17 | if (!process.env.SENDGRID_API_KEY) { 18 | server.log( 19 | 'warn', 20 | `The SENDGRID_API_KEY env var must be set, otherwise the API won't be able to send emails. Using debug mode which logs the email tokens instead.`, 21 | ) 22 | server.app.sendEmailToken = debugSendEmailToken 23 | } else { 24 | sendgrid.setApiKey(process.env.SENDGRID_API_KEY) 25 | server.app.sendEmailToken = sendEmailToken 26 | } 27 | }, 28 | } 29 | 30 | export default emailPlugin 31 | 32 | async function sendEmailToken(email: string, token: string) { 33 | const msg = { 34 | to: email, 35 | from: 'norman@prisma.io', 36 | subject: 'Login token for the modern backend API', 37 | text: `The login token for the API is: ${token}`, 38 | } 39 | 40 | await sendgrid.send(msg) 41 | } 42 | 43 | async function debugSendEmailToken(email: string, token: string) { 44 | console.log(`email token for ${email}: ${token} `) 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prisma-class", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "dependencies": { 6 | "@hapi/boom": "9.1.4", 7 | "@hapi/hapi": "20.2.1", 8 | "joi": "17.4.2", 9 | "@prisma/client": "3.5.0", 10 | "@sendgrid/mail": "7.4.6", 11 | "prisma": "3.5.0", 12 | "date-fns": "2.22.1", 13 | "dotenv": "10.0.0", 14 | "hapi-auth-jwt2": "10.2.0", 15 | "hapi-pino": "8.3.0", 16 | "jsonwebtoken": "8.5.1" 17 | }, 18 | "devDependencies": { 19 | "@types/hapi-pino": "8.0.2", 20 | "@types/hapi__hapi": "20.0.9", 21 | "@types/jest": "27.0.3", 22 | "@types/jsonwebtoken": "8.5.6", 23 | "jest": "27.3.1", 24 | "ts-jest": "27.0.7", 25 | "ts-node": "10.4.0", 26 | "ts-node-dev": "1.1.8", 27 | "typescript": "4.3.5" 28 | }, 29 | "scripts": { 30 | "build": "prisma generate && tsc", 31 | "start": "node dist/index.js", 32 | "compile": "tsc", 33 | "dev": "ts-node-dev --respawn ./src/index.ts", 34 | "seed": "prisma db seed --preview-feature", 35 | "seed-users": "ts-node ./src/seed-users.ts", 36 | "test": "TEST=true jest", 37 | "test:watch": "TEST=true jest --watch", 38 | "postgres:start": "docker-compose up -d", 39 | "db:push": "prisma db push --preview-feature", 40 | "migrate:reset": "prisma migrate reset", 41 | "migrate:create": "prisma migrate dev --create-only", 42 | "migrate:dev": "prisma migrate dev", 43 | "migrate:deploy": "prisma migrate deploy", 44 | "prisma:generate": "prisma generate" 45 | }, 46 | "engines": { 47 | "node": "16.x" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/grading-app.yaml: -------------------------------------------------------------------------------- 1 | name: grading-app 2 | on: push 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | # Service containers to run with `container-job` 8 | services: 9 | # Label used to access the service container 10 | postgres: 11 | # Docker Hub image 12 | image: postgres 13 | # Provide the password for postgres 14 | env: 15 | POSTGRES_USER: postgres 16 | POSTGRES_PASSWORD: postgres 17 | options: >- 18 | --health-cmd pg_isready 19 | --health-interval 10s 20 | --health-timeout 5s 21 | --health-retries 5 22 | ports: 23 | # Maps tcp port 5432 on service container to the host 24 | - 5432:5432 25 | env: 26 | DATABASE_URL: postgresql://postgres:postgres@localhost:5432/grading-app 27 | 28 | steps: 29 | - uses: actions/checkout@v2 30 | - uses: actions/setup-node@v1 31 | with: 32 | node-version: '14.x' 33 | - run: npm ci 34 | - run: npm run build 35 | # run the migration in the test database 36 | - run: npm run db:push 37 | - run: npm run test 38 | deploy: 39 | runs-on: ubuntu-latest 40 | if: github.event_name == 'push' && github.ref == 'refs/heads/master' # Only deploy master 41 | needs: test 42 | steps: 43 | - uses: actions/checkout@v2 44 | # - name: Run production migration 45 | # Note: disabled migrate deploy in favour of using the Procfile release phase 46 | # run: npm run migrate:deploy 47 | # env: 48 | # DATABASE_URL: ${{ secrets.DATABASE_URL }} 49 | - uses: akhileshns/heroku-deploy@v3.12.12 50 | name: Deploy to Heroku 51 | with: 52 | heroku_api_key: ${{ secrets.HEROKU_API_KEY }} 53 | heroku_app_name: ${{ secrets.HEROKU_APP_NAME }} 54 | heroku_email: ${{ secrets.HEROKU_EMAIL }} -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import Hapi from '@hapi/hapi' 2 | import hapiAuthJWT from 'hapi-auth-jwt2' 3 | import prismaPlugin from './plugins/prisma' 4 | import emailPlugin from './plugins/email' 5 | import usersPlugin from './plugins/users' 6 | import usersEnrollmentPlugin from './plugins/users-enrollment' 7 | import statusPlugin from './plugins/status' 8 | import coursesPlugin from './plugins/courses' 9 | import testsPlugin from './plugins/tests' 10 | import authPlugin from './plugins/auth' 11 | import testResultsPlugin from './plugins/test-results' 12 | import dotenv from 'dotenv' 13 | import hapiPino from 'hapi-pino' 14 | 15 | dotenv.config() 16 | 17 | const isProduction = process.env.NODE_ENV === 'production' 18 | 19 | const server: Hapi.Server = Hapi.server({ 20 | port: process.env.PORT || 3000, 21 | host: process.env.HOST || '0.0.0.0', 22 | }) 23 | 24 | export async function createServer(): Promise { 25 | // Register the logger 26 | await server.register({ 27 | plugin: hapiPino, 28 | options: { 29 | logEvents: (process.env.CI === 'true' || process.env.TEST === 'true') ? false : undefined, 30 | prettyPrint: process.env.NODE_ENV !== 'production', 31 | // Redact Authorization headers, see https://getpino.io/#/docs/redaction 32 | redact: ['req.headers.authorization'], 33 | }, 34 | }) 35 | 36 | await server.register([ 37 | hapiAuthJWT, 38 | authPlugin, 39 | prismaPlugin, 40 | emailPlugin, 41 | statusPlugin, 42 | usersPlugin, 43 | usersEnrollmentPlugin, 44 | coursesPlugin, 45 | testsPlugin, 46 | testResultsPlugin, 47 | ]) 48 | await server.initialize() 49 | 50 | return server 51 | } 52 | 53 | export async function startServer(server: Hapi.Server): Promise { 54 | await server.start() 55 | server.log('info', `Server running on ${server.info.uri}`) 56 | return server 57 | } 58 | 59 | process.on('unhandledRejection', (err) => { 60 | console.log(err) 61 | process.exit(1) 62 | }) 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Real-world class grading backend 2 | 3 | ## ⚠️ Warning 4 | 5 | This repo is no longer actively maintained, however it's still a [great educational](https://www.prisma.io/blog/backend-prisma-typescript-orm-with-postgresql-data-modeling-tsjs1ps7kip1) resource for building REST APIs in Node.js. 6 | 7 | ## Intro 8 | 9 | A real-world class grading application built with Prisma. 10 | 11 | The grading application is used to manage enrollment in online classes, tests (as in exams) for classes, and test results. 12 | 13 | The goal if this application is to showcase a real-world scenario of an application using Prisma. the following aspects of Prisma 14 | - Data modeling 15 | - CRUD 16 | - Aggregations 17 | - API layer 18 | - Validation 19 | - Testing 20 | - Authentication 21 | - Authorization 22 | - Integration with other APIs 23 | - Deployment 24 | 25 | Check out the [**associated tutorial**](https://www.prisma.io/blog/modern-backend-1-tsjs1ps7kip1/) to learn more about how the backend was built. 26 | 27 | ## Data model 28 | 29 | The development of this project is driven by the database schema (also known as the data model). 30 | The schema is first designed to represent the following concepts: 31 | 32 | - **User**: this can be a student or a teacher, or both. The role of the user is determined through their association with a course. 33 | - **Course**: represent a course that can have multiple students/teachers. Each user can be associated with multiple courses either as a student or as a teacher. 34 | - **CourseEnrollment**: 35 | - **Test**: Each course can have many tests 36 | - **TestResult**: Each Test can have many TestReusults that is associated with a student 37 | 38 | These are defined in the [Prisma schema](./prisma/schema.prisma). 39 | The database schema will be created by Prisma Migrate. 40 | 41 | ## Tech Stack 42 | 43 | - Backend: 44 | - PostgreSQL 45 | - Node.js 46 | - Prisma 47 | - TypeScript 48 | - Jest 49 | - Hapi.js 50 | 51 | ## How to use 52 | 53 | Install npm dependencies: 54 | 55 | ``` 56 | npm install 57 | ``` 58 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model User { 11 | id Int @id @default(autoincrement()) 12 | email String @unique 13 | firstName String? 14 | lastName String? 15 | social Json? 16 | isAdmin Boolean @default(false) 17 | 18 | // Relation fields 19 | courses CourseEnrollment[] 20 | testResults TestResult[] @relation(name: "results") 21 | testsGraded TestResult[] @relation(name: "graded") 22 | tokens Token[] 23 | feedback CourseFeedback[] 24 | } 25 | 26 | model Token { 27 | id Int @id @default(autoincrement()) 28 | createdAt DateTime @default(now()) 29 | updatedAt DateTime @updatedAt 30 | type TokenType 31 | emailToken String? @unique // Only used for short lived email tokens 32 | valid Boolean @default(true) 33 | expiration DateTime 34 | 35 | // Relation fields 36 | user User @relation(fields: [userId], references: [id]) 37 | userId Int 38 | } 39 | 40 | model Course { 41 | id Int @id @default(autoincrement()) 42 | name String 43 | courseDetails String? 44 | 45 | // Relation fields 46 | members CourseEnrollment[] 47 | tests Test[] 48 | feedback CourseFeedback[] 49 | } 50 | 51 | model CourseFeedback { 52 | id Int @id @default(autoincrement()) 53 | feedback String @db.Text 54 | 55 | studentId Int 56 | courseId Int 57 | student User @relation(fields: [studentId], references: [id]) 58 | course Course @relation(fields: [courseId], references: [id]) 59 | } 60 | 61 | model CourseEnrollment { 62 | createdAt DateTime @default(now()) 63 | role UserRole 64 | 65 | // Relation Fields 66 | userId Int 67 | courseId Int 68 | user User @relation(fields: [userId], references: [id]) 69 | course Course @relation(fields: [courseId], references: [id]) 70 | 71 | @@id([userId, courseId]) 72 | @@index([userId, role]) 73 | } 74 | 75 | model Test { 76 | id Int @id @default(autoincrement()) 77 | updatedAt DateTime @updatedAt 78 | name String 79 | date DateTime 80 | 81 | // Relation Fields 82 | courseId Int 83 | course Course @relation(fields: [courseId], references: [id]) 84 | testResults TestResult[] 85 | } 86 | 87 | model TestResult { 88 | id Int @id @default(autoincrement()) 89 | createdAt DateTime @default(now()) 90 | result Int // Percentage precise to one decimal point represented as `result * 10^-1` 91 | 92 | // Relation Fields 93 | studentId Int 94 | student User @relation(name: "results", fields: [studentId], references: [id]) 95 | graderId Int 96 | gradedBy User @relation(name: "graded", fields: [graderId], references: [id]) 97 | testId Int 98 | test Test @relation(fields: [testId], references: [id]) 99 | } 100 | 101 | enum UserRole { 102 | STUDENT 103 | TEACHER 104 | } 105 | 106 | enum TokenType { 107 | EMAIL // used as a short lived token sent to the user's email 108 | API 109 | } 110 | -------------------------------------------------------------------------------- /prisma/migrations/20201214130854_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "public"."UserRole" AS ENUM ('STUDENT', 'TEACHER'); 3 | 4 | -- CreateEnum 5 | CREATE TYPE "public"."TokenType" AS ENUM ('EMAIL', 'API'); 6 | 7 | -- CreateTable 8 | CREATE TABLE "User" ( 9 | "id" SERIAL, 10 | "email" TEXT NOT NULL, 11 | "firstName" TEXT, 12 | "lastName" TEXT, 13 | "social" JSONB, 14 | "isAdmin" BOOLEAN NOT NULL DEFAULT false, 15 | 16 | PRIMARY KEY ("id") 17 | ); 18 | 19 | -- CreateTable 20 | CREATE TABLE "Token" ( 21 | "id" SERIAL, 22 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 23 | "updatedAt" TIMESTAMP(3) NOT NULL, 24 | "type" "TokenType" NOT NULL, 25 | "emailToken" TEXT, 26 | "valid" BOOLEAN NOT NULL DEFAULT true, 27 | "expiration" TIMESTAMP(3) NOT NULL, 28 | "userId" INTEGER NOT NULL, 29 | 30 | PRIMARY KEY ("id") 31 | ); 32 | 33 | -- CreateTable 34 | CREATE TABLE "Course" ( 35 | "id" SERIAL, 36 | "name" TEXT NOT NULL, 37 | "courseDetails" TEXT, 38 | 39 | PRIMARY KEY ("id") 40 | ); 41 | 42 | -- CreateTable 43 | CREATE TABLE "CourseEnrollment" ( 44 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 45 | "role" "UserRole" NOT NULL, 46 | "userId" INTEGER NOT NULL, 47 | "courseId" INTEGER NOT NULL, 48 | 49 | PRIMARY KEY ("userId","courseId") 50 | ); 51 | 52 | -- CreateTable 53 | CREATE TABLE "Test" ( 54 | "id" SERIAL, 55 | "updatedAt" TIMESTAMP(3) NOT NULL, 56 | "name" TEXT NOT NULL, 57 | "date" TIMESTAMP(3) NOT NULL, 58 | "courseId" INTEGER NOT NULL, 59 | 60 | PRIMARY KEY ("id") 61 | ); 62 | 63 | -- CreateTable 64 | CREATE TABLE "TestResult" ( 65 | "id" SERIAL, 66 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 67 | "result" INTEGER NOT NULL, 68 | "studentId" INTEGER NOT NULL, 69 | "graderId" INTEGER NOT NULL, 70 | "testId" INTEGER NOT NULL, 71 | 72 | PRIMARY KEY ("id") 73 | ); 74 | 75 | -- CreateIndex 76 | CREATE UNIQUE INDEX "User.email_unique" ON "User"("email"); 77 | 78 | -- CreateIndex 79 | CREATE UNIQUE INDEX "Token.emailToken_unique" ON "Token"("emailToken"); 80 | 81 | -- CreateIndex 82 | CREATE INDEX "CourseEnrollment.userId_role_index" ON "CourseEnrollment"("userId", "role"); 83 | 84 | -- AddForeignKey 85 | ALTER TABLE "Token" ADD FOREIGN KEY("userId")REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 86 | 87 | -- AddForeignKey 88 | ALTER TABLE "CourseEnrollment" ADD FOREIGN KEY("userId")REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 89 | 90 | -- AddForeignKey 91 | ALTER TABLE "CourseEnrollment" ADD FOREIGN KEY("courseId")REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE; 92 | 93 | -- AddForeignKey 94 | ALTER TABLE "Test" ADD FOREIGN KEY("courseId")REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE; 95 | 96 | -- AddForeignKey 97 | ALTER TABLE "TestResult" ADD FOREIGN KEY("studentId")REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 98 | 99 | -- AddForeignKey 100 | ALTER TABLE "TestResult" ADD FOREIGN KEY("graderId")REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 101 | 102 | -- AddForeignKey 103 | ALTER TABLE "TestResult" ADD FOREIGN KEY("testId")REFERENCES "Test"("id") ON DELETE CASCADE ON UPDATE CASCADE; 104 | -------------------------------------------------------------------------------- /src/auth-helpers.ts: -------------------------------------------------------------------------------- 1 | import Boom from '@hapi/boom' 2 | import Hapi from '@hapi/hapi' 3 | 4 | // Pre function to check if user is the teacher of a course and can modify it 5 | export async function isTeacherOfCourseOrAdmin( 6 | request: Hapi.Request, 7 | h: Hapi.ResponseToolkit, 8 | ) { 9 | const { isAdmin, teacherOf } = request.auth.credentials 10 | 11 | if (isAdmin) { 12 | // If the user is an admin allow 13 | return h.continue 14 | } 15 | 16 | const courseId = parseInt(request.params.courseId, 10) 17 | 18 | if (teacherOf?.includes(courseId)) { 19 | return h.continue 20 | } 21 | // If the user is not a teacher of the course, deny access 22 | throw Boom.forbidden() 23 | } 24 | 25 | // Pre function to check if authenticated user is the grader of a testResult 26 | export async function isGraderOfTestResultOrAdmin( 27 | request: Hapi.Request, 28 | h: Hapi.ResponseToolkit, 29 | ) { 30 | const { userId, isAdmin, teacherOf } = request.auth.credentials 31 | 32 | if (isAdmin) { 33 | // If the user is an admin allow 34 | return h.continue 35 | } 36 | 37 | const testResultId = parseInt(request.params.testResultId, 10) 38 | const { prisma } = request.server.app 39 | 40 | const testResult = await prisma.testResult.findUnique({ 41 | where: { 42 | id: testResultId, 43 | }, 44 | }) 45 | 46 | if (testResult?.graderId === userId) { 47 | return h.continue 48 | } 49 | // The authenticated user is not a teacher 50 | throw Boom.forbidden() 51 | } 52 | 53 | // Pre function to check if the authenticated user matches the requested user 54 | export async function isRequestedUserOrAdmin( 55 | request: Hapi.Request, 56 | h: Hapi.ResponseToolkit, 57 | ) { 58 | const { userId, isAdmin } = request.auth.credentials 59 | 60 | if (isAdmin) { 61 | // If the user is an admin allow 62 | return h.continue 63 | } 64 | 65 | const requestedUserId = parseInt(request.params.userId, 10) 66 | 67 | if (requestedUserId === userId) { 68 | return h.continue 69 | } 70 | 71 | // The authenticated user is not authorized 72 | throw Boom.forbidden() 73 | } 74 | 75 | // Pre function to check if the authenticated user matches the requested user 76 | export async function isAdmin(request: Hapi.Request, h: Hapi.ResponseToolkit) { 77 | if (request.auth.credentials.isAdmin) { 78 | // If the user is an admin allow 79 | return h.continue 80 | } 81 | 82 | // The authenticated user is not a teacher 83 | throw Boom.forbidden() 84 | } 85 | 86 | // Pre function to check if user is the teacher of a test's course 87 | export async function isTeacherOfTestOrAdmin( 88 | request: Hapi.Request, 89 | h: Hapi.ResponseToolkit, 90 | ) { 91 | const { isAdmin, teacherOf } = request.auth.credentials 92 | 93 | if (isAdmin) { 94 | // If the user is an admin allow 95 | return h.continue 96 | } 97 | 98 | const testId = parseInt(request.params.testId, 10) 99 | const { prisma } = request.server.app 100 | 101 | const test = await prisma.test.findUnique({ 102 | where: { 103 | id: testId, 104 | }, 105 | select: { 106 | course: { 107 | select: { 108 | id: true, 109 | }, 110 | }, 111 | }, 112 | }) 113 | 114 | if (test?.course.id && teacherOf.includes(test?.course.id)) { 115 | return h.continue 116 | } 117 | // The authenticated user is not a teacher 118 | throw Boom.forbidden() 119 | } 120 | -------------------------------------------------------------------------------- /tests/test-helpers.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | import { TokenType, UserRole } from '@prisma/client' 3 | import { add } from 'date-fns' 4 | import { AuthCredentials } from '@hapi/hapi' 5 | 6 | // Helper function to create a test user and return the credentials object the same way that the auth plugin does 7 | export const createUserCredentials = async ( 8 | prisma: PrismaClient, 9 | isAdmin: boolean, 10 | ): Promise => { 11 | const testUser = await prisma.user.create({ 12 | data: { 13 | email: `test-${Date.now()}@test.com`, 14 | isAdmin, 15 | tokens: { 16 | create: { 17 | expiration: add(new Date(), { days: 7 }), 18 | type: TokenType.API, 19 | }, 20 | }, 21 | }, 22 | include: { 23 | tokens: true, 24 | courses: { 25 | where: { 26 | role: UserRole.TEACHER, 27 | }, 28 | select: { 29 | courseId: true, 30 | }, 31 | }, 32 | }, 33 | }) 34 | 35 | return { 36 | userId: testUser.id, 37 | tokenId: testUser.tokens[0].id, 38 | isAdmin: testUser.isAdmin, 39 | teacherOf: testUser.courses?.map(({ courseId }) => courseId), 40 | } 41 | } 42 | 43 | // Helper function to create a course, test, student, and a teacher 44 | export const createCourseTestStudentTeacher = async ( 45 | prisma: PrismaClient, 46 | ): Promise<{ 47 | courseId: number 48 | testId: number 49 | studentId: number 50 | teacherId: number 51 | studentCredentials: AuthCredentials 52 | teacherCredentials: AuthCredentials 53 | }> => { 54 | const teacherCredentials = await createUserCredentials(prisma, false) 55 | const studentCredentials = await createUserCredentials(prisma, false) 56 | 57 | const now = Date.now().toString() 58 | const course = await prisma.course.create({ 59 | data: { 60 | name: `test-course-${now}`, 61 | courseDetails: `test-course-${now}-details`, 62 | members: { 63 | create: [ 64 | { 65 | role: UserRole.TEACHER, 66 | user: { 67 | connect: { 68 | id: teacherCredentials.userId, 69 | }, 70 | }, 71 | }, 72 | { 73 | role: UserRole.STUDENT, 74 | user: { 75 | connect: { 76 | id: studentCredentials.userId, 77 | }, 78 | }, 79 | }, 80 | ], 81 | }, 82 | tests: { 83 | create: [ 84 | { 85 | date: add(new Date(), { days: 7 }), 86 | name: 'First test', 87 | }, 88 | ], 89 | }, 90 | }, 91 | include: { 92 | tests: true, 93 | }, 94 | }) 95 | 96 | // 👇Update the credentials as they're static in tests (not fetched automatically on request by the auth plugin) 97 | teacherCredentials.teacherOf.push(course.id) 98 | 99 | return { 100 | courseId: course.id, 101 | testId: course.tests[0].id, 102 | teacherId: teacherCredentials.userId, 103 | teacherCredentials, 104 | studentId: studentCredentials.userId, 105 | studentCredentials, 106 | } 107 | } 108 | 109 | // Helper function to create a course 110 | export const createCourse = async (prisma: PrismaClient): Promise => { 111 | const course = await prisma.course.create({ 112 | data: { 113 | name: `test-course-${Date.now().toString()}`, 114 | courseDetails: `test-course-${Date.now().toString()}-details`, 115 | }, 116 | }) 117 | return course.id 118 | } 119 | -------------------------------------------------------------------------------- /tests/users-enrollment.test.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from '../src/server' 2 | import Hapi, { AuthCredentials } from '@hapi/hapi' 3 | import { createCourse, createUserCredentials } from './test-helpers' 4 | import { API_AUTH_STATEGY } from '../src/plugins/auth' 5 | 6 | describe('user specific courses endpoints', () => { 7 | let server: Hapi.Server 8 | let teacherCredentials: AuthCredentials 9 | let studentCredentials: AuthCredentials 10 | let studentId: number 11 | let teacherId: number 12 | let courseId: number 13 | 14 | beforeAll(async () => { 15 | server = await createServer() 16 | 17 | studentCredentials = await createUserCredentials(server.app.prisma, false) 18 | teacherCredentials = await createUserCredentials(server.app.prisma, false) 19 | courseId = await createCourse(server.app.prisma) 20 | studentId = studentCredentials.userId 21 | teacherId = teacherCredentials.userId 22 | }) 23 | 24 | afterAll(async () => { 25 | await server.stop() 26 | }) 27 | 28 | test(`add a user as a student to a course`, async () => { 29 | const response = await server.inject({ 30 | method: 'POST', 31 | url: `/users/${studentId}/courses`, 32 | auth: { 33 | strategy: API_AUTH_STATEGY, 34 | credentials: studentCredentials, 35 | }, 36 | payload: { 37 | courseId: courseId, 38 | role: 'STUDENT', 39 | }, 40 | }) 41 | expect(response.statusCode).toEqual(201) 42 | const userCourse = JSON.parse(response.payload) 43 | expect(userCourse.role).toEqual('STUDENT') 44 | expect(userCourse.userId).toEqual(studentId) 45 | expect(userCourse.courseId).toEqual(courseId) 46 | }) 47 | 48 | test('add a user as a teacher to a course', async () => { 49 | const response = await server.inject({ 50 | method: 'POST', 51 | url: `/users/${teacherId}/courses`, 52 | auth: { 53 | strategy: API_AUTH_STATEGY, 54 | credentials: teacherCredentials, 55 | }, 56 | payload: { 57 | courseId: courseId, 58 | role: 'TEACHER', 59 | }, 60 | }) 61 | expect(response.statusCode).toEqual(201) 62 | const userCourse = JSON.parse(response.payload) 63 | expect(userCourse.role).toEqual('TEACHER') 64 | expect(userCourse.userId).toEqual(teacherId) 65 | expect(userCourse.courseId).toEqual(courseId) 66 | }) 67 | 68 | test('add a user to a course validation', async () => { 69 | const response = await server.inject({ 70 | method: 'POST', 71 | url: `/users/${teacherId}/courses`, 72 | auth: { 73 | strategy: API_AUTH_STATEGY, 74 | credentials: teacherCredentials, 75 | }, 76 | payload: { 77 | courseId: courseId, 78 | role: 'NONEXISTANT', 79 | }, 80 | }) 81 | expect(response.statusCode).toEqual(400) 82 | }) 83 | 84 | test('get user courses', async () => { 85 | const response = await server.inject({ 86 | method: 'GET', 87 | url: `/users/${studentId}/courses`, 88 | auth: { 89 | strategy: API_AUTH_STATEGY, 90 | credentials: studentCredentials, 91 | }, 92 | }) 93 | expect(response.statusCode).toEqual(200) 94 | const userCourses = JSON.parse(response.payload) 95 | expect(userCourses[0]?.id).toEqual(courseId) 96 | }) 97 | 98 | test('delete user enrollment in course', async () => { 99 | const response = await server.inject({ 100 | method: 'DELETE', 101 | url: `/users/${studentId}/courses/${courseId}`, 102 | auth: { 103 | strategy: API_AUTH_STATEGY, 104 | credentials: studentCredentials, 105 | }, 106 | }) 107 | expect(response.statusCode).toEqual(204) 108 | }) 109 | 110 | test('get user courses is empty after deletion', async () => { 111 | const response = await server.inject({ 112 | method: 'GET', 113 | url: `/users/${studentId}/courses`, 114 | auth: { 115 | strategy: API_AUTH_STATEGY, 116 | credentials: studentCredentials, 117 | }, 118 | }) 119 | expect(response.statusCode).toEqual(200) 120 | const userCourses = JSON.parse(response.payload) 121 | expect(userCourses.length).toEqual(0) 122 | }) 123 | }) 124 | -------------------------------------------------------------------------------- /tests/test-results.test.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from '../src/server' 2 | import Hapi, { AuthCredentials } from '@hapi/hapi' 3 | import { add } from 'date-fns' 4 | import { createCourseTestStudentTeacher } from './test-helpers' 5 | import { API_AUTH_STATEGY } from '../src/plugins/auth' 6 | 7 | describe('test results endpoints', () => { 8 | let server: Hapi.Server 9 | 10 | let teacherCredentials: AuthCredentials 11 | let studentCredentials: AuthCredentials 12 | let courseId: number 13 | let testId: number 14 | let studentId: number 15 | let teacherId: number 16 | let testResultId: number 17 | 18 | beforeAll(async () => { 19 | server = await createServer() 20 | // create a course, an associated test, and two users (student and teacher) to assign test results to 21 | const testData = await createCourseTestStudentTeacher(server.app.prisma) 22 | 23 | courseId = testData.courseId 24 | testId = testData.testId 25 | studentId = testData.studentId 26 | teacherId = testData.teacherId 27 | teacherCredentials = testData.teacherCredentials 28 | studentCredentials = testData.studentCredentials 29 | }) 30 | 31 | afterAll(async () => { 32 | await server.stop() 33 | }) 34 | 35 | test('create test result', async () => { 36 | const response = await server.inject({ 37 | method: 'POST', 38 | url: `/courses/tests/${testId}/test-results`, 39 | auth: { 40 | strategy: API_AUTH_STATEGY, 41 | credentials: teacherCredentials, 42 | }, 43 | payload: { 44 | result: 950, 45 | studentId: studentId, 46 | graderId: teacherId, 47 | }, 48 | }) 49 | expect(response.statusCode).toEqual(201) 50 | const testResult = JSON.parse(response.payload) 51 | expect(typeof testResult.id === 'number').toBeTruthy() 52 | testResultId = testResult.id 53 | }) 54 | 55 | test('create test result validation', async () => { 56 | const response = await server.inject({ 57 | method: 'POST', 58 | url: `/courses/tests/${testId}/test-results`, 59 | auth: { 60 | strategy: API_AUTH_STATEGY, 61 | credentials: teacherCredentials, 62 | }, 63 | payload: { 64 | result: 1001, 65 | }, 66 | }) 67 | // Should return 400 because of missing fields 68 | expect(response.statusCode).toEqual(400) 69 | }) 70 | 71 | test('update test result', async () => { 72 | const response = await server.inject({ 73 | method: 'PUT', 74 | url: `/courses/tests/test-results/${testResultId}`, 75 | auth: { 76 | strategy: API_AUTH_STATEGY, 77 | credentials: teacherCredentials, 78 | }, 79 | payload: { 80 | result: 1000, 81 | }, 82 | }) 83 | expect(response.statusCode).toEqual(200) 84 | const testResult = JSON.parse(response.payload) 85 | expect(typeof testResult.id === 'number').toBeTruthy() 86 | }) 87 | 88 | test('get test results for a specific test', async () => { 89 | const response = await server.inject({ 90 | method: 'GET', 91 | url: `/courses/tests/${testId}/test-results`, 92 | auth: { 93 | strategy: API_AUTH_STATEGY, 94 | credentials: teacherCredentials, 95 | }, 96 | }) 97 | 98 | expect(response.statusCode).toEqual(200) 99 | const testResults = JSON.parse(response.payload) 100 | expect(testResults[0]?.testId).toEqual(testId) 101 | expect(testResults[0]?.result).toBeTruthy() 102 | }) 103 | 104 | test('get test results for a specific test fails for student', async () => { 105 | const response = await server.inject({ 106 | method: 'GET', 107 | url: `/courses/tests/${testId}/test-results`, 108 | auth: { 109 | strategy: API_AUTH_STATEGY, 110 | credentials: studentCredentials, 111 | }, 112 | }) 113 | 114 | expect(response.statusCode).toEqual(403) 115 | }) 116 | 117 | test('get test results for a specific user', async () => { 118 | const response = await server.inject({ 119 | method: 'GET', 120 | url: `/users/${studentId}/test-results`, 121 | auth: { 122 | strategy: API_AUTH_STATEGY, 123 | credentials: studentCredentials, 124 | }, 125 | }) 126 | 127 | expect(response.statusCode).toEqual(200) 128 | const testResults = JSON.parse(response.payload) 129 | expect(testResults[0]?.testId).toEqual(testId) 130 | expect(testResults[0]?.result).toEqual(1000) 131 | }) 132 | 133 | test('delete test result', async () => { 134 | const response = await server.inject({ 135 | method: 'DELETE', 136 | url: `/courses/tests/test-results/${testResultId}`, 137 | auth: { 138 | strategy: API_AUTH_STATEGY, 139 | credentials: teacherCredentials, 140 | }, 141 | }) 142 | 143 | expect(response.statusCode).toEqual(204) 144 | }) 145 | }) 146 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | import { add } from 'date-fns' 3 | 4 | // Instantiate Prisma Client 5 | const prisma = new PrismaClient() 6 | 7 | // A `main` function so that we can use async/await 8 | async function main() { 9 | await prisma.token.deleteMany({}) 10 | await prisma.testResult.deleteMany({}) 11 | await prisma.courseEnrollment.deleteMany({}) 12 | await prisma.test.deleteMany({}) 13 | await prisma.user.deleteMany({}) 14 | await prisma.course.deleteMany({}) 15 | 16 | const grace = await prisma.user.create({ 17 | data: { 18 | email: 'grace@hey.com', 19 | firstName: 'Grace', 20 | lastName: 'Bell', 21 | social: { 22 | facebook: 'gracebell', 23 | twitter: 'therealgracebell', 24 | }, 25 | }, 26 | }) 27 | 28 | const weekFromNow = add(new Date(), { days: 7 }) 29 | const twoWeekFromNow = add(new Date(), { days: 14 }) 30 | const monthFromNow = add(new Date(), { days: 28 }) 31 | 32 | const course = await prisma.course.create({ 33 | data: { 34 | name: 'CRUD with Prisma', 35 | tests: { 36 | create: [ 37 | { 38 | date: weekFromNow, 39 | name: 'First test', 40 | }, 41 | { 42 | date: twoWeekFromNow, 43 | name: 'Second test', 44 | }, 45 | { 46 | date: monthFromNow, 47 | name: 'Final exam', 48 | }, 49 | ], 50 | }, 51 | members: { 52 | create: { 53 | role: 'TEACHER', 54 | user: { 55 | connect: { 56 | email: grace.email, 57 | }, 58 | }, 59 | }, 60 | }, 61 | }, 62 | include: { 63 | tests: true, 64 | }, 65 | }) 66 | 67 | const shakuntala = await prisma.user.create({ 68 | data: { 69 | email: 'devi@prisma.io', 70 | firstName: 'Shakuntala', 71 | lastName: 'Devi', 72 | courses: { 73 | create: { 74 | role: 'STUDENT', 75 | course: { 76 | connect: { id: course.id }, 77 | }, 78 | }, 79 | }, 80 | }, 81 | }) 82 | 83 | const david = await prisma.user.create({ 84 | data: { 85 | email: 'david@prisma.io', 86 | firstName: 'David', 87 | lastName: 'Deutsch', 88 | courses: { 89 | create: { 90 | role: 'STUDENT', 91 | course: { 92 | connect: { id: course.id }, 93 | }, 94 | }, 95 | }, 96 | }, 97 | }) 98 | 99 | const feedback1 = await prisma.courseFeedback.create({ 100 | data: { 101 | feedback: 'Interesting course. Looking forward to learning more', 102 | course: { 103 | connect: { 104 | id: course.id 105 | } 106 | }, 107 | student: { 108 | connect: { 109 | id: david.id 110 | } 111 | } 112 | } 113 | }) 114 | 115 | const testResultsDavid = [650, 900, 950] 116 | const testResultsShakuntala = [800, 950, 910] 117 | 118 | let counter = 0 119 | for (const test of course.tests) { 120 | await prisma.testResult.create({ 121 | data: { 122 | gradedBy: { 123 | connect: { email: grace.email }, 124 | }, 125 | student: { 126 | connect: { email: shakuntala.email }, 127 | }, 128 | test: { 129 | connect: { id: test.id }, 130 | }, 131 | result: testResultsShakuntala[counter], 132 | }, 133 | }) 134 | 135 | await prisma.testResult.create({ 136 | data: { 137 | gradedBy: { 138 | connect: { email: grace.email }, 139 | }, 140 | student: { 141 | connect: { email: david.email }, 142 | }, 143 | test: { 144 | connect: { id: test.id }, 145 | }, 146 | result: testResultsDavid[counter], 147 | }, 148 | }) 149 | 150 | // Get aggregates for each test 151 | const results = await prisma.testResult.aggregate({ 152 | where: { 153 | testId: test.id, 154 | }, 155 | avg: { result: true }, 156 | max: { result: true }, 157 | min: { result: true }, 158 | count: true, 159 | }) 160 | console.log(`test: ${test.name} (id: ${test.id})`, results) 161 | 162 | counter++ 163 | } 164 | 165 | // Get aggregates for David 166 | const davidAggregates = await prisma.testResult.aggregate({ 167 | where: { 168 | student: { email: david.email }, 169 | }, 170 | avg: { result: true }, 171 | max: { result: true }, 172 | min: { result: true }, 173 | count: true, 174 | }) 175 | console.log(`David's results (email: ${david.email})`, davidAggregates) 176 | 177 | // Get aggregates for Shakuntala 178 | const shakuntalaAggregates = await prisma.testResult.aggregate({ 179 | where: { 180 | student: { email: shakuntala.email }, 181 | }, 182 | avg: { result: true }, 183 | max: { result: true }, 184 | min: { result: true }, 185 | count: true, 186 | }) 187 | console.log( 188 | `Shakuntala's results (email: ${shakuntala.email})`, 189 | shakuntalaAggregates, 190 | ) 191 | } 192 | 193 | main() 194 | .catch((e: Error) => { 195 | console.error(e) 196 | process.exit(1) 197 | }) 198 | .finally(async () => { 199 | // Disconnect Prisma Client 200 | await prisma.$disconnect() 201 | }) 202 | -------------------------------------------------------------------------------- /src/plugins/users-enrollment.ts: -------------------------------------------------------------------------------- 1 | import Hapi from '@hapi/hapi' 2 | import Joi, { object } from 'joi' 3 | import Boom from '@hapi/boom' 4 | import { UserRole } from '@prisma/client' 5 | import { API_AUTH_STATEGY } from './auth' 6 | import { isRequestedUserOrAdmin } from '../auth-helpers' 7 | 8 | const usersEnrollmentPlugin = { 9 | name: 'app/usersEnrollment', 10 | dependencies: ['prisma'], 11 | register: async function (server: Hapi.Server) { 12 | server.route([ 13 | { 14 | method: 'GET', 15 | path: '/users/{userId}/courses', 16 | handler: getUserEnrollmentsHandler, 17 | options: { 18 | pre: [isRequestedUserOrAdmin], 19 | auth: { 20 | mode: 'required', 21 | strategy: API_AUTH_STATEGY, 22 | }, 23 | validate: { 24 | params: Joi.object({ 25 | userId: Joi.number().integer(), 26 | }), 27 | failAction: (request, h, err) => { 28 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 29 | throw err 30 | }, 31 | }, 32 | }, 33 | }, 34 | { 35 | method: 'POST', 36 | path: '/users/{userId}/courses', 37 | handler: createUserEnrollmentHandler, 38 | options: { 39 | // TODO: ensure that only a teacher of a course can enroll other users as teachers 40 | pre: [isRequestedUserOrAdmin], 41 | auth: { 42 | mode: 'required', 43 | strategy: API_AUTH_STATEGY, 44 | }, 45 | validate: { 46 | params: Joi.object({ 47 | userId: Joi.number().integer(), 48 | }), 49 | payload: Joi.object({ 50 | courseId: Joi.number().integer(), 51 | // 👇 Allow roles derived from the generated Prisma types 52 | role: Joi.string().valid(...Object.values(UserRole)), 53 | }), 54 | failAction: (request, h, err) => { 55 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 56 | throw err 57 | }, 58 | }, 59 | }, 60 | }, 61 | { 62 | method: 'DELETE', 63 | path: '/users/{userId}/courses/{courseId}', 64 | handler: deleteUserEnrollmentHandler, 65 | options: { 66 | pre: [isRequestedUserOrAdmin], 67 | auth: { 68 | mode: 'required', 69 | strategy: API_AUTH_STATEGY, 70 | }, 71 | validate: { 72 | params: Joi.object({ 73 | userId: Joi.number().integer(), 74 | courseId: Joi.number().integer(), 75 | }), 76 | failAction: (request, h, err) => { 77 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 78 | throw err 79 | }, 80 | }, 81 | }, 82 | }, 83 | ]) 84 | }, 85 | } 86 | 87 | export default usersEnrollmentPlugin 88 | 89 | interface UserEnrollmentInput { 90 | courseId: number 91 | role: UserRole 92 | } 93 | 94 | async function getUserEnrollmentsHandler( 95 | request: Hapi.Request, 96 | h: Hapi.ResponseToolkit, 97 | ) { 98 | const { prisma } = request.server.app 99 | const userId = parseInt(request.params.userId, 10) 100 | 101 | try { 102 | const userCourses = await prisma.course.findMany({ 103 | where: { 104 | members: { 105 | some: { 106 | userId: userId, 107 | }, 108 | }, 109 | }, 110 | }) 111 | return h.response(userCourses).code(200) 112 | } catch (err) { 113 | request.log('error', err) 114 | return Boom.badImplementation('failed to get user') 115 | } 116 | } 117 | 118 | async function createUserEnrollmentHandler( 119 | request: Hapi.Request, 120 | h: Hapi.ResponseToolkit, 121 | ) { 122 | const { prisma } = request.server.app 123 | const userId = parseInt(request.params.userId, 10) 124 | const payload = request.payload as UserEnrollmentInput 125 | 126 | try { 127 | const userCourses = await prisma.courseEnrollment.create({ 128 | data: { 129 | user: { 130 | connect: { 131 | id: userId, 132 | }, 133 | }, 134 | course: { 135 | connect: { 136 | id: payload.courseId, 137 | }, 138 | }, 139 | role: payload.role, 140 | }, 141 | }) 142 | return h.response(userCourses).code(201) 143 | } catch (err) { 144 | request.log('error', err) 145 | return Boom.badImplementation('failed to update the user courses') 146 | } 147 | } 148 | 149 | async function deleteUserEnrollmentHandler( 150 | request: Hapi.Request, 151 | h: Hapi.ResponseToolkit, 152 | ) { 153 | const { prisma } = request.server.app 154 | const userId = parseInt(request.params.userId, 10) 155 | const courseId = parseInt(request.params.courseId, 10) 156 | 157 | try { 158 | await prisma.courseEnrollment.delete({ 159 | where: { 160 | userId_courseId: { 161 | userId, 162 | courseId, 163 | }, 164 | }, 165 | }) 166 | return h.response().code(204) 167 | } catch (err) { 168 | request.log('error', err) 169 | return Boom.badImplementation( 170 | `failed to delete the user: ${userId} enrollment in course: ${courseId} `, 171 | ) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /tests/tests.test.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from '../src/server' 2 | import Hapi, { AuthCredentials } from '@hapi/hapi' 3 | import { add } from 'date-fns' 4 | import { createUserCredentials } from './test-helpers' 5 | import { API_AUTH_STATEGY } from '../src/plugins/auth' 6 | 7 | describe('tests endpoints', () => { 8 | let server: Hapi.Server 9 | let testUserCredentials: AuthCredentials 10 | let testAdminCredentials: AuthCredentials 11 | const weekFromNow = add(new Date(), { days: 7 }) 12 | let testId: number 13 | let courseId: number 14 | 15 | beforeAll(async () => { 16 | server = await createServer() 17 | // Create a test user and admin and get the credentials object for them 18 | testUserCredentials = await createUserCredentials(server.app.prisma, false) 19 | testAdminCredentials = await createUserCredentials(server.app.prisma, true) 20 | }) 21 | 22 | afterAll(async () => { 23 | await server.stop() 24 | }) 25 | 26 | test('create test', async () => { 27 | const courseResponse = await server.inject({ 28 | method: 'POST', 29 | url: '/courses', 30 | auth: { 31 | strategy: API_AUTH_STATEGY, 32 | credentials: testUserCredentials, 33 | }, 34 | payload: { 35 | name: 'Modern Backend Course', 36 | courseDetails: 'Learn how to build a modern backend', 37 | }, 38 | }) 39 | expect(courseResponse.statusCode).toEqual(201) 40 | courseId = JSON.parse(courseResponse.payload)?.id 41 | // 👇Update the credentials as they're static in tests (not fetched automatically on request by the auth plugin) 42 | testUserCredentials.teacherOf.push(courseId) 43 | 44 | const testResponse = await server.inject({ 45 | method: 'POST', 46 | url: `/courses/${courseId}/tests`, 47 | auth: { 48 | strategy: API_AUTH_STATEGY, 49 | credentials: testUserCredentials, 50 | }, 51 | payload: { 52 | name: 'First Test', 53 | date: weekFromNow.toString(), 54 | }, 55 | }) 56 | 57 | expect(testResponse.statusCode).toEqual(201) 58 | 59 | testId = JSON.parse(testResponse.payload)?.id 60 | expect(typeof testId === 'number').toBeTruthy() 61 | }) 62 | 63 | test('create test validation', async () => { 64 | const response = await server.inject({ 65 | method: 'POST', 66 | url: '/courses', 67 | auth: { 68 | strategy: API_AUTH_STATEGY, 69 | credentials: testUserCredentials, 70 | }, 71 | payload: { 72 | name: 'name', 73 | invalidField: 'woot', 74 | }, 75 | }) 76 | 77 | expect(response.statusCode).toEqual(400) 78 | }) 79 | 80 | test('get course returns 404 for non existant course', async () => { 81 | const response = await server.inject({ 82 | method: 'GET', 83 | url: '/courses/tests/9999', 84 | auth: { 85 | strategy: API_AUTH_STATEGY, 86 | credentials: testUserCredentials, 87 | }, 88 | }) 89 | 90 | expect(response.statusCode).toEqual(404) 91 | }) 92 | 93 | test('get test returns test', async () => { 94 | const response = await server.inject({ 95 | method: 'GET', 96 | url: `/courses/tests/${testId}`, 97 | auth: { 98 | strategy: API_AUTH_STATEGY, 99 | credentials: testUserCredentials, 100 | }, 101 | }) 102 | expect(response.statusCode).toEqual(200) 103 | const course = JSON.parse(response.payload) 104 | 105 | expect(course.id).toBe(testId) 106 | }) 107 | 108 | test('get course fails with invalid id', async () => { 109 | const response = await server.inject({ 110 | method: 'GET', 111 | url: '/courses/tests/a123', 112 | auth: { 113 | strategy: API_AUTH_STATEGY, 114 | credentials: testUserCredentials, 115 | }, 116 | }) 117 | expect(response.statusCode).toEqual(400) 118 | }) 119 | 120 | test('update course fails with invalid testId parameter', async () => { 121 | const response = await server.inject({ 122 | method: 'PUT', 123 | url: `/courses/tests/aa22`, 124 | auth: { 125 | strategy: API_AUTH_STATEGY, 126 | credentials: testUserCredentials, 127 | }, 128 | }) 129 | expect(response.statusCode).toEqual(400) 130 | }) 131 | 132 | test('update course', async () => { 133 | const updatedName = 'test-UPDATED-NAME' 134 | 135 | const response = await server.inject({ 136 | method: 'PUT', 137 | url: `/courses/tests/${testId}`, 138 | auth: { 139 | strategy: API_AUTH_STATEGY, 140 | credentials: testUserCredentials, 141 | }, 142 | payload: { 143 | name: updatedName, 144 | }, 145 | }) 146 | expect(response.statusCode).toEqual(200) 147 | const course = JSON.parse(response.payload) 148 | expect(course.name).toEqual(updatedName) 149 | }) 150 | 151 | test('delete course fails with invalid testId parameter', async () => { 152 | const response = await server.inject({ 153 | method: 'DELETE', 154 | url: `/courses/tests/aa22`, 155 | auth: { 156 | strategy: API_AUTH_STATEGY, 157 | credentials: testUserCredentials, 158 | }, 159 | }) 160 | expect(response.statusCode).toEqual(400) 161 | }) 162 | 163 | test('delete course', async () => { 164 | const response = await server.inject({ 165 | method: 'DELETE', 166 | url: `/courses/tests/${testId}`, 167 | auth: { 168 | strategy: API_AUTH_STATEGY, 169 | credentials: testUserCredentials, 170 | }, 171 | }) 172 | expect(response.statusCode).toEqual(204) 173 | }) 174 | }) 175 | -------------------------------------------------------------------------------- /src/plugins/tests.ts: -------------------------------------------------------------------------------- 1 | import Hapi from '@hapi/hapi' 2 | import Joi from 'joi' 3 | import Boom from '@hapi/boom' 4 | import { API_AUTH_STATEGY } from './auth' 5 | import { 6 | isTeacherOfCourseOrAdmin, 7 | isTeacherOfTestOrAdmin, 8 | } from '../auth-helpers' 9 | 10 | const testsPlugin = { 11 | name: 'app/tests', 12 | dependencies: ['prisma'], 13 | register: async function (server: Hapi.Server) { 14 | server.route([ 15 | { 16 | method: 'GET', 17 | path: '/courses/tests/{testId}', 18 | handler: getTestHandler, 19 | options: { 20 | auth: { 21 | mode: 'required', 22 | strategy: API_AUTH_STATEGY, 23 | }, 24 | validate: { 25 | params: Joi.object({ 26 | testId: Joi.number().integer(), 27 | }), 28 | failAction: (request, h, err) => { 29 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 30 | throw err 31 | }, 32 | }, 33 | }, 34 | }, 35 | { 36 | method: 'POST', 37 | path: '/courses/{courseId}/tests', 38 | handler: createTestHandler, 39 | options: { 40 | pre: [isTeacherOfCourseOrAdmin], 41 | auth: { 42 | mode: 'required', 43 | strategy: API_AUTH_STATEGY, 44 | }, 45 | validate: { 46 | params: Joi.object({ 47 | courseId: Joi.number().integer(), 48 | }), 49 | payload: createTestValidator, 50 | failAction: (request, h, err) => { 51 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 52 | throw err 53 | }, 54 | }, 55 | }, 56 | }, 57 | { 58 | method: 'PUT', 59 | path: '/courses/tests/{testId}', 60 | handler: updateTestHandler, 61 | options: { 62 | pre: [isTeacherOfTestOrAdmin], 63 | auth: { 64 | mode: 'required', 65 | strategy: API_AUTH_STATEGY, 66 | }, 67 | validate: { 68 | params: Joi.object({ 69 | testId: Joi.number().integer(), 70 | }), 71 | payload: updateTestValidator, 72 | failAction: (request, h, err) => { 73 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 74 | throw err 75 | }, 76 | }, 77 | }, 78 | }, 79 | { 80 | method: 'DELETE', 81 | path: '/courses/tests/{testId}', 82 | handler: deleteTestHandler, 83 | options: { 84 | pre: [isTeacherOfTestOrAdmin], 85 | auth: { 86 | mode: 'required', 87 | strategy: API_AUTH_STATEGY, 88 | }, 89 | validate: { 90 | params: Joi.object({ 91 | testId: Joi.number().integer(), 92 | }), 93 | failAction: (request, h, err) => { 94 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 95 | throw err 96 | }, 97 | }, 98 | }, 99 | }, 100 | ]) 101 | }, 102 | } 103 | 104 | export default testsPlugin 105 | 106 | const testInputValidator = Joi.object({ 107 | name: Joi.string().alter({ 108 | create: (schema) => schema.required(), 109 | update: (schema) => schema.optional(), 110 | }), 111 | date: Joi.date().alter({ 112 | create: (schema) => schema.required(), 113 | update: (schema) => schema.optional(), 114 | }), 115 | }) 116 | 117 | const createTestValidator = testInputValidator.tailor('create') 118 | const updateTestValidator = testInputValidator.tailor('update') 119 | 120 | interface TestInput { 121 | name: string 122 | date: Date 123 | } 124 | 125 | async function getTestHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) { 126 | const { prisma } = request.server.app 127 | const testId = parseInt(request.params.testId, 10) 128 | 129 | try { 130 | const test = await prisma.test.findUnique({ 131 | where: { 132 | id: testId, 133 | }, 134 | }) 135 | if (!test) { 136 | return h.response().code(404) 137 | } else { 138 | return h.response(test).code(200) 139 | } 140 | } catch (err) { 141 | request.log('error', err) 142 | return Boom.badImplementation('failed to get test') 143 | } 144 | } 145 | 146 | async function createTestHandler( 147 | request: Hapi.Request, 148 | h: Hapi.ResponseToolkit, 149 | ) { 150 | const { prisma } = request.server.app 151 | const payload = request.payload as TestInput 152 | const courseId = parseInt(request.params.courseId, 10) 153 | 154 | try { 155 | const createdTest = await prisma.test.create({ 156 | data: { 157 | name: payload.name, 158 | date: payload.date, 159 | course: { 160 | connect: { 161 | id: courseId, 162 | }, 163 | }, 164 | }, 165 | }) 166 | return h.response(createdTest).code(201) 167 | } catch (err) { 168 | request.log('error', err) 169 | return Boom.badImplementation('failed to create test') 170 | } 171 | } 172 | 173 | async function deleteTestHandler( 174 | request: Hapi.Request, 175 | h: Hapi.ResponseToolkit, 176 | ) { 177 | const { prisma } = request.server.app 178 | const testId = parseInt(request.params.testId, 10) 179 | 180 | try { 181 | await prisma.test.delete({ 182 | where: { 183 | id: testId, 184 | }, 185 | }) 186 | return h.response().code(204) 187 | } catch (err) { 188 | request.log('error', err) 189 | return Boom.badImplementation('failed to delete test') 190 | } 191 | } 192 | 193 | async function updateTestHandler( 194 | request: Hapi.Request, 195 | h: Hapi.ResponseToolkit, 196 | ) { 197 | const { prisma } = request.server.app 198 | const testId = parseInt(request.params.testId, 10) 199 | const payload = request.payload as Partial 200 | 201 | try { 202 | const updatedTest = await prisma.test.update({ 203 | where: { 204 | id: testId, 205 | }, 206 | data: payload, 207 | }) 208 | return h.response(updatedTest).code(200) 209 | } catch (err) { 210 | request.log('error', err) 211 | return Boom.badImplementation('failed to update test') 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /tests/users.test.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from '../src/server' 2 | import Hapi, { AuthCredentials } from '@hapi/hapi' 3 | import { createUserCredentials } from './test-helpers' 4 | import { API_AUTH_STATEGY } from '../src/plugins/auth' 5 | 6 | describe('users endpoints', () => { 7 | let server: Hapi.Server 8 | let testUserCredentials: AuthCredentials 9 | let testAdminCredentials: AuthCredentials 10 | 11 | beforeAll(async () => { 12 | server = await createServer() 13 | // Create a test user and admin and get the credentials object for them 14 | testUserCredentials = await createUserCredentials(server.app.prisma, false) 15 | testAdminCredentials = await createUserCredentials(server.app.prisma, true) 16 | }) 17 | 18 | afterAll(async () => { 19 | await server.stop() 20 | }) 21 | 22 | let userId: number 23 | 24 | test('profile', async () => { 25 | const response = await server.inject({ 26 | method: 'GET', 27 | url: '/profile', 28 | auth: { 29 | strategy: API_AUTH_STATEGY, 30 | credentials: testUserCredentials, 31 | }, 32 | }) 33 | 34 | expect(response.statusCode).toEqual(200) 35 | 36 | let fetchedUserId = JSON.parse(response.payload)?.id as number 37 | expect(fetchedUserId).toEqual(testUserCredentials.userId) 38 | }) 39 | 40 | test('create user', async () => { 41 | const response = await server.inject({ 42 | method: 'POST', 43 | url: '/users', 44 | auth: { 45 | strategy: API_AUTH_STATEGY, 46 | credentials: testAdminCredentials, 47 | }, 48 | payload: { 49 | firstName: 'test-first-name', 50 | lastName: 'test-last-name', 51 | email: `test-${Date.now()}@prisma.io`, 52 | social: { 53 | twitter: 'thisisalice', 54 | website: 'https://www.thisisalice.com', 55 | }, 56 | }, 57 | }) 58 | 59 | expect(response.statusCode).toEqual(201) 60 | 61 | userId = JSON.parse(response.payload)?.id 62 | expect(typeof userId === 'number').toBeTruthy() 63 | }) 64 | 65 | test('create user validation', async () => { 66 | const response = await server.inject({ 67 | method: 'POST', 68 | url: '/users', 69 | auth: { 70 | strategy: API_AUTH_STATEGY, 71 | credentials: testAdminCredentials, 72 | }, 73 | payload: { 74 | lastName: 'test-last-name', 75 | email: `test-${Date.now()}@prisma.io`, 76 | social: { 77 | twitter: 'thisisalice', 78 | website: 'https://www.thisisalice.com', 79 | }, 80 | }, 81 | }) 82 | 83 | expect(response.statusCode).toEqual(400) 84 | }) 85 | 86 | test('get user returns 404 for non existant user', async () => { 87 | const response = await server.inject({ 88 | method: 'GET', 89 | url: '/users/9999', 90 | auth: { 91 | strategy: API_AUTH_STATEGY, 92 | credentials: testAdminCredentials, 93 | }, 94 | }) 95 | 96 | expect(response.statusCode).toEqual(404) 97 | }) 98 | 99 | test('get users returns array of users', async () => { 100 | const response = await server.inject({ 101 | method: 'GET', 102 | url: `/users`, 103 | auth: { 104 | strategy: API_AUTH_STATEGY, 105 | credentials: testAdminCredentials, 106 | }, 107 | }) 108 | expect(response.statusCode).toEqual(200) 109 | const users = JSON.parse(response.payload) 110 | 111 | expect(Array.isArray(users)).toBeTruthy() 112 | expect(users[0]?.id).toBeTruthy() 113 | }) 114 | 115 | test('get user returns user', async () => { 116 | const response = await server.inject({ 117 | method: 'GET', 118 | url: `/users/${testUserCredentials.userId}`, 119 | auth: { 120 | strategy: API_AUTH_STATEGY, 121 | credentials: testUserCredentials, 122 | }, 123 | }) 124 | expect(response.statusCode).toEqual(200) 125 | const user = JSON.parse(response.payload) 126 | 127 | expect(user.id).toBe(testUserCredentials.userId) 128 | }) 129 | 130 | test('get user fails with invalid id', async () => { 131 | const response = await server.inject({ 132 | method: 'GET', 133 | url: '/users/a123', 134 | auth: { 135 | strategy: API_AUTH_STATEGY, 136 | credentials: testUserCredentials, 137 | }, 138 | }) 139 | expect(response.statusCode).toEqual(400) 140 | }) 141 | 142 | test('update user fails with invalid userId parameter', async () => { 143 | const response = await server.inject({ 144 | method: 'PUT', 145 | url: `/users/aa22`, 146 | auth: { 147 | strategy: API_AUTH_STATEGY, 148 | credentials: testUserCredentials, 149 | }, 150 | }) 151 | expect(response.statusCode).toEqual(400) 152 | }) 153 | 154 | test('update user - authenticated user updates his profile', async () => { 155 | const updatedFirstName = 'test-first-name-UPDATED' 156 | const updatedLastName = 'test-last-name-UPDATED' 157 | 158 | const response = await server.inject({ 159 | method: 'PUT', 160 | url: `/users/${testUserCredentials.userId}`, 161 | auth: { 162 | strategy: API_AUTH_STATEGY, 163 | credentials: testUserCredentials, 164 | }, 165 | payload: { 166 | firstName: updatedFirstName, 167 | lastName: updatedLastName, 168 | }, 169 | }) 170 | expect(response.statusCode).toEqual(200) 171 | const user = JSON.parse(response.payload) 172 | expect(user.firstName).toEqual(updatedFirstName) 173 | expect(user.lastName).toEqual(updatedLastName) 174 | }) 175 | 176 | test('delete user fails with invalid userId parameter', async () => { 177 | const response = await server.inject({ 178 | method: 'DELETE', 179 | url: `/users/aa22`, 180 | auth: { 181 | strategy: API_AUTH_STATEGY, 182 | credentials: testUserCredentials, 183 | }, 184 | }) 185 | expect(response.statusCode).toEqual(400) 186 | }) 187 | 188 | test('delete authenticated user', async () => { 189 | const response = await server.inject({ 190 | method: 'DELETE', 191 | url: `/users/${testUserCredentials.userId}`, 192 | auth: { 193 | strategy: API_AUTH_STATEGY, 194 | credentials: testUserCredentials, 195 | }, 196 | }) 197 | expect(response.statusCode).toEqual(204) 198 | }) 199 | 200 | test('delete user as an admin', async () => { 201 | const response = await server.inject({ 202 | method: 'DELETE', 203 | url: `/users/${userId}`, 204 | auth: { 205 | strategy: API_AUTH_STATEGY, 206 | credentials: testAdminCredentials, 207 | }, 208 | }) 209 | expect(response.statusCode).toEqual(204) 210 | }) 211 | }) 212 | -------------------------------------------------------------------------------- /tests/courses.test.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from '../src/server' 2 | import Hapi, { AuthCredentials } from '@hapi/hapi' 3 | import { createUserCredentials } from './test-helpers' 4 | import { API_AUTH_STATEGY } from '../src/plugins/auth' 5 | 6 | describe('courses endpoints', () => { 7 | let server: Hapi.Server 8 | let testUserCredentials: AuthCredentials 9 | let testAdminCredentials: AuthCredentials 10 | 11 | beforeAll(async () => { 12 | server = await createServer() 13 | 14 | // Create a test user and admin and get the credentials object for them 15 | testUserCredentials = await createUserCredentials(server.app.prisma, false) 16 | testAdminCredentials = await createUserCredentials(server.app.prisma, true) 17 | }) 18 | 19 | afterAll(async () => { 20 | await server.stop() 21 | }) 22 | 23 | let courseId: number 24 | 25 | test('create course', async () => { 26 | const response = await server.inject({ 27 | method: 'POST', 28 | url: '/courses', 29 | auth: { 30 | strategy: API_AUTH_STATEGY, 31 | credentials: testUserCredentials, 32 | }, 33 | payload: { 34 | name: 'Modern Backend with TypeScript, PostgreSQL, and Prisma', 35 | courseDetails: 36 | 'Explore and demonstrate different patterns, problems, and architectures for a modern backend by solving a concrete problem: **a grading system for online courses.**', 37 | }, 38 | }) 39 | 40 | expect(response.statusCode).toEqual(201) 41 | 42 | courseId = JSON.parse(response.payload)?.id 43 | // 👇Update the credentials as they're static in tests (not fetched automatically on request by the auth plugin) 44 | testUserCredentials.teacherOf.push(courseId) 45 | expect(typeof courseId === 'number').toBeTruthy() 46 | }) 47 | 48 | test('create course auth', async () => { 49 | const response = await server.inject({ 50 | method: 'POST', 51 | url: '/courses', 52 | payload: { 53 | name: 'Modern Backend with TypeScript, PostgreSQL, and Prisma', 54 | courseDetails: 55 | 'Explore and demonstrate different patterns, problems, and architectures for a modern backend by solving a concrete problem: **a grading system for online courses.**', 56 | }, 57 | }) 58 | expect(response.statusCode).toEqual(401) 59 | }) 60 | 61 | test('create course validation', async () => { 62 | const response = await server.inject({ 63 | method: 'POST', 64 | url: '/courses', 65 | auth: { 66 | strategy: API_AUTH_STATEGY, 67 | credentials: testUserCredentials, 68 | }, 69 | payload: { 70 | name: 'name', 71 | }, 72 | }) 73 | 74 | expect(response.statusCode).toEqual(400) 75 | }) 76 | 77 | test('get course returns 404 for non existant course', async () => { 78 | const response = await server.inject({ 79 | method: 'GET', 80 | url: '/courses/9999', 81 | auth: { 82 | strategy: API_AUTH_STATEGY, 83 | credentials: testUserCredentials, 84 | }, 85 | }) 86 | 87 | expect(response.statusCode).toEqual(404) 88 | }) 89 | 90 | test('get course returns course', async () => { 91 | const response = await server.inject({ 92 | method: 'GET', 93 | url: `/courses/${courseId}`, 94 | auth: { 95 | strategy: API_AUTH_STATEGY, 96 | credentials: testUserCredentials, 97 | }, 98 | }) 99 | expect(response.statusCode).toEqual(200) 100 | const course = JSON.parse(response.payload) 101 | 102 | expect(course.id).toBe(courseId) 103 | }) 104 | 105 | test('get courses returns courses with their tests', async () => { 106 | const response = await server.inject({ 107 | method: 'GET', 108 | url: `/courses`, 109 | auth: { 110 | strategy: API_AUTH_STATEGY, 111 | credentials: testUserCredentials, 112 | }, 113 | }) 114 | expect(response.statusCode).toEqual(200) 115 | const course = JSON.parse(response.payload) 116 | 117 | expect(Array.isArray(course)).toBeTruthy() 118 | expect(course[0]?.id).toBeTruthy() 119 | expect(course[0]?.tests).toBeTruthy() 120 | }) 121 | 122 | test('get course fails with invalid id', async () => { 123 | const response = await server.inject({ 124 | method: 'GET', 125 | url: '/courses/a123', 126 | auth: { 127 | strategy: API_AUTH_STATEGY, 128 | credentials: testUserCredentials, 129 | }, 130 | }) 131 | expect(response.statusCode).toEqual(400) 132 | }) 133 | 134 | test('update course fails with invalid courseId parameter', async () => { 135 | const response = await server.inject({ 136 | method: 'PUT', 137 | url: `/courses/aa22`, 138 | auth: { 139 | strategy: API_AUTH_STATEGY, 140 | credentials: testUserCredentials, 141 | }, 142 | }) 143 | expect(response.statusCode).toEqual(400) 144 | }) 145 | 146 | test('update course', async () => { 147 | const updatedName = 'test-UPDATED' 148 | 149 | const response = await server.inject({ 150 | method: 'PUT', 151 | url: `/courses/${courseId}`, 152 | auth: { 153 | strategy: API_AUTH_STATEGY, 154 | credentials: testUserCredentials, 155 | }, 156 | payload: { 157 | name: updatedName, 158 | }, 159 | }) 160 | expect(response.statusCode).toEqual(200) 161 | const course = JSON.parse(response.payload) 162 | expect(course.name).toEqual(updatedName) 163 | }) 164 | 165 | test('update course as an admin', async () => { 166 | const updatedName = 'test-UPDATED-BY-ADMIN' 167 | 168 | const response = await server.inject({ 169 | method: 'PUT', 170 | url: `/courses/${courseId}`, 171 | auth: { 172 | strategy: API_AUTH_STATEGY, 173 | credentials: testAdminCredentials, 174 | }, 175 | payload: { 176 | name: updatedName, 177 | }, 178 | }) 179 | expect(response.statusCode).toEqual(200) 180 | const course = JSON.parse(response.payload) 181 | expect(course.name).toEqual(updatedName) 182 | }) 183 | 184 | test('delete course fails with invalid courseId parameter', async () => { 185 | const response = await server.inject({ 186 | method: 'DELETE', 187 | url: `/courses/aa22`, 188 | auth: { 189 | strategy: API_AUTH_STATEGY, 190 | credentials: testUserCredentials, 191 | }, 192 | }) 193 | expect(response.statusCode).toEqual(400) 194 | }) 195 | 196 | test('delete course', async () => { 197 | const response = await server.inject({ 198 | method: 'DELETE', 199 | url: `/courses/${courseId}`, 200 | auth: { 201 | strategy: API_AUTH_STATEGY, 202 | credentials: testUserCredentials, 203 | }, 204 | }) 205 | expect(response.statusCode).toEqual(204) 206 | }) 207 | }) 208 | -------------------------------------------------------------------------------- /src/plugins/courses.ts: -------------------------------------------------------------------------------- 1 | import Hapi from '@hapi/hapi' 2 | import Joi, { required } from 'joi' 3 | import Boom, { boomify } from '@hapi/boom' 4 | import { API_AUTH_STATEGY } from './auth' 5 | import { UserRole } from '@prisma/client' 6 | import { isTeacherOfCourseOrAdmin } from '../auth-helpers' 7 | 8 | const coursesPlugin = { 9 | name: 'app/courses', 10 | dependencies: ['prisma'], 11 | register: async function (server: Hapi.Server) { 12 | server.route([ 13 | { 14 | method: 'GET', 15 | path: '/courses/{courseId}', 16 | handler: getCourseHandler, 17 | options: { 18 | auth: { 19 | mode: 'required', 20 | strategy: API_AUTH_STATEGY, 21 | }, 22 | validate: { 23 | params: Joi.object({ 24 | courseId: Joi.number().integer(), 25 | }), 26 | failAction: (request, h, err) => { 27 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 28 | throw err 29 | }, 30 | }, 31 | }, 32 | }, 33 | { 34 | method: 'GET', 35 | path: '/courses', 36 | handler: getCoursesHandler, 37 | options: { 38 | auth: { 39 | mode: 'required', 40 | strategy: API_AUTH_STATEGY, 41 | }, 42 | }, 43 | }, 44 | { 45 | method: 'POST', 46 | path: '/courses', 47 | handler: createCourseHandler, 48 | options: { 49 | auth: { 50 | mode: 'required', 51 | strategy: API_AUTH_STATEGY, 52 | }, 53 | validate: { 54 | payload: createCourseValidator, 55 | failAction: (request, h, err) => { 56 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 57 | throw err 58 | }, 59 | }, 60 | }, 61 | }, 62 | { 63 | method: 'PUT', 64 | path: '/courses/{courseId}', 65 | handler: updateCourseHandler, 66 | options: { 67 | pre: [isTeacherOfCourseOrAdmin], 68 | auth: { 69 | mode: 'required', 70 | strategy: API_AUTH_STATEGY, 71 | }, 72 | validate: { 73 | params: Joi.object({ 74 | courseId: Joi.number().integer(), 75 | }), 76 | payload: updateCourseValidator, 77 | failAction: (request, h, err) => { 78 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 79 | throw err 80 | }, 81 | }, 82 | }, 83 | }, 84 | { 85 | method: 'DELETE', 86 | path: '/courses/{courseId}', 87 | handler: deleteCourseHandler, 88 | options: { 89 | pre: [isTeacherOfCourseOrAdmin], 90 | auth: { 91 | mode: 'required', 92 | strategy: API_AUTH_STATEGY, 93 | }, 94 | validate: { 95 | params: Joi.object({ 96 | courseId: Joi.number().integer(), 97 | }), 98 | failAction: (request, h, err) => { 99 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 100 | throw err 101 | }, 102 | }, 103 | }, 104 | }, 105 | ]) 106 | }, 107 | } 108 | 109 | export default coursesPlugin 110 | 111 | const courseInputValidator = Joi.object({ 112 | name: Joi.string().alter({ 113 | create: (schema) => schema.required(), 114 | update: (schema) => schema.optional(), 115 | }), 116 | courseDetails: Joi.string().alter({ 117 | create: (schema) => schema.required(), 118 | update: (schema) => schema.optional(), 119 | }), 120 | }) 121 | 122 | const createCourseValidator = courseInputValidator.tailor('create') 123 | const updateCourseValidator = courseInputValidator.tailor('update') 124 | 125 | interface CourseInput { 126 | name: string 127 | courseDetails: string 128 | } 129 | 130 | async function getCourseHandler( 131 | request: Hapi.Request, 132 | h: Hapi.ResponseToolkit, 133 | ) { 134 | const { prisma } = request.server.app 135 | const courseId = parseInt(request.params.courseId, 10) 136 | 137 | try { 138 | const course = await prisma.course.findUnique({ 139 | where: { 140 | id: courseId, 141 | }, 142 | include: { 143 | tests: true, 144 | }, 145 | }) 146 | if (!course) { 147 | return h.response().code(404) 148 | } else { 149 | return h.response(course).code(200) 150 | } 151 | } catch (err) { 152 | request.log('error', err) 153 | return Boom.badImplementation('failed to get course') 154 | } 155 | } 156 | 157 | async function getCoursesHandler( 158 | request: Hapi.Request, 159 | h: Hapi.ResponseToolkit, 160 | ) { 161 | const { prisma } = request.server.app 162 | 163 | try { 164 | const courses = await prisma.course.findMany({ 165 | include: { 166 | tests: true, 167 | }, 168 | }) 169 | return h.response(courses).code(200) 170 | } catch (err) { 171 | request.log('error', err) 172 | return Boom.badImplementation('failed to get course') 173 | } 174 | } 175 | 176 | async function createCourseHandler( 177 | request: Hapi.Request, 178 | h: Hapi.ResponseToolkit, 179 | ) { 180 | const { prisma } = request.server.app 181 | const payload = request.payload as CourseInput 182 | const { userId } = request.auth.credentials 183 | 184 | try { 185 | // when creating a course make the authenticated user a teacher of the course 186 | const createdCourse = await prisma.course.create({ 187 | data: { 188 | name: payload.name, 189 | courseDetails: payload.courseDetails, 190 | members: { 191 | create: { 192 | role: 'TEACHER', 193 | user: { 194 | connect: { 195 | id: userId, 196 | }, 197 | }, 198 | }, 199 | }, 200 | }, 201 | }) 202 | return h.response(createdCourse).code(201) 203 | } catch (err) { 204 | request.log('error', err) 205 | return Boom.badImplementation('failed to create course') 206 | } 207 | } 208 | 209 | async function updateCourseHandler( 210 | request: Hapi.Request, 211 | h: Hapi.ResponseToolkit, 212 | ) { 213 | const { prisma } = request.server.app 214 | const courseId = parseInt(request.params.courseId, 10) 215 | const payload = request.payload as Partial 216 | 217 | try { 218 | const updatedCourse = await prisma.course.update({ 219 | where: { 220 | id: courseId, 221 | }, 222 | data: payload, 223 | }) 224 | return h.response(updatedCourse).code(200) 225 | } catch (err) { 226 | request.log('error', err) 227 | return Boom.badImplementation('failed to update course') 228 | } 229 | } 230 | 231 | async function deleteCourseHandler( 232 | request: Hapi.Request, 233 | h: Hapi.ResponseToolkit, 234 | ) { 235 | const { prisma } = request.server.app 236 | const courseId = parseInt(request.params.courseId, 10) 237 | 238 | try { 239 | // Delete all enrollments 240 | await prisma.$transaction([ 241 | prisma.courseEnrollment.deleteMany({ 242 | where: { 243 | courseId: courseId, 244 | }, 245 | }), 246 | prisma.course.delete({ 247 | where: { 248 | id: courseId, 249 | }, 250 | }), 251 | ]) 252 | return h.response().code(204) 253 | } catch (err) { 254 | request.log('error', err) 255 | return Boom.badImplementation('failed to delete course') 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/plugins/test-results.ts: -------------------------------------------------------------------------------- 1 | import Hapi from '@hapi/hapi' 2 | import Joi from 'joi' 3 | import Boom from '@hapi/boom' 4 | import { API_AUTH_STATEGY } from './auth' 5 | import { 6 | isRequestedUserOrAdmin, 7 | isTeacherOfTestOrAdmin, 8 | isGraderOfTestResultOrAdmin, 9 | } from '../auth-helpers' 10 | 11 | const testResultsPlugin = { 12 | name: 'app/testResults', 13 | dependencies: ['prisma'], 14 | register: async function (server: Hapi.Server) { 15 | server.route([ 16 | { 17 | method: 'GET', 18 | path: '/users/{userId}/test-results', 19 | handler: getUserTestResultsHandler, 20 | options: { 21 | pre: [isRequestedUserOrAdmin], 22 | auth: { 23 | mode: 'required', 24 | strategy: API_AUTH_STATEGY, 25 | }, 26 | validate: { 27 | params: Joi.object({ 28 | userId: Joi.number().integer(), 29 | }), 30 | failAction: (request, h, err) => { 31 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 32 | throw err 33 | }, 34 | }, 35 | }, 36 | }, 37 | { 38 | method: 'GET', 39 | path: '/courses/tests/{testId}/test-results', 40 | handler: getTestResultsHandler, 41 | options: { 42 | pre: [isTeacherOfTestOrAdmin], 43 | auth: { 44 | mode: 'required', 45 | strategy: API_AUTH_STATEGY, 46 | }, 47 | validate: { 48 | params: Joi.object({ 49 | testId: Joi.number().integer().integer(), 50 | }), 51 | failAction: (request, h, err) => { 52 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 53 | throw err 54 | }, 55 | }, 56 | }, 57 | }, 58 | { 59 | method: 'POST', 60 | path: '/courses/tests/{testId}/test-results', 61 | handler: createTestResultsHandler, 62 | options: { 63 | pre: [isTeacherOfTestOrAdmin], 64 | auth: { 65 | mode: 'required', 66 | strategy: API_AUTH_STATEGY, 67 | }, 68 | validate: { 69 | params: Joi.object({ 70 | testId: Joi.number().integer(), 71 | }), 72 | payload: createTestResultValidator, 73 | failAction: (request, h, err) => { 74 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 75 | throw err 76 | }, 77 | }, 78 | }, 79 | }, 80 | { 81 | method: 'PUT', 82 | path: '/courses/tests/test-results/{testResultId}', 83 | handler: updateTestResultHandler, 84 | options: { 85 | pre: [isGraderOfTestResultOrAdmin], 86 | auth: { 87 | mode: 'required', 88 | strategy: API_AUTH_STATEGY, 89 | }, 90 | validate: { 91 | params: Joi.object({ 92 | testResultId: Joi.number().integer(), 93 | }), 94 | payload: updateTestResultValidator, 95 | failAction: (request, h, err) => { 96 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 97 | throw err 98 | }, 99 | }, 100 | }, 101 | }, 102 | { 103 | method: 'DELETE', 104 | path: '/courses/tests/test-results/{testResultId}', 105 | handler: deleteTestResultHandler, 106 | options: { 107 | pre: [isGraderOfTestResultOrAdmin], 108 | auth: { 109 | mode: 'required', 110 | strategy: API_AUTH_STATEGY, 111 | }, 112 | validate: { 113 | params: Joi.object({ 114 | testResultId: Joi.number().integer(), 115 | }), 116 | failAction: (request, h, err) => { 117 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 118 | throw err 119 | }, 120 | }, 121 | }, 122 | }, 123 | ]) 124 | }, 125 | } 126 | 127 | export default testResultsPlugin 128 | 129 | // Once a test result is created, only the result can be updated. 130 | const testResultInputValidator = Joi.object({ 131 | result: Joi.number().integer().sign('positive').max(1000).required(), 132 | studentId: Joi.number() 133 | .integer() 134 | .alter({ 135 | create: (schema) => schema.required(), 136 | update: (schema) => schema.forbidden(), 137 | }), 138 | graderId: Joi.number() 139 | .integer() 140 | .alter({ 141 | create: (schema) => schema.required(), 142 | update: (schema) => schema.forbidden(), 143 | }), 144 | }) 145 | 146 | const createTestResultValidator = testResultInputValidator.tailor('create') 147 | const updateTestResultValidator = testResultInputValidator.tailor('update') 148 | 149 | interface TestResultInput { 150 | result: number 151 | studentId: number 152 | graderId: number 153 | } 154 | 155 | async function getTestResultsHandler( 156 | request: Hapi.Request, 157 | h: Hapi.ResponseToolkit, 158 | ) { 159 | const { prisma } = request.server.app 160 | const testId = parseInt(request.params.testId, 10) 161 | 162 | try { 163 | const testResults = await prisma.testResult.findMany({ 164 | where: { 165 | testId: testId, 166 | }, 167 | }) 168 | 169 | return h.response(testResults).code(200) 170 | } catch (err) { 171 | request.log('error', err) 172 | return Boom.badImplementation( 173 | `failed to get test results for test ${testId}`, 174 | ) 175 | } 176 | } 177 | 178 | async function getUserTestResultsHandler( 179 | request: Hapi.Request, 180 | h: Hapi.ResponseToolkit, 181 | ) { 182 | const { prisma } = request.server.app 183 | const userId = parseInt(request.params.userId, 10) 184 | 185 | try { 186 | const userTestResults = await prisma.testResult.findMany({ 187 | where: { 188 | studentId: userId, 189 | }, 190 | }) 191 | return h.response(userTestResults).code(200) 192 | } catch (err) { 193 | request.log('error', err) 194 | return Boom.badImplementation('failed to get user test results') 195 | } 196 | } 197 | 198 | async function createTestResultsHandler( 199 | request: Hapi.Request, 200 | h: Hapi.ResponseToolkit, 201 | ) { 202 | const { prisma } = request.server.app 203 | const payload = request.payload as TestResultInput 204 | const testId = parseInt(request.params.testId, 10) 205 | 206 | try { 207 | const createdTestResult = await prisma.testResult.create({ 208 | data: { 209 | result: payload.result, 210 | student: { 211 | connect: { id: payload.studentId }, 212 | }, 213 | gradedBy: { 214 | connect: { id: payload.graderId }, 215 | }, 216 | test: { 217 | connect: { 218 | id: testId, 219 | }, 220 | }, 221 | }, 222 | }) 223 | return h.response(createdTestResult).code(201) 224 | } catch (err) { 225 | request.log('error', err) 226 | return Boom.badImplementation( 227 | `failed to create test result for testId: ${testId}`, 228 | ) 229 | } 230 | } 231 | 232 | async function updateTestResultHandler( 233 | request: Hapi.Request, 234 | h: Hapi.ResponseToolkit, 235 | ) { 236 | const { prisma } = request.server.app 237 | const testResultId = parseInt(request.params.testResultId, 10) 238 | const payload = request.payload as Pick 239 | 240 | try { 241 | // Only allow updating the result 242 | const updatedTestResult = await prisma.testResult.update({ 243 | where: { 244 | id: testResultId, 245 | }, 246 | data: { 247 | result: payload.result, 248 | }, 249 | }) 250 | return h.response(updatedTestResult).code(200) 251 | } catch (err) { 252 | request.log('error', err) 253 | return Boom.badImplementation('failed to update test result') 254 | } 255 | } 256 | 257 | async function deleteTestResultHandler( 258 | request: Hapi.Request, 259 | h: Hapi.ResponseToolkit, 260 | ) { 261 | const { prisma } = request.server.app 262 | const testResultId = parseInt(request.params.testResultId, 10) 263 | 264 | try { 265 | await prisma.testResult.delete({ 266 | where: { 267 | id: testResultId, 268 | }, 269 | }) 270 | return h.response().code(204) 271 | } catch (err) { 272 | request.log('error', err) 273 | return Boom.badImplementation('failed to delete test result') 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/plugins/users.ts: -------------------------------------------------------------------------------- 1 | import Hapi from '@hapi/hapi' 2 | import Joi from 'joi' 3 | import Boom from '@hapi/boom' 4 | import { API_AUTH_STATEGY } from './auth' 5 | import { isRequestedUserOrAdmin, isAdmin } from '../auth-helpers' 6 | 7 | const usersPlugin = { 8 | name: 'app/users', 9 | dependencies: ['prisma'], 10 | register: async function (server: Hapi.Server) { 11 | server.route([ 12 | { 13 | method: 'GET', 14 | path: '/profile', 15 | handler: getAuthenticatedUser, 16 | options: { 17 | auth: { 18 | mode: 'required', 19 | strategy: API_AUTH_STATEGY, 20 | }, 21 | }, 22 | }, 23 | { 24 | method: 'GET', 25 | path: '/users', 26 | handler: getUsersHandler, 27 | options: { 28 | pre: [isAdmin], 29 | auth: { 30 | mode: 'required', 31 | strategy: API_AUTH_STATEGY, 32 | }, 33 | validate: { 34 | failAction: (request, h, err) => { 35 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 36 | throw err 37 | }, 38 | }, 39 | }, 40 | }, 41 | { 42 | method: 'GET', 43 | path: '/users/{userId}', 44 | handler: getUserHandler, 45 | options: { 46 | pre: [isRequestedUserOrAdmin], 47 | auth: { 48 | mode: 'required', 49 | strategy: API_AUTH_STATEGY, 50 | }, 51 | validate: { 52 | params: Joi.object({ 53 | userId: Joi.number().integer(), 54 | }), 55 | failAction: (request, h, err) => { 56 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 57 | throw err 58 | }, 59 | }, 60 | }, 61 | }, 62 | { 63 | method: 'POST', 64 | path: '/users', 65 | handler: createUserHandler, 66 | options: { 67 | pre: [isAdmin], 68 | auth: { 69 | mode: 'required', 70 | strategy: API_AUTH_STATEGY, 71 | }, 72 | validate: { 73 | payload: createUserValidator, 74 | failAction: (request, h, err) => { 75 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 76 | throw err 77 | }, 78 | }, 79 | }, 80 | }, 81 | { 82 | method: 'DELETE', 83 | path: '/users/{userId}', 84 | handler: deleteUserHandler, 85 | options: { 86 | pre: [isRequestedUserOrAdmin], 87 | auth: { 88 | mode: 'required', 89 | strategy: API_AUTH_STATEGY, 90 | }, 91 | validate: { 92 | params: Joi.object({ 93 | userId: Joi.number().integer(), 94 | }), 95 | failAction: (request, h, err) => { 96 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 97 | throw err 98 | }, 99 | }, 100 | }, 101 | }, 102 | { 103 | method: 'PUT', 104 | path: '/users/{userId}', 105 | handler: updateUserHandler, 106 | options: { 107 | pre: [isRequestedUserOrAdmin], 108 | auth: { 109 | mode: 'required', 110 | strategy: API_AUTH_STATEGY, 111 | }, 112 | validate: { 113 | params: Joi.object({ 114 | userId: Joi.number().integer(), 115 | }), 116 | payload: updateUserValidator, 117 | failAction: (request, h, err) => { 118 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 119 | throw err 120 | }, 121 | }, 122 | }, 123 | }, 124 | ]) 125 | }, 126 | } 127 | 128 | export default usersPlugin 129 | 130 | const userInputValidator = Joi.object({ 131 | firstName: Joi.string().alter({ 132 | create: (schema) => schema.required(), 133 | update: (schema) => schema.optional(), 134 | }), 135 | lastName: Joi.string().alter({ 136 | create: (schema) => schema.required(), 137 | update: (schema) => schema.optional(), 138 | }), 139 | email: Joi.string() 140 | .email() 141 | .alter({ 142 | create: (schema) => schema.required(), 143 | update: (schema) => schema.optional(), 144 | }), 145 | social: Joi.object({ 146 | facebook: Joi.string().optional(), 147 | twitter: Joi.string().optional(), 148 | github: Joi.string().optional(), 149 | website: Joi.string().optional(), 150 | }).optional(), 151 | }) 152 | 153 | const createUserValidator = userInputValidator.tailor('create') 154 | const updateUserValidator = userInputValidator.tailor('update') 155 | 156 | interface UserInput { 157 | firstName: string 158 | lastName: string 159 | email: string 160 | social: { 161 | facebook?: string 162 | twitter?: string 163 | github?: string 164 | website?: string 165 | } 166 | } 167 | 168 | async function getUsersHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) { 169 | const { prisma } = request.server.app 170 | 171 | try { 172 | const users = await prisma.user.findMany({ 173 | select: { 174 | id: true, 175 | email: true, 176 | firstName: true, 177 | lastName: true, 178 | social: true, 179 | }, 180 | }) 181 | return h.response(users).code(200) 182 | } catch (err) { 183 | request.log('error', err) 184 | return Boom.badImplementation('failed to get users') 185 | } 186 | } 187 | 188 | async function getAuthenticatedUser( 189 | request: Hapi.Request, 190 | h: Hapi.ResponseToolkit, 191 | ) { 192 | const { prisma } = request.server.app 193 | const { userId } = request.auth.credentials 194 | 195 | try { 196 | const user = await prisma.user.findUnique({ 197 | select: { 198 | id: true, 199 | email: true, 200 | firstName: true, 201 | lastName: true, 202 | social: true, 203 | }, 204 | where: { 205 | id: userId, 206 | }, 207 | }) 208 | return h.response(user || undefined).code(200) 209 | } catch (err) { 210 | request.log('error', err) 211 | return Boom.badImplementation() 212 | } 213 | } 214 | 215 | async function getUserHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) { 216 | const { prisma } = request.server.app 217 | const userId = parseInt(request.params.userId, 10) 218 | 219 | try { 220 | const user = await prisma.user.findUnique({ 221 | where: { 222 | id: userId, 223 | }, 224 | select: { 225 | id: true, 226 | email: true, 227 | firstName: true, 228 | lastName: true, 229 | social: true, 230 | }, 231 | }) 232 | if (!user) { 233 | return h.response().code(404) 234 | } else { 235 | return h.response(user).code(200) 236 | } 237 | } catch (err) { 238 | request.log('error', err) 239 | return Boom.badImplementation('failed to get user') 240 | } 241 | } 242 | 243 | async function createUserHandler( 244 | request: Hapi.Request, 245 | h: Hapi.ResponseToolkit, 246 | ) { 247 | const { prisma } = request.server.app 248 | const payload = request.payload as UserInput 249 | 250 | try { 251 | const createdUser = await prisma.user.create({ 252 | data: { 253 | firstName: payload.firstName, 254 | lastName: payload.lastName, 255 | email: payload.email, 256 | social: payload.social, 257 | }, 258 | select: { 259 | id: true, 260 | email: true, 261 | firstName: true, 262 | lastName: true, 263 | social: true, 264 | }, 265 | }) 266 | return h.response(createdUser).code(201) 267 | } catch (err) { 268 | request.log('error', err) 269 | return Boom.badImplementation('failed to create user') 270 | } 271 | } 272 | 273 | async function deleteUserHandler( 274 | request: Hapi.Request, 275 | h: Hapi.ResponseToolkit, 276 | ) { 277 | const { prisma } = request.server.app 278 | const userId = parseInt(request.params.userId, 10) 279 | 280 | try { 281 | // Delete all enrollments 282 | await prisma.$transaction([ 283 | prisma.token.deleteMany({ 284 | where: { 285 | userId: userId, 286 | }, 287 | }), 288 | prisma.user.delete({ 289 | where: { 290 | id: userId, 291 | }, 292 | }), 293 | ]) 294 | 295 | return h.response().code(204) 296 | } catch (err) { 297 | request.log('error', err) 298 | return Boom.badImplementation('failed to delete user') 299 | } 300 | } 301 | 302 | async function updateUserHandler( 303 | request: Hapi.Request, 304 | h: Hapi.ResponseToolkit, 305 | ) { 306 | const { prisma } = request.server.app 307 | const userId = parseInt(request.params.userId, 10) 308 | const payload = request.payload as Partial 309 | 310 | try { 311 | const updatedUser = await prisma.user.update({ 312 | where: { 313 | id: userId, 314 | }, 315 | data: payload, 316 | }) 317 | return h.response(updatedUser).code(200) 318 | } catch (err) { 319 | request.log('error', err) 320 | return Boom.badImplementation('failed to update user') 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /src/plugins/auth.ts: -------------------------------------------------------------------------------- 1 | import Hapi from '@hapi/hapi' 2 | import Joi from 'joi' 3 | import Boom from '@hapi/boom' 4 | import jwt from 'jsonwebtoken' 5 | import { TokenType, UserRole } from '@prisma/client' 6 | import { add, compareAsc } from 'date-fns' 7 | 8 | declare module '@hapi/hapi' { 9 | interface AuthCredentials { 10 | userId: number 11 | tokenId: number 12 | isAdmin: boolean 13 | // 👇 The courseIds that a user is a teacher of, thereby granting him permissions to change entitites 14 | teacherOf: number[] 15 | } 16 | } 17 | 18 | const authPlugin: Hapi.Plugin = { 19 | name: 'app/auth', 20 | dependencies: ['prisma', 'hapi-auth-jwt2', 'app/email'], 21 | register: async function (server: Hapi.Server) { 22 | if (!process.env.JWT_SECRET) { 23 | server.log( 24 | 'warn', 25 | 'The JWT_SECRET env var is not set. This is unsafe! If running in production, set it.', 26 | ) 27 | } 28 | 29 | server.auth.strategy(API_AUTH_STATEGY, 'jwt', { 30 | key: JWT_SECRET, 31 | verifyOptions: { algorithms: [JWT_ALGORITHM] }, 32 | validate: validateAPIToken, 33 | }) 34 | 35 | // Require by default API token unless otherwise configured 36 | server.auth.default(API_AUTH_STATEGY) 37 | 38 | server.route([ 39 | // Endpoint to login or regsiter and to send the short lived token 40 | { 41 | method: 'POST', 42 | path: '/login', 43 | handler: loginHandler, 44 | options: { 45 | auth: false, 46 | validate: { 47 | failAction: (request, h, err) => { 48 | // show validation errors to user https://github.com/hapijs/hapi/issues/3706 49 | throw err 50 | }, 51 | payload: Joi.object({ 52 | email: Joi.string().email().required(), 53 | }), 54 | }, 55 | }, 56 | }, 57 | { 58 | // Endpoint to authenticate the magiclink and to generate a long lived token 59 | method: 'POST', 60 | path: '/authenticate', 61 | handler: authenticateHandler, 62 | options: { 63 | auth: false, 64 | validate: { 65 | payload: Joi.object({ 66 | email: Joi.string().email().required(), 67 | emailToken: Joi.string().required(), 68 | }), 69 | }, 70 | }, 71 | }, 72 | ]) 73 | }, 74 | } 75 | export default authPlugin 76 | 77 | export const API_AUTH_STATEGY = 'API' 78 | 79 | const JWT_SECRET = process.env.JWT_SECRET || 'SUPER_SECRET_JWT_SECRET' 80 | 81 | const JWT_ALGORITHM = 'HS256' 82 | 83 | const EMAIL_TOKEN_EXPIRATION_MINUTES = 10 84 | const AUTHENTICATION_TOKEN_EXPIRATION_HOURS = 12 85 | 86 | const apiTokenSchema = Joi.object({ 87 | tokenId: Joi.number().integer().required(), 88 | }) 89 | 90 | interface APITokenPayload { 91 | tokenId: number 92 | } 93 | 94 | interface LoginInput { 95 | email: string 96 | } 97 | 98 | interface AuthenticateInput { 99 | email: string 100 | emailToken: string 101 | } 102 | 103 | // Function will be called on every request using the auth strategy 104 | const validateAPIToken = async ( 105 | decoded: APITokenPayload, 106 | request: Hapi.Request, 107 | h: Hapi.ResponseToolkit, 108 | ) => { 109 | const { prisma } = request.server.app 110 | const { tokenId } = decoded 111 | const { error } = apiTokenSchema.validate(decoded) 112 | 113 | if (error) { 114 | request.log(['error', 'auth'], `API token error: ${error.message}`) 115 | return { isValid: false } 116 | } 117 | 118 | try { 119 | // Fetch the token from DB to verify it's valid 120 | const fetchedToken = await prisma.token.findUnique({ 121 | where: { 122 | id: tokenId, 123 | }, 124 | include: { 125 | user: true, 126 | }, 127 | }) 128 | 129 | // Check if token could be found in database and is valid 130 | if (!fetchedToken || !fetchedToken?.valid) { 131 | return { isValid: false, errorMessage: 'Invalid token' } 132 | } 133 | 134 | // Check token expiration 135 | if (fetchedToken.expiration < new Date()) { 136 | return { isValid: false, errorMessage: 'Token expired' } 137 | } 138 | 139 | const teacherOf = await prisma.courseEnrollment.findMany({ 140 | where: { 141 | userId: fetchedToken.userId, 142 | role: UserRole.TEACHER, 143 | }, 144 | select: { 145 | courseId: true, 146 | }, 147 | }) 148 | 149 | // The token is valid. Pass the token payload (in `decoded`), userId, and isAdmin to `credentials` 150 | // which is available in route handlers via request.auth.credentials 151 | return { 152 | isValid: true, 153 | credentials: { 154 | tokenId: decoded.tokenId, 155 | userId: fetchedToken.userId, 156 | isAdmin: fetchedToken.user.isAdmin, 157 | // convert teacherOf into an array of courseIds 158 | teacherOf: teacherOf.map(({ courseId }) => courseId), 159 | }, 160 | } 161 | } catch (error) { 162 | request.log(['error', 'auth', 'db'], error) 163 | return { isValid: false, errorMessage: 'DB Error' } 164 | } 165 | } 166 | 167 | /** 168 | * Login/Registration handler 169 | * 170 | * Because there are no passwords, the same endpoint is used for login and regsitration 171 | * Generates a short lived verification token and sends an email 172 | */ 173 | async function loginHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) { 174 | // 👇 get prisma and the sendEmailToken from shared application state 175 | const { prisma, sendEmailToken } = request.server.app 176 | // 👇 get the email from the request payload 177 | const { email } = request.payload as LoginInput 178 | // 👇 generate an alphanumeric token 179 | const emailToken = generateEmailToken() 180 | // 👇 create a date object for the email token expiration 181 | const tokenExpiration = add(new Date(), { 182 | minutes: EMAIL_TOKEN_EXPIRATION_MINUTES, 183 | }) 184 | 185 | try { 186 | // 👇 create a short lived token and update user or create if they don't exist 187 | const createdToken = await prisma.token.create({ 188 | data: { 189 | emailToken, 190 | type: TokenType.EMAIL, 191 | expiration: tokenExpiration, 192 | user: { 193 | connectOrCreate: { 194 | create: { 195 | email, 196 | }, 197 | where: { 198 | email, 199 | }, 200 | }, 201 | }, 202 | }, 203 | }) 204 | 205 | // 👇 send the email token 206 | await sendEmailToken(email, emailToken) 207 | return h.response().code(200) 208 | } catch (error) { 209 | return Boom.badImplementation(error.message) 210 | } 211 | } 212 | 213 | async function authenticateHandler( 214 | request: Hapi.Request, 215 | h: Hapi.ResponseToolkit, 216 | ) { 217 | // 👇 get prisma from shared application state 218 | const { prisma } = request.server.app 219 | // 👇 get the email and emailToken from the request payload 220 | const { email, emailToken } = request.payload as AuthenticateInput 221 | 222 | try { 223 | // Get short lived email token 224 | const fetchedEmailToken = await prisma.token.findUnique({ 225 | where: { 226 | emailToken: emailToken, 227 | }, 228 | include: { 229 | user: true, 230 | }, 231 | }) 232 | 233 | if (!fetchedEmailToken?.valid) { 234 | // If the token doesn't exist or is not valid, return 401 unauthorized 235 | return Boom.unauthorized() 236 | } 237 | 238 | if (fetchedEmailToken.expiration < new Date()) { 239 | // If the token has expired, return 401 unauthorized 240 | return Boom.unauthorized('Token expired') 241 | } 242 | 243 | // If token matches the user email passed in the payload, generate long lived API token 244 | if (fetchedEmailToken?.user?.email === email) { 245 | const tokenExpiration = add(new Date(), { 246 | hours: AUTHENTICATION_TOKEN_EXPIRATION_HOURS, 247 | }) 248 | // Persist token in DB so it's stateful 249 | const createdToken = await prisma.token.create({ 250 | data: { 251 | type: TokenType.API, 252 | expiration: tokenExpiration, 253 | user: { 254 | connect: { 255 | email, 256 | }, 257 | }, 258 | }, 259 | }) 260 | 261 | // Invalidate the email token after it's been used 262 | await prisma.token.update({ 263 | where: { 264 | id: fetchedEmailToken.id, 265 | }, 266 | data: { 267 | valid: false, 268 | }, 269 | }) 270 | 271 | const authToken = generateAuthToken(createdToken.id) 272 | return h.response().code(200).header('Authorization', authToken) 273 | } else { 274 | return Boom.unauthorized() 275 | } 276 | } catch (error) { 277 | return Boom.badImplementation(error.message) 278 | } 279 | } 280 | 281 | // Generate a signed JWT token with the tokenId in the payload 282 | function generateAuthToken(tokenId: number): string { 283 | const jwtPayload = { tokenId } 284 | 285 | return jwt.sign(jwtPayload, JWT_SECRET, { 286 | algorithm: JWT_ALGORITHM, 287 | noTimestamp: true, 288 | }) 289 | } 290 | 291 | // Generate a random 8 digit number as the email token 292 | function generateEmailToken(): string { 293 | return Math.floor(10000000 + Math.random() * 90000000).toString() 294 | } 295 | --------------------------------------------------------------------------------