From f9455d074de630136c0a437c0cd0a15ccacbbd6f Mon Sep 17 00:00:00 2001 From: aungmyooo Date: Tue, 8 Aug 2023 13:54:08 +0630 Subject: [PATCH] Initial --- .env.sample | 9 + .gitignore | 35 ++ Dockerfile | 26 ++ README.md | 206 +++++++++ .../controllers/teachers-controller.test.ts | 426 ++++++++++++++++++ __test__/utils/utils.test.ts | 45 ++ docker-compose.yml | 27 ++ jest.config.js | 5 + package.json | 30 ++ .../20230807091955_init/migration.sql | 33 ++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 27 ++ src/app.ts | 22 + src/controllers/teachers-controller.ts | 229 ++++++++++ src/middlewares/global-error-middleware.ts | 67 +++ src/routes/teachers-route.ts | 16 + src/server.ts | 30 ++ src/utils/async-error-handler.ts | 16 + src/utils/custom-error.ts | 13 + src/utils/utils.ts | 15 + tsconfig.json | 103 +++++ 21 files changed, 1383 insertions(+) create mode 100644 .env.sample create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 __test__/controllers/teachers-controller.test.ts create mode 100644 __test__/utils/utils.test.ts create mode 100644 docker-compose.yml create mode 100644 jest.config.js create mode 100644 package.json create mode 100644 prisma/migrations/20230807091955_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 src/app.ts create mode 100644 src/controllers/teachers-controller.ts create mode 100644 src/middlewares/global-error-middleware.ts create mode 100644 src/routes/teachers-route.ts create mode 100644 src/server.ts create mode 100644 src/utils/async-error-handler.ts create mode 100644 src/utils/custom-error.ts create mode 100644 src/utils/utils.ts create mode 100644 tsconfig.json diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..804533d --- /dev/null +++ b/.env.sample @@ -0,0 +1,9 @@ +# Environment variables declared in this file are automatically made available to Prisma. +# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema + +# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. +# See the documentation for all the connection string options: https://pris.ly/d/connection-strings + +NODE_ENV=development +NODE_PORT=3000 +DATABASE_URL=mysql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1914380 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Ignore Node.js build files +node_modules/ + +# Ignore TypeScript build artifacts +/dist/ +*.tsbuildinfo + +# Ignore Prisma artifacts +/prisma/client/ + +# Ignore editor-specific files +.vscode/ +*.sublime-project +*.sublime-workspace +*.idea/ + +# Ignore log files +*.log + +# Ignore environment-specific files +.env + +# Ignore package lock files +package-lock.json +yarn.lock + +# Ignore OS-specific files +.DS_Store +Thumbs.db + +# Ignore coverage reports (if you have tests and generate coverage reports) +/coverage/ + +# Ignore build artifacts from CI/CD +/build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..23a2721 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Use the specified image as the base +FROM node:18-alpine + +# Set the working directory in the container +WORKDIR /app + +# Install Prisma CLI +RUN npm install -g prisma + +# Copy the package.json and package-lock.json first to leverage Docker cache +COPY package.json package-lock.json ./ + +# Install the dependencies +RUN npm install + +# Copy the rest of the application +COPY . . + +# Generate Prisma client for this environment +RUN npx prisma generate + +# Expose port 8080 for the app +EXPOSE 8080 + +# Run the app when the container launches +CMD [ "npm", "start" ] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7545c4b --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +# D3Hiring API Server + +API server powered by Node.js, TypeScript, Prisma, and MySQL, designed for the D3Hiring project. + +## Table of Contents + +- [Prerequisites](#prerequisites) +- [Installation](#installation) + - [Using Docker](#using-docker) + - [Locally Without Docker](#locally-without-docker) +- [Running the Server](#running-the-server) +- [Running Tests](#running-tests) +- [Hosted API](#hosted-api) +- [API Documentation](#api-documentation) +- [Authors](#authors) +- [License](#license) + +## Prerequisites + +Ensure you have the following installed on your system: + +- Docker & Docker Compose (for Docker installation) +- MySQL (for local installation) +- Node.js +- Git + +## Installation + +### Using Docker + +**1. Clone the Repository:** + +```bash +git clone https://github.com/alexaung/d3hiring.git +cd d3hiring +``` + +**2. Set Up Docker Containers:** + +```bash +docker-compose up -d +``` + +This command will start the API server and MySQL database as described in the docker-compose.yml file. + +**3. Database Migrations:** + +After the services are up, you can execute migrations with: + +```bash +docker-compose exec app npx prisma migrate deploy +``` + +### Locally Without Docker + +**1. Clone the Repository:** + +```bash +git clone https://github.com/alexaung/d3hiring.git +cd d3hiring +``` + +**2. Install Dependencies:** + +```bash +npm install +``` + +**3. Generate Prisma Client:** + +This will generate necessary client files for Prisma. + +```bash +npx prisma generate +``` + +**4. Run Database Migrations:** + +First, ensure your MySQL service is running. Then, run: + +```bash +npx prisma migrate deploy +``` + +## Running the Server + +With the containers up and running, the API server will be accessible at: + +```bash +http://localhost:3000 +``` +## Running Tests + +To run the unit tests for the API, use the following command: + +```bash +npm test +``` + +## Hosted API + +The API is hosted on Google Cloud Run and uses Cloud SQL for the database. Please note that this hosted version is configured with minimum capacity for testing purposes only. + +- API Base URL: `https://d3hiring-dwx6gkx3sq-uc.a.run.app/` + +Please use this link for testing and evaluation purposes. If you intend to deploy the API for production use, make sure to adjust the configuration and capacity accordingly. + +## API Documentation + +Here's a detailed overview of the available endpoints: + +***1. Register Students to a Teacher*** (`POST /api/register`) + +**Description**: This endpoint allows a teacher to register one or more students to their class. + +**Request:** + +```json +{ + "teacher": "teacherken@gmail.com", + "students": [ + "studentjon@gmail.com", + "studenthon@gmail.com" + ] +} +``` + +**Response:** + +- Status Code: `204 No Content` +- Description: The request was successful. + +***2. Retrieve Common Students*** (`GET /api/commonstudents`) + +**Description**: This endpoint retrieves a list of students who are common to the given list of teachers. + +**Request:** + +- Method: GET +- Query Parameters: + - `teacher`: Teacher's email (multiple values allowed). +- Example: `GET /api/commonstudents?teacher=teacherken%40gmail.com&teacher=teacherjoe%40gmail.com` + +**Response:** +- Status Code: `200 OK` +- Body: + +```json +{ + "students": [ + "commonstudent1@gmail.com", + "commonstudent2@gmail.com" + ] +} +``` + +***3. Suspend a Student*** (`POST /api/suspend`) + +**Description**: This endpoint allows a teacher to suspend a specified student. + +**Request:** + +```json +{ + "student" : "studentmary@gmail.com" +} +``` + +**Response:** +- Status Code: `204 No Content` +- Description: The request was successful. + +***4. Retrieve Student Recipients for a Notification*** (`POST /api/retrievefornotifications`) + +**Description**: This endpoint allows a teacher to retrieve a list of students who can receive a given notification. + +**Request:** + +```json +{ + "teacher": "teacherken@gmail.com", + "notification": "Hello students! @studentagnes@gmail.com @studentmiche@gmail.com" +} +``` + +**Response:** +- Status Code: `200 OK` +- Body: + +```json +{ + "recipients": [ + "studentbob@gmail.com", + "studentagnes@gmail.com", + "studentmiche@gmail.com" + ] +} +``` + +## Authors + +Alex Aung Myo OO + +## License + +This project is licensed under the MIT License. See the `LICENSE` file for details. \ No newline at end of file diff --git a/__test__/controllers/teachers-controller.test.ts b/__test__/controllers/teachers-controller.test.ts new file mode 100644 index 0000000..0d2ddd6 --- /dev/null +++ b/__test__/controllers/teachers-controller.test.ts @@ -0,0 +1,426 @@ +import request from 'supertest'; +import app from "../../src/app"; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +beforeEach(async () => { + + // Disconnect and delete the many-to-many relationships between teachers and students: + const teachers = await prisma.teacher.findMany({ include: { students: true } }); + for (let teacher of teachers) { + for (let student of teacher.students) { + await prisma.teacher.update({ + where: { id: teacher.id }, + data: { + students: { + disconnect: { id: student.id } + } + } + }); + } + } + + // Now that relations are disconnected, we can delete main entities: + await prisma.teacher.deleteMany(); + await prisma.student.deleteMany(); +}); + +// After all tests have finished, close the Prisma connection +afterAll(async () => { + await prisma.$disconnect(); +}); + +describe('POST /api/register', () => { + //1. checks if a teacher and students can be registered successfully: + it('responds with 204 and registers the teacher and students', async () => { + const teacherEmail = 'teacher1@example.com'; + const studentsEmails = ['student1@example.com', 'student2@example.com']; + + const response = await request(app) + .post('/api/register') + .send({ + teacher: teacherEmail, + students: studentsEmails, + }); + + expect(response.status).toBe(204); + + // Verify that the teacher and students were inserted into the database + const teacher = await prisma.teacher.findUnique({ + where: { email: teacherEmail }, + include: { students: true }, + }); + + expect(teacher).toBeDefined(); + + const students = teacher?.students.map((student) => student.email) || []; + + expect(students).toEqual(expect.arrayContaining(studentsEmails)); + }); + + //2. Test when the request body is missing parameters: + it('responds with 400 if teacher is missing from request body', async () => { + const response = await request(app) + .post('/api/register') + .send({ + students: ['student1@example.com'] + }); + expect(response.status).toBe(400); + expect(response.body.message).toBe('Missing teacher or students'); + }); + + //3. Test when students or teacher data is not in the form of an email: + it('responds with 400 if students are missing from request body', async () => { + const response = await request(app) + .post('/api/register') + .send({ + teacher: 'teacher1@example.com' + }); + expect(response.status).toBe(400); + expect(response.body.message).toBe('Missing teacher or students'); + }); + + //4. Test when students or teacher data is not in the form of an email: + it('responds with 400 if teacher is not a valid email', async () => { + const response = await request(app) + .post('/api/register') + .send({ + teacher: 'invalidteacher', + students: ['student1@example.com'] + }); + expect(response.status).toBe(400); + expect(response.body.message).toBe('Invalid teacher email format'); + }); + + //5. Test when students or teacher data is not in the form of an email: + it('responds with 400 if a student is not a valid email', async () => { + const response = await request(app) + .post('/api/register') + .send({ + teacher: 'teacher1@example.com', + students: ['invalidstudent'] + }); + expect(response.status).toBe(400); + expect(response.body.message).toBe('Invalid student email format'); + }); + + //6. Test the case when a student is registered with another teacher: + it('can register a student who is already registered with another teacher', async () => { + // First, ensure the student is registered with teacher1 + await request(app) + .post('/api/register') + .send({ + teacher: 'teacher1@example.com', + students: ['student1@example.com'] + }); + + // Register the same student with teacher2 + const response = await request(app) + .post('/api/register') + .send({ + teacher: 'teacher2@example.com', + students: ['student1@example.com'] + }); + + expect(response.status).toBe(204); + + const teacher2 = await prisma.teacher.findUnique({ + where: { email: 'teacher2@example.com' }, + include: { students: true } + }); + + expect(teacher2?.students).toBeDefined(); + expect(teacher2?.students[0].email).toBe('student1@example.com'); + }); +}); + +describe('GET /api/commonstudents', () => { + //1. Retrieve the list of students common to a single teacher: + it('responds wtih 200 and retrieves students registered to a single teacher', async () => { + + // First, ensure the student is registered with teacherken@gmail.com + await request(app) + .post('/api/register') + .send({ + teacher: 'teacherken@gmail.com', + students: [ + 'commonstudent1@gmail.com', + 'commonstudent2@gmail.com', + 'student_only_under_teacher_ken@gmail.com' + ] + }); + + const response = await request(app) + .get('/api/commonstudents?teacher=teacherken%40gmail.com'); + + expect(response.status).toBe(200); + expect(response.body.students).toEqual(expect.arrayContaining([ + 'commonstudent1@gmail.com', + 'commonstudent2@gmail.com', + 'student_only_under_teacher_ken@gmail.com' + ])); + }); + + //2. Retrieve the list of students common to multiple teachers: + it('responds wtih 200 and retrieves students registered to multiple teachers', async () => { + + // First, ensure the student is registered with + // teacherken@gmail and teacherjoe@ + await request(app) + .post('/api/register') + .send({ + teacher: 'teacherken@gmail.com', + students: [ + 'commonstudent1@gmail.com', + 'commonstudent2@gmail.com', + 'student_only_under_teacher_ken@gmail.com' + ] + }); + + await request(app) + .post('/api/register') + .send({ + teacher: 'teacherjoe@gmail.com', + students: [ + 'commonstudent1@gmail.com', + 'commonstudent2@gmail.com' + ] + }); + + const response = await request(app) + .get('/api/commonstudents?teacher=teacherken%40gmail.com&teacher=teacherjoe%40gmail.com'); + + expect(response.status).toBe(200); + expect(response.body.students).toEqual(expect.arrayContaining([ + 'commonstudent1@gmail.com', + 'commonstudent2@gmail.com' + ])); + + }); + + //3. Test when the request query is missing parameters: + it('responds with 400 if teacher is missing from request query', async () => { + const response = await request(app) + .get('/api/commonstudents'); + expect(response.status).toBe(400); + expect(response.body.message).toBe('No teachers specified'); + }); + + //4. Test when an invalid/non-existent teacher is specified: + it('responds with 400 if teacher does not exist', async () => { + const response = await request(app) + .get('/api/commonstudents?teacher=invalidteacher%40gmail.com'); + expect(response.status).toBe(404); + expect(response.body.message).toBe('Teacher does not exist'); + }); + + //5. Test where there are no common students between the teachers: + it('responds with 200 and empty list if no common students', async () => { + + // First, ensure the student is registered with teacher1@gmail and teacher2@gmail.com + // Register teacher1 with students + await request(app) + .post('/api/register') + .send({ + teacher: 'teacher1@gmail.com', + students: [ + 'studentA1@gmail.com', + 'studentA2@gmail.com', + 'studentA3@gmail.com' + ] + }); + + // Register teacher2 with different students + await request(app) + .post('/api/register') + .send({ + teacher: 'teacher2@gmail.com', + students: [ + 'studentB1@gmail.com', + 'studentB2@gmail.com', + 'studentB3@gmail.com' + ] + }); + + const response = await request(app) + .get('/api/commonstudents?teacher=teacher1%40gmail.com&teacher=teacher2%40gmail.com'); + expect(response.status).toBe(200); + expect(response.body.students).toEqual([]); + }); +}); + +describe('POST /api/suspend', () => { + + //1. Test when a student is successfully suspended: + it('responds with 204 and successfully suspends a valid student', async () => { + // First, ensure the student is registered with teacher1@gmail and is not suspended + await request(app) + .post('/api/register') + .send({ + teacher: 'teacher1@gmail.com', + students: [ + 'studentmary@gmail.com', + ] + }); + + const response = await request(app) + .post('/api/suspend') + .send({ + student: 'studentmary@gmail.com', + }); + expect(response.status).toBe(204); + }); + + //2. Test when a student is not found: + it('responds with 400 when trying to suspend a student who does not exist', async () => { + const response = await request(app) + .post('/api/suspend') + .send({ + student: 'nonexistentstudent@gmail.com', + }); + expect(response.status).toBe(400); + expect(response.body.message).toBe('Student does not exist'); + }); + + //3. Test when no student is specified: + it('responds with 400 when no student email is specified', async () => { + const response = await request(app).post('/api/suspend').send({}); + expect(response.status).toBe(400); + expect(response.body.message).toBe('No student specified'); + }); + + //4. Test when a student is already suspended: + it('responds with 204 when suspending an already suspended student', async () => { + // First, ensure the student is registered with teacher1@gmail and is not suspended + await request(app) + .post('/api/register') + .send({ + teacher: 'teacher1@gmail.com', + students: [ + 'studentmary@gmail.com', + ] + }); + + // Suspend the student + const response1 = await request(app) + .post('/api/suspend') + .send({ + student: 'studentmary@gmail.com', + }); + + const response = await request(app) + .post('/api/suspend') + .send({ + student: 'studentmary@gmail.com', + }); + expect(response.status).toBe(204); + }); +}); + +describe('POST /api/retrievefornotifications', () => { + + // Setup necessary data before running the tests + beforeEach(async () => { + // Register a teacher + await request(app) + .post('/api/register') + .send({ + teacher: 'teacherken@gmail.com', + students: [ + 'studentbob@gmail.com', + 'studentagnes@gmail.com' + ] + }); + + // Register another teacher and students for other test cases + await request(app) + .post('/api/register') + .send({ + teacher: 'teacheralice@gmail.com', + students: [ + 'studentmiche@gmail.com' + ] + }); + }); + + + it('responds with 200 and retrieves students who can receive a notification with @mentions', async () => { + const response = await request(app) + .post('/api/retrievefornotifications') + .send({ + teacher: 'teacherken@gmail.com', + notification: 'Hello students! @studentagnes@gmail.com @studentmiche@gmail.com' + }); + expect(response.status).toBe(200); + expect(response.body.recipients).toEqual(expect.arrayContaining([ + "studentbob@gmail.com", + "studentagnes@gmail.com", + "studentmiche@gmail.com" + ])); + }); + + it('responds with 200 and retrieves students who can receive a notification without any @mentions', async () => { + const response = await request(app) + .post('/api/retrievefornotifications') + .send({ + teacher: 'teacherken@gmail.com', + notification: 'Hey everybody' + }); + expect(response.status).toBe(200); + expect(response.body.recipients).toEqual(expect.arrayContaining([ + "studentbob@gmail.com" + ])); + }); + + it('responds with 400 when no teacher is specified', async () => { + const response = await request(app) + .post('/api/retrievefornotifications') + .send({ + notification: 'Hey everybody' + }); + expect(response.status).toBe(400); + expect(response.body.message).toBe('Missing teacher or notification'); + }); + + it('responds with 400 when no notification text is specified', async () => { + const response = await request(app) + .post('/api/retrievefornotifications') + .send({ + teacher: 'teacherken@gmail.com' + }); + expect(response.status).toBe(400); + expect(response.body.message).toBe('Missing teacher or notification'); + }); + + it('responds with 400 when a non-existent teacher sends a notification', async () => { + const response = await request(app) + .post('/api/retrievefornotifications') + .send({ + teacher: 'nonexistentteacher@gmail.com', + notification: 'Hello' + }); + expect(response.status).toBe(400); + expect(response.body.message).toBe('Teacher does not exist'); + }); + + // To test the case for a suspended student, we first need to suspend a student. + it('responds with 200 but does not include a suspended student in the recipients list', async () => { + + // Suspend the student first + await request(app) + .post('/api/suspend') + .send({ student: 'studentmiche@gmail.com' }); + + // Now send a notification mentioning the suspended student + const response = await request(app) + .post('/api/retrievefornotifications') + .send({ + teacher: 'teacherken@gmail.com', + notification: 'Hello @studentmiche@gmail.com' + }); + + expect(response.status).toBe(200); + expect(response.body.recipients).not.toContain('studentmiche@gmail.com'); + }); +}); diff --git a/__test__/utils/utils.test.ts b/__test__/utils/utils.test.ts new file mode 100644 index 0000000..0852010 --- /dev/null +++ b/__test__/utils/utils.test.ts @@ -0,0 +1,45 @@ +import { isValidEmail, parseMentionedEmails } from "../../src/utils/utils"; + +describe('isValidEmail utility function', () => { + + it('should return true for a valid email', () => { + const email = "test@example.com"; + expect(isValidEmail(email)).toBe(true); + }); + + it('should return false for an email without a domain', () => { + const email = "test@"; + expect(isValidEmail(email)).toBe(false); + }); + + it('should return false for an email without a local part', () => { + const email = "@example.com"; + expect(isValidEmail(email)).toBe(false); + }); + + it('should return false for an email with spaces', () => { + const email = "test @example.com"; + expect(isValidEmail(email)).toBe(false); + }); +}); + + +describe('parseMentionedEmails utility function', () => { + + it('should return all valid mentioned emails', () => { + const text = "Hello @test1@example.com and @test2@example.com!"; + const expectedEmails = ["test1@example.com", "test2@example.com"]; + expect(parseMentionedEmails(text)).toEqual(expectedEmails); + }); + + it('should not return invalid emails', () => { + const text = "Hello @invalidEmail and @test@example.com!"; + const expectedEmails = ["test@example.com"]; + expect(parseMentionedEmails(text)).toEqual(expectedEmails); + }); + + it('should return an empty array if no emails are mentioned', () => { + const text = "Hello everyone!"; + expect(parseMentionedEmails(text)).toEqual([]); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..af75e07 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3' + +services: + + app: + image: gcr.io/d3hiring/d3hiring:latest + build: . + ports: + - "3000:3000" + environment: + - NODE_ENV=development + - PORT=3000 + - DATABASE_URL=mysql://root:eFfzsYywAp9E@db:3306/d3hiring + networks: + - d3hiring-network + + db: + image: mysql:8.0 + environment: + - MYSQL_ROOT_PASSWORD=eFfzsYywAp9E + - MYSQL_DATABASE=d3hiring + ports: + - "3307:3306" + networks: + - d3hiring-network +networks: + d3hiring-network: \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..e37c356 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testTimeout: 20000 + }; diff --git a/package.json b/package.json new file mode 100644 index 0000000..a3a5bca --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "d3hiring", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "start": "nodemon --watch 'src/**/*.ts' --ignore 'src/**/*.spec.ts' --exec 'ts-node' src/server.ts", + "test": "jest --config jest.config.js", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev" + }, + "dependencies": { + "@prisma/client": "^5.1.1", + "@types/express": "^4.17.17", + "@types/node": "^20.4.8", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "nodemon": "^3.0.1", + "prisma": "^5.1.1", + "ts-node": "^10.9.1", + "typescript": "^5.1.6" + }, + "devDependencies": { + "@types/jest": "^29.5.3", + "@types/supertest": "^2.0.12", + "jest": "^29.6.2", + "supertest": "^6.3.3", + "ts-jest": "^29.1.1" + } +} diff --git a/prisma/migrations/20230807091955_init/migration.sql b/prisma/migrations/20230807091955_init/migration.sql new file mode 100644 index 0000000..fb0b233 --- /dev/null +++ b/prisma/migrations/20230807091955_init/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE `Student` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `email` VARCHAR(191) NOT NULL, + `suspended` BOOLEAN NOT NULL DEFAULT false, + + UNIQUE INDEX `Student_email_key`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Teacher` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `email` VARCHAR(191) NOT NULL, + + UNIQUE INDEX `Teacher_email_key`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `_StudentToTeacher` ( + `A` INTEGER NOT NULL, + `B` INTEGER NOT NULL, + + UNIQUE INDEX `_StudentToTeacher_AB_unique`(`A`, `B`), + INDEX `_StudentToTeacher_B_index`(`B`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `_StudentToTeacher` ADD CONSTRAINT `_StudentToTeacher_A_fkey` FOREIGN KEY (`A`) REFERENCES `Student`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `_StudentToTeacher` ADD CONSTRAINT `_StudentToTeacher_B_fkey` FOREIGN KEY (`B`) REFERENCES `Teacher`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..e5a788a --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "mysql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..ffd59eb --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,27 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" + binaryTargets = ["linux-musl-openssl-3.0.x", "darwin"] +} + + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +model Student { + id Int @id @default(autoincrement()) + email String @unique + suspended Boolean @default(false) + teachers Teacher[] +} + +model Teacher { + id Int @id @default(autoincrement()) + email String @unique + students Student[] +} + diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..807264d --- /dev/null +++ b/src/app.ts @@ -0,0 +1,22 @@ +import express, { Request, Response, NextFunction } from 'express'; +import teachersRouter from './routes/teachers-route'; +import globalErrorHandler from "./middlewares/global-error-middleware"; +import CustomError from "./utils/custom-error"; +require('dotenv').config() +const app = express(); + +app.use(express.json()); + +// Mounting the router on the app at the root level +app.use('/', teachersRouter); + +app.all('*', (req: Request, res: Response, next: NextFunction) => { + const err = new CustomError(`Can't find ${req.originalUrl} on this server!`, 404); + next(err); +}); + +// Global error handling middleware +app.use(globalErrorHandler); + +export default app; + diff --git a/src/controllers/teachers-controller.ts b/src/controllers/teachers-controller.ts new file mode 100644 index 0000000..1ac7d8e --- /dev/null +++ b/src/controllers/teachers-controller.ts @@ -0,0 +1,229 @@ +// Desc: Handlers for teachers routes +import { Request, Response, NextFunction } from 'express'; +import { PrismaClient } from '@prisma/client'; +import asyncErrorHandler from '../utils/async-error-handler'; +import CustomError from '../utils/custom-error'; +import { isValidEmail, parseMentionedEmails } from '../utils/utils'; + +const prisma = new PrismaClient(); + +const register = asyncErrorHandler(async (req: Request, res: Response, next: NextFunction) => { + + const { teacher, students } = req.body; + if (!teacher || !students || !Array.isArray(students)) { + return next(new CustomError('Missing teacher or students', 400)); + } + // Validate teacher email format + if (!isValidEmail(teacher)) { + return next(new CustomError('Invalid teacher email format', 400)); + } + + // Validate student email formats + students.forEach((studentEmail) => { + if (!isValidEmail(studentEmail)) { + return next(new CustomError('Invalid student email format', 400)); + } + }); + + // Check if teacher exists, if not, create the teacher + let existingTeacher = await prisma.teacher.findUnique({ + where: { + email: teacher + }, + }); + + if (!existingTeacher) { + existingTeacher = await prisma.teacher.create({ + data: { + email: teacher + } + }); + } + + // Get the students connected to the teacher + const currentStudents = await prisma.student.findMany({ + where: { + teachers: { + some: { + email: teacher, + }, + }, + }, + }); + + await Promise.all(students.map(async (studentEmail: string) => { + + // Check if student is already connected to the teacher + if (currentStudents.some((student) => student.email === studentEmail)) { + return; + } + + // Check if student exists, if not, create the student + let student = await prisma.student.findUnique({ + where: { email: studentEmail }, + }); + + if (!student) { + student = await prisma.student.create({ + data: { email: studentEmail }, + }); + } + + await prisma.teacher.update({ + where: { email: teacher }, + data: { + students: { + connect: { id: student.id }, + }, + }, + }); + })); + + res.status(204).json(); +}); + +const commonstudents = asyncErrorHandler(async (req: Request, res: Response, next: NextFunction) => { + + const { teacher } = req.query; + + // Validate the presence of teacher query and ensure it is an array or a single string + if (!teacher || !Array.isArray(teacher) && teacher.length === 0) { + return next(new CustomError('No teachers specified', 400)); + } + + // Convert teacher to an array if it is a single string + const teachersArray = Array.isArray(teacher) ? teacher : [teacher]; + + let commonstudents: string[] = []; + + for (const teacherEmail of teachersArray) { + // Check if the teacher exists + const teacherExists = await prisma.teacher.findUnique({ + where: { email: teacherEmail as string } + }); + + // If the teacher doesn't exist, return a 404 error + if (!teacherExists) { + return next(new CustomError('Teacher does not exist', 404)); + } + // Get the students connected to the teacher + const studentsOfTeacher = await prisma.student.findMany({ + where: { + teachers: { + some: { + email: teacherEmail, + }, + }, + }, + select: { + email: true, + }, + }); + + // Get the emails of the students connected to the teacher + const emails = studentsOfTeacher.map((student) => student.email); + + // If commonstudents is empty, set it to the emails, else intersect with existing commonstudents + if (commonstudents.length === 0) { + commonstudents = emails; + } else { + commonstudents = commonstudents.filter((email) => emails.includes(email)); + } + + // If commonstudents is empty, break out of the loop + if (commonstudents.length === 0) { + break; + } + } + + res.status(200).json({ + status: 'success', + students: commonstudents + }); + +}); + +const suspend = asyncErrorHandler(async (req: Request, res: Response, next: NextFunction) => { + const { student } = req.body; + + if (!student) { + return next(new CustomError('No student specified', 400)); + } + + // Check if student exists, if not, throw error + const studentExists = await prisma.student.findUnique({ + where: { email: student }, + }); + + if (!studentExists) { + return next(new CustomError('Student does not exist', 400)); + } + + await prisma.student.update({ + where: { email: student }, + data: { suspended: true }, + }); + + res.status(204).json(); +}); + +const retrievefornotifications = asyncErrorHandler(async (req: Request, res: Response, next: NextFunction) => { + const { teacher, notification } = req.body; + + if (!teacher || !notification) { + return next(new CustomError('Missing teacher or notification', 400)); + } + + // Parse the notification to find any valid @mentioned students' emails + const mentionedStudents = parseMentionedEmails(notification); + + + const mentionedStudentObjects = await prisma.student.findMany({ + where: { + suspended: false, + email: { + in: mentionedStudents, + }, + }, + }); + + const activeMentionedStudents = mentionedStudentObjects.map(student => student.email); + + const teacherExist = await prisma.teacher.findUnique({ + where: { email: teacher }, + }); + + if (!teacherExist) { + return next(new CustomError('Teacher does not exist', 400)); + } + + // Get all students who are registered with the given teacher and are not suspended + const studentsOfTeacher = await prisma.student.findMany({ + where: { + suspended: false, + teachers: { + some: { + email: teacher, + }, + }, + }, + select: { + email: true, + }, + }); + + // Combine the mentioned students and students of the teacher and remove duplicates + const recipients = [...new Set([...studentsOfTeacher.map((student) => student.email), ...activeMentionedStudents])]; + + res.status(200).json({ + status: 'success', + recipients + }); +}); + +export default { + register, + commonstudents, + suspend, + retrievefornotifications, +}; \ No newline at end of file diff --git a/src/middlewares/global-error-middleware.ts b/src/middlewares/global-error-middleware.ts new file mode 100644 index 0000000..ffd1a60 --- /dev/null +++ b/src/middlewares/global-error-middleware.ts @@ -0,0 +1,67 @@ +import { Request, Response, NextFunction } from 'express'; +import CustomError from "../utils/custom-error"; +import { Prisma } from '@prisma/client' + +const devError = (err: CustomError, res: Response) => { + res.status(err.statusCode).json({ + status: err.status, + message: err.message, + error: err, + stack: err.stack + }); +}; + +const prodError = (err: CustomError, res: Response) => { + // Production error + let error = { ...err }; + error.message = err.message; + + if (err instanceof Prisma.PrismaClientKnownRequestError) { + // The .code property can be accessed in a type-safe manner + if (err.code === 'P2002') { + const message = 'There is a unique constraint violation, a new user cannot be created with this email'; + error = new CustomError(message, 401); + } + } + + if (err.name === "JsonWebTokenError") { + const message = "Invalid token. Please log in again."; + error = new CustomError(message, 401); + } + + if (err.name === "TokenExpiredError") { + const message = "Your token has expired. Please log in again."; + error = new CustomError(message, 401); + } + + // Operational, trusted error: send message to client + if (err.isOperational) { + res.status(err.statusCode).json({ + status: err.status, + message: err.message || "Internal Server Error", + }); + + // Programming or other unknown error: don't leak error details + } else { + // 1) Log error + console.error("ERROR", err); + + // 2) Send generic message + res.status(500).json({ + status: "error", + message: "An unexpected error occurred. Please try again later." + }); + } +} + +// This is a global error handling middleware +export default (err: CustomError, req: Request, res: Response, next: NextFunction) => { + err.statusCode = err.statusCode || 500; + err.status = err.status || "error"; + + if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") { + devError(err, res); + } else if (process.env.NODE_ENV === "production") { + prodError(err, res); + } +} diff --git a/src/routes/teachers-route.ts b/src/routes/teachers-route.ts new file mode 100644 index 0000000..875c776 --- /dev/null +++ b/src/routes/teachers-route.ts @@ -0,0 +1,16 @@ +// Desc: Routes for teachers + +import express from 'express'; +import teachersController from '../controllers/teachers-controller'; + +const router = express.Router(); + +router.post('/api/register', teachersController.register); + +router.get('/api/commonstudents', teachersController.commonstudents); + +router.post('/api/suspend', teachersController.suspend); + +router.post('/api/retrievefornotifications', teachersController.retrievefornotifications); + +export default router; \ No newline at end of file diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..24be4c2 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,30 @@ +// Handling uncaught exceptions (synchronous errors) in the application. +// This will catch any exception that was thrown and not caught anywhere in the code. +// It's typically used as a last resort to log the exception and shut down the application gracefully. +process.on("uncaughtException", (err) => { + console.log(err.name, err.message); + console.log("Uncaught Exception. Shutting down..."); + + process.exit(1); // 0 for success, 1 for uncaught exception + +}); + +import app from "./app"; + +// Server configuration +const port = process.env.NODE_PORT || 3000; + +const server = app.listen(port, () => { + console.log(`Server is running on port http://localhost/${port}`); +}); + +// Handling unhandled rejections (errors outside express) in the application. +process.on("unhandledRejection", (err) => { + + console.log("Unhandled Rejection. Shutting down..."); + console.log(err); + // Shutting down the server + server.close(() => { + process.exit(1); // 0 for success, 1 for unhandled rejection + }); +}); \ No newline at end of file diff --git a/src/utils/async-error-handler.ts b/src/utils/async-error-handler.ts new file mode 100644 index 0000000..1c139b4 --- /dev/null +++ b/src/utils/async-error-handler.ts @@ -0,0 +1,16 @@ +import { Request, Response, NextFunction } from 'express'; + +// This module exports a higher-order function that wraps an asynchronous function (fn) +// with Express request, response, and next function parameters. +// The purpose of this wrapper is to handle any rejected Promises that occur within fn, +// and pass the error to the next middleware in the Express pipeline. +export default (fn: (req: Request, res: Response, next: NextFunction) => Promise) => { + + // Returns a new function that will be called when the corresponding route is hit. + return (req: Request, res: Response, next: NextFunction) => { + + // Calls the provided asynchronous function (fn) and catches any errors. + // If an error occurs, it's passed to the next middleware function in the Express pipeline. + fn(req, res, next).catch((err: Error) => next(err)); + }; +}; diff --git a/src/utils/custom-error.ts b/src/utils/custom-error.ts new file mode 100644 index 0000000..e9c4ef2 --- /dev/null +++ b/src/utils/custom-error.ts @@ -0,0 +1,13 @@ +export default class CustomError extends Error { + statusCode: number; + status: string; + isOperational: boolean; + constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + this.status = `${statusCode}`.startsWith("4") ? "fail" : "error"; // 400 or 500 + this.isOperational = true; // Operational errors are errors that we can predict and handle + + Error.captureStackTrace(this, this.constructor); // This will not appear in the stack trace. + } +} \ No newline at end of file diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000..bed269a --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,15 @@ +// Desc: This file contains utility functions + +// Desc: This function checks if an email is valid +export function isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // A basic email format validation + return emailRegex.test(email); +} + +// Desc: This function parses the mentioned emails from a text +export function parseMentionedEmails(text: string): string[] { + const matches = text.match(/@\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+/g) || []; + return matches + .map((email: string) => email.substring(1)) // Remove the @ symbol + .filter(email => isValidEmail(email)); // Filter only valid emails +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5cfa348 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,103 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}