diff --git a/.env.example b/.env.example deleted file mode 100644 index 932b9f1e..00000000 --- a/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -PORT=4000 -DATABASE_URL="?schema=prisma" -SHADOW_DATABASE_URL="?schema=shadow" -JWT_SECRET="somesecurestring" -JWT_EXPIRY="24h" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56cddc0a..1add38cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,5 +13,5 @@ jobs: with: node-version: 'lts/*' - run: npm ci - - run: npx eslint src - - run: npx prisma migrate reset --force --skip-seed + - run: npm run lint + - run: npm run db-reset diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 diff --git a/Dockerfile b/Dockerfile index b0c8ba77..31f6c1b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,4 +33,6 @@ WORKDIR /app ENV NODE_ENV production ENV PATH /root/.volta/bin:$PATH +RUN npm run migrate + CMD [ "npm", "run", "start" ] diff --git a/README.md b/README.md index 1bd07ee4..571cff38 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Once you have complete the above guide, continue to the steps below. ## API Spec [TODO]: -[Deployed API Spec](https://UPDATEME) +[Deployed API Spec](https://boolean-team-dev-server.fly.dev/api-docs/) The API Spec is hosted by the server itself (i.e. this project), and the view/page is generated automatically by the SwaggerUI libraryi. diff --git a/docs/openapi.yml b/docs/openapi.yml index 5f2a05f2..7f395971 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: Team Dev Server API description: |- - version: 1.0 + version: '1.0' servers: - url: http://localhost:4000/ @@ -35,15 +35,15 @@ paths: get: tags: - user - summary: Get all users by first name if provided + summary: Get all users by name if provided description: '' operationId: getAllUsers security: - bearerAuth: [] parameters: - - name: firstName + - name: name in: query - description: Search all users by first name if provided (case-sensitive and exact string matches only) + description: Search all users by name if provided. Name is case insensitive and will return matches for both first name and last name. schema: type: string responses: @@ -65,6 +65,152 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + + /teachers: + get: + tags: + - teacher + summary: Get all teachers + description: '' + operationId: getAllTeachers + security: + - bearerAuth: [] + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/AllTeachers' + '400': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /teachers/:id: + get: + tags: + - teacher + summary: Get a teacher by id + description: '' + operationId: getTeacherById + security: + - bearerAuth: [] + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Teacher' + '400': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /teachers/me: + get: + tags: + - teacher + summary: Get a teacher by userId + description: '' + operationId: getTeacherByUserId + security: + - bearerAuth: [] + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Teacher' + '400': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /students: + get: + tags: + - student + summary: Get all students + description: '' + operationId: getAllStudents + security: + - bearerAuth: [] + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/AllStudents' + '400': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /students/me: + get: + tags: + - student + summary: Get a student by userId + description: '' + operationId: getStudentByUserId + security: + - bearerAuth: [] + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Student' + '400': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /login: post: tags: @@ -164,6 +310,83 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + + put: + tags: + - user + summary: Update a user profile + description: Allows a user to complete profile details after registering for the first time. + operationId: userUpdate + security: + - bearerAuth: [] + parameters: + - name: id + in: path + description: 'The user id of the profile' + required: true + schema: + type: string + requestBody: + description: The profile info + content: + application/json: + schema: + $ref: '#/components/schemas/UserProfile' + responses: + '201': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatedProfile' + '404': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /users/profile/{id}: + get: + tags: + - user + summary: Get user profile by user id + description: '' + operationId: getUserProfile + security: + - bearerAuth: [] + parameters: + - name: id + in: path + description: 'The id of the user profile required' + required: true + schema: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + properties: + status: + type: string + data: + $ref: '#/components/schemas/User' + '404': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /posts: post: tags: @@ -183,13 +406,73 @@ paths: content: type: string responses: - 201: + '201': description: success content: application/json: schema: $ref: '#/components/schemas/Post' - 400: + '400': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + put: + tags: + - post + summary: Update post + description: you can update your own post + operationId: editPost + security: + - bearerAuth: [] + requestBody: + description: Updated post object + content: + application/json: + schema: + type: object + properties: + content: + type: string + responses: + '200': + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '400': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + tags: + - post + summary: Delete post + description: If you are user with role "STUDENT" you can delete only own post. If you are user with role "TEACHER" you will be able to delete any posts. + operationId: deletePost + security: + - bearerAuth: [] + requestBody: + description: Updated post object + content: + application/json: + schema: + type: object + properties: + content: + type: string + responses: + '200': + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '500': description: fail content: application/json: @@ -204,13 +487,55 @@ paths: security: - bearerAuth: [] responses: - '200': + 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/Posts' - '401': + 401: + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /notes: + post: + tags: + - note + summary: Create note + description: Can only be done by users with the teacher role + operationId: postNote + security: + - bearerAuth: [] + requestBody: + description: Created note object + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: Outstanding use of commas + content: + type: string + example: This student's mastery over using commas is unlike anything I've ever seen. To the extent I never want to use a comma again. + studentUserId: + type: integer + example: 1 + teacherUserId: + type: integer + example: 3 + responses: + '201': + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/Note' + '400': description: fail content: application/json: @@ -256,6 +581,7 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /cohorts: post: tags: @@ -266,26 +592,118 @@ paths: security: - bearerAuth: [] responses: - 201: + 201: + description: success + content: + application/json: + schema: + type: object + properties: + status: + type: string + data: + properties: + cohort: + $ref: '#/components/schemas/Cohort' + 400: + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + put: + tags: + - cohort + summary: change a student's cohort + description: This can only be done by the logged in user with role TEACHER. + operationId: changeStudentCohort + security: + - bearerAuth: [] + responses: + 200: + description: success + content: + application/json: + schema: + type: object + properties: + status: + type: string + data: + properties: + cohort: + $ref: '#/components/schemas/Cohort' + get: + tags: + - cohort + summary: Get a list of all the /cohorts + description: This can only be done is the logged in user has a role of TEACHER + operationId: getCohorts + security: + - bearerAuth: [] + responses: + '200': + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/Cohorts' + + /comments: + get: + tags: + - comments + summary: Retrieve comments + description: only registered users can view these + operationId: getComments + security: + - bearerAuth: [] + responses: + '200': + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/Comments' + post: + tags: + - comments + summary: Create a new comment + description: only registered users can do this + operationId: postComment + security: + - bearerAuth: [] + responses: + '201': description: success content: application/json: schema: - type: object - properties: - status: - type: string - data: - properties: - cohort: - $ref: '#/components/schemas/Cohort' - 400: + $ref: '#/components/schemas/Comment' + '400': description: fail content: application/json: schema: $ref: '#/components/schemas/Error' + /comments/{postId}: + get: + tags: + - comments + summary: Retrieve comments by post id + description: only registered users can view these + operationId: getCommentsByPostId + security: + - bearerAuth: [] + responses: + '200': + description: success + content: + application/json: + schema: + $ref: '#/components/schemas/Comments' + components: securitySchemes: bearerAuth: @@ -312,6 +730,16 @@ components: properties: id: type: integer + name: + type: string + format: string + departmentId: + type: integer + department: + type: object + properties: + name: + type: string createdAt: type: string format: string @@ -319,6 +747,15 @@ components: type: string format: string + Cohorts: + type: object + properties: + status: + type: string + data: + type: object + $ref: '#/components/schemas/Cohort' + AllUsers: type: object properties: @@ -332,6 +769,106 @@ components: items: $ref: '#/components/schemas/User' + AllTeachers: + type: object + properties: + status: + type: string + data: + type: object + properties: + teachers: + type: array + items: + type: object + properties: + id: + type: integer + userId: + type: integer + departmentId: + type: integer + createdAt: + type: string + updatedAt: + type: string + user: + type: object + department: + type: object + + Teacher: + type: object + properties: + status: + type: string + data: + type: object + properties: + id: + type: integer + userId: + type: integer + departmentId: + type: integer + createdAt: + type: string + updatedAt: + type: string + department: + type: object + + AllStudents: + type: object + properties: + status: + type: string + data: + type: object + properties: + students: + type: array + items: + type: object + properties: + id: + type: integer + userId: + type: integer + cohortId: + type: integer + createdAt: + type: string + updatedAt: + type: string + user: + type: object + title: + type: object + + Student: + type: object + properties: + status: + type: string + data: + type: object + properties: + id: + type: integer + userId: + type: integer + cohortId: + type: integer + createdAt: + type: string + updatedAt: + type: string + user: + type: object + title: + type: object + User: type: object properties: @@ -351,6 +888,8 @@ components: type: string githubUrl: type: string + imageUrl: + type: string CreateUser: type: object @@ -367,6 +906,8 @@ components: type: string password: type: string + imageUrl: + type: string UpdateUser: type: object @@ -387,6 +928,38 @@ components: type: string githubUrl: type: string + imageUrl: + type: string + + UserProfile: + type: object + properties: + firstName: + type: string + lastName: + type: string + githubUrl: + type: string + imageUrl: + type: string + bio: + type: string + role: + type: string + specialism: + type: string + cohort: + type: string + startDate: + type: string + endDate: + type: string + email: + type: string + mobile: + type: string + password: + type: string Posts: type: object @@ -405,11 +978,6 @@ components: type: integer content: type: string - createdAt: - type: string - format: string - updatedAt: - type: string format: string author: type: object @@ -430,7 +998,137 @@ components: type: string profileImageUrl: type: string - + likes: + type: array + items: + type: object + properties: + id: + type: integer + userId: + type: integer + postId: + type: integer + createdAt: + type: string + format: string + updatedAt: + type: string + + Comments: + type: object + properties: + status: + type: string + data: + type: object + properties: + comments: + type: array + items: + type: object + properties: + id: + type: integer + content: + type: string + postId: + type: integer + userId: + type: integer + author: + type: object + properties: + firstName: + type: string + lastName: + type: string + createdAt: + type: string + format: string + updatedAt: + type: string + format: string + + Comment: + type: object + properties: + status: + type: string + data: + type: object + properties: + comment: + type: object + properties: + id: + type: integer + content: + type: string + postId: + type: integer + userId: + type: integer + author: + type: object + properties: + firstName: + type: string + lastName: + type: string + createdAt: + type: string + format: string + updatedAt: + type: string + format: string + + Note: + type: object + properties: + status: + type: string + example: success + data: + type: object + properties: + note: + type: object + properties: + id: + type: integer + example: 1 + title: + type: string + example: Outstanding use of commas + content: + type: string + example: This student's mastery over using commas is unlike anything I've ever seen. To the extent I never want to use a comma again. + student: + type: object + properties: + userId: + type: integer + example: 1 + firstName: + type: string + example: Joe + lastName: + type: string + example: Bloggs + teacher: + type: object + properties: + userId: + type: integer + example: 3 + firstName: + type: string + example: Rick + lastName: + type: string + example: Sanchez + CreatedUser: type: object properties: @@ -457,6 +1155,7 @@ components: type: string githubUrl: type: string + login: type: object properties: @@ -492,6 +1191,7 @@ components: type: string githubUrl: type: string + Error: type: object properties: @@ -535,3 +1235,44 @@ components: type: integer content: type: string + + UpdatedProfile: + type: object + properties: + status: + type: string + example: success + data: + properties: + profile: + properties: + id: + type: integer + userId: + type: integer + firstName: + type: string + lastName: + type: string + githubUrl: + type: string + imageUrl: + type: string + bio: + type: string + role: + type: string + specialism: + type: string + cohort: + type: string + startDate: + type: string + endDate: + type: string + email: + type: string + mobile: + type: string + password: + type: string diff --git a/fly.toml b/fly.toml index eac058e7..09f71d04 100644 --- a/fly.toml +++ b/fly.toml @@ -1,9 +1,12 @@ -# fly.toml file generated for team-dev-backend-api on 2022-11-30T11:30:34Z +# fly.toml app configuration file generated for boolean-team-dev-server on 2024-02-02T17:36:23+01:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# -app = "team-dev-backend-api" +app = 'boolean-team-dev-server' kill_signal = "SIGINT" kill_timeout = 5 -processes = [] +primary_region = "lhr" [env] PORT = "8080" @@ -12,9 +15,6 @@ processes = [] allowed_public_ports = [] auto_rollback = true -[deploy] - release_command = "npx prisma migrate deploy" - [[services]] http_checks = [] internal_port = 8080 diff --git a/package-lock.json b/package-lock.json index 044145e5..52f48126 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "dotenv": "^16.0.0", "express": "^4.17.3", "jsonwebtoken": "^8.5.1", + "morgan": "^1.10.0", "swagger-ui-express": "^5.0.0", "yaml": "^2.3.4" }, @@ -27,7 +28,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-promise": "^5.1.0", - "husky": "^7.0.4", + "husky": "^9.0.10", "nodemon": "^2.0.15", "prettier": "^2.6.2", "prisma": "^3.12.0" @@ -431,6 +432,22 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/bcrypt": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.0.1.tgz", @@ -1766,15 +1783,15 @@ } }, "node_modules/husky": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/husky/-/husky-7.0.4.tgz", - "integrity": "sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==", + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.0.10.tgz", + "integrity": "sha512-TQGNknoiy6bURzIO77pPRu+XHi6zI7T93rX+QnJsoYFf3xdjKOur+IlfqzJGMHIK/wXrLg+GsvMs8Op7vI2jVA==", "dev": true, "bin": { - "husky": "lib/bin.js" + "husky": "bin.mjs" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/typicode" @@ -2386,6 +2403,45 @@ "node": ">=10" } }, + "node_modules/morgan": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", + "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -2601,6 +2657,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3887,6 +3951,21 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "requires": { + "safe-buffer": "5.1.2" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, "bcrypt": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.0.1.tgz", @@ -4894,9 +4973,9 @@ } }, "husky": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/husky/-/husky-7.0.4.tgz", - "integrity": "sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==", + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.0.10.tgz", + "integrity": "sha512-TQGNknoiy6bURzIO77pPRu+XHi6zI7T93rX+QnJsoYFf3xdjKOur+IlfqzJGMHIK/wXrLg+GsvMs8Op7vI2jVA==", "dev": true }, "iconv-lite": { @@ -5349,6 +5428,41 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" }, + "morgan": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", + "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "requires": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.0.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "requires": { + "ee-first": "1.1.1" + } + } + } + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -5500,6 +5614,11 @@ "ee-first": "1.1.1" } }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index 41e19e5e..9702f36e 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,10 @@ "scripts": { "start": "node src/index.js", "dev": "nodemon src/index.js", - "prepare": "husky install", - "db-reset": "prisma migrate reset" + "prepare": "npx husky install", + "lint": "eslint src", + "migrate": "prisma migrate deploy", + "db-reset": "prisma migrate reset --force --skip-seed" }, "prisma": { "seed": "node prisma/seed.js" @@ -33,7 +35,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-promise": "^5.1.0", - "husky": "^7.0.4", + "husky": "^9.0.10", "nodemon": "^2.0.15", "prettier": "^2.6.2", "prisma": "^3.12.0" @@ -45,6 +47,7 @@ "dotenv": "^16.0.0", "express": "^4.17.3", "jsonwebtoken": "^8.5.1", + "morgan": "^1.10.0", "swagger-ui-express": "^5.0.0", "yaml": "^2.3.4" } diff --git a/prisma/migrations/20240206161737_test/migration.sql b/prisma/migrations/20240206161737_test/migration.sql new file mode 100644 index 00000000..af5102c8 --- /dev/null +++ b/prisma/migrations/20240206161737_test/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file diff --git a/prisma/migrations/20240208100112_add_new_model_comment/migration.sql b/prisma/migrations/20240208100112_add_new_model_comment/migration.sql new file mode 100644 index 00000000..a96e5151 --- /dev/null +++ b/prisma/migrations/20240208100112_add_new_model_comment/migration.sql @@ -0,0 +1,48 @@ +/* + Warnings: + + - Added the required column `updatedAt` to the `Cohort` table without a default value. This is not possible if the table is not empty. + - Added the required column `updatedAt` to the `DeliveryLog` table without a default value. This is not possible if the table is not empty. + - Added the required column `updatedAt` to the `DeliveryLogLine` table without a default value. This is not possible if the table is not empty. + - Added the required column `updatedAt` to the `Post` table without a default value. This is not possible if the table is not empty. + - Added the required column `updatedAt` to the `Profile` table without a default value. This is not possible if the table is not empty. + - Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Cohort" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; + +-- AlterTable +ALTER TABLE "DeliveryLog" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; + +-- AlterTable +ALTER TABLE "DeliveryLogLine" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; + +-- AlterTable +ALTER TABLE "Post" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "likes" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; + +-- AlterTable +ALTER TABLE "Profile" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; + +-- CreateTable +CREATE TABLE "Comment" ( + "id" SERIAL NOT NULL, + "postId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Comment_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20240208101235_add_fields_to_the_comment_model/migration.sql b/prisma/migrations/20240208101235_add_fields_to_the_comment_model/migration.sql new file mode 100644 index 00000000..f181eada --- /dev/null +++ b/prisma/migrations/20240208101235_add_fields_to_the_comment_model/migration.sql @@ -0,0 +1,13 @@ +/* + Warnings: + + - Added the required column `content` to the `Comment` table without a default value. This is not possible if the table is not empty. + - Added the required column `userId` to the `Comment` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Comment" ADD COLUMN "content" TEXT NOT NULL, +ADD COLUMN "userId" INTEGER NOT NULL; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20240208111427_create_new_model_like/migration.sql b/prisma/migrations/20240208111427_create_new_model_like/migration.sql new file mode 100644 index 00000000..2677d2b9 --- /dev/null +++ b/prisma/migrations/20240208111427_create_new_model_like/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - You are about to drop the column `likes` on the `Post` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Post" DROP COLUMN "likes"; + +-- CreateTable +CREATE TABLE "Like" ( + "id" SERIAL NOT NULL, + "postId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Like_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Like" ADD CONSTRAINT "Like_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Like" ADD CONSTRAINT "Like_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20240212125430_cohort_name_property/migration.sql b/prisma/migrations/20240212125430_cohort_name_property/migration.sql new file mode 100644 index 00000000..7ca75017 --- /dev/null +++ b/prisma/migrations/20240212125430_cohort_name_property/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `name` to the `Cohort` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Cohort" ADD COLUMN "name" TEXT NOT NULL; diff --git a/prisma/migrations/20240213132101_add_cascade_delete_to_posts_comments/migration.sql b/prisma/migrations/20240213132101_add_cascade_delete_to_posts_comments/migration.sql new file mode 100644 index 00000000..cab7e692 --- /dev/null +++ b/prisma/migrations/20240213132101_add_cascade_delete_to_posts_comments/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "Comment" DROP CONSTRAINT "Comment_postId_fkey"; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20240214120743_add_teacher/migration.sql b/prisma/migrations/20240214120743_add_teacher/migration.sql new file mode 100644 index 00000000..149adc61 --- /dev/null +++ b/prisma/migrations/20240214120743_add_teacher/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Teacher" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "department" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Teacher_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Teacher_userId_key" ON "Teacher"("userId"); + +-- AddForeignKey +ALTER TABLE "Teacher" ADD CONSTRAINT "Teacher_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20240214154916_add_department_model/migration.sql b/prisma/migrations/20240214154916_add_department_model/migration.sql new file mode 100644 index 00000000..5aeb9a9d --- /dev/null +++ b/prisma/migrations/20240214154916_add_department_model/migration.sql @@ -0,0 +1,33 @@ +/* + Warnings: + + - You are about to drop the column `department` on the `Teacher` table. All the data in the column will be lost. + - Added the required column `departmentId` to the `Cohort` table without a default value. This is not possible if the table is not empty. + - Added the required column `departmentId` to the `Teacher` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Cohort" ADD COLUMN "departmentId" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "Teacher" DROP COLUMN "department", +ADD COLUMN "departmentId" INTEGER NOT NULL; + +-- CreateTable +CREATE TABLE "Department" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Department_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Department_name_key" ON "Department"("name"); + +-- AddForeignKey +ALTER TABLE "Teacher" ADD CONSTRAINT "Teacher_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Cohort" ADD CONSTRAINT "Cohort_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20240215152246_student_model/migration.sql b/prisma/migrations/20240215152246_student_model/migration.sql new file mode 100644 index 00000000..ffed63c1 --- /dev/null +++ b/prisma/migrations/20240215152246_student_model/migration.sql @@ -0,0 +1,32 @@ +/* + Warnings: + + - You are about to drop the column `cohortId` on the `User` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "User" DROP CONSTRAINT "User_cohortId_fkey"; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "cohortId"; + +-- CreateTable +CREATE TABLE "Student" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "cohortId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Student_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Student_userId_key" ON "Student"("userId"); + +-- AddForeignKey +ALTER TABLE "Student" ADD CONSTRAINT "Student_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Student" ADD CONSTRAINT "Student_cohortId_fkey" FOREIGN KEY ("cohortId") REFERENCES "Cohort"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20240215164032_add/migration.sql b/prisma/migrations/20240215164032_add/migration.sql new file mode 100644 index 00000000..6d58e6ff --- /dev/null +++ b/prisma/migrations/20240215164032_add/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Profile" ADD COLUMN "imageUrl" TEXT; diff --git a/prisma/migrations/20240220160214_profile/migration.sql b/prisma/migrations/20240220160214_profile/migration.sql new file mode 100644 index 00000000..f364982f --- /dev/null +++ b/prisma/migrations/20240220160214_profile/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Profile" ADD COLUMN "cohort" TEXT, +ADD COLUMN "email" TEXT, +ADD COLUMN "endDate" TEXT, +ADD COLUMN "mobile" TEXT, +ADD COLUMN "password" TEXT, +ADD COLUMN "role" TEXT, +ADD COLUMN "specialism" TEXT, +ADD COLUMN "startDate" TEXT; diff --git a/prisma/migrations/20240220163315_add_note_model/migration.sql b/prisma/migrations/20240220163315_add_note_model/migration.sql new file mode 100644 index 00000000..99f59bbe --- /dev/null +++ b/prisma/migrations/20240220163315_add_note_model/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Note" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "studentId" INTEGER NOT NULL, + "teacherId" INTEGER NOT NULL, + + CONSTRAINT "Note_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Note" ADD CONSTRAINT "Note_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Note" ADD CONSTRAINT "Note_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "Teacher"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20240221004845_add_cascade_deleting_for_likes/migration.sql b/prisma/migrations/20240221004845_add_cascade_deleting_for_likes/migration.sql new file mode 100644 index 00000000..dfdb21c3 --- /dev/null +++ b/prisma/migrations/20240221004845_add_cascade_deleting_for_likes/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "Like" DROP CONSTRAINT "Like_postId_fkey"; + +-- AddForeignKey +ALTER TABLE "Like" ADD CONSTRAINT "Like_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20240222173653_add_tba_to_role_enum/migration.sql b/prisma/migrations/20240222173653_add_tba_to_role_enum/migration.sql new file mode 100644 index 00000000..f757d97d --- /dev/null +++ b/prisma/migrations/20240222173653_add_tba_to_role_enum/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "Role" ADD VALUE 'TBA'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 72ec5632..3ac79b32 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,56 +14,148 @@ datasource db { enum Role { STUDENT TEACHER + TBA } model User { - id Int @id @default(autoincrement()) - email String @unique - password String - role Role @default(STUDENT) - profile Profile? - cohortId Int? - cohort Cohort? @relation(fields: [cohortId], references: [id]) - posts Post[] - deliveryLogs DeliveryLog[] + id Int @id @default(autoincrement()) + email String @unique + password String + role Role @default(STUDENT) + profile Profile? + posts Post[] + comments Comment[] + likes Like[] + deliveryLogs DeliveryLog[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + Teacher Teacher? + Student Student? +} + +model Student { + id Int @id @default(autoincrement()) + title String + userId Int @unique + user User @relation(fields: [userId], references: [id]) + cohortId Int? + cohort Cohort? @relation(fields: [cohortId], references: [id]) + notes Note[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Profile { - id Int @id @default(autoincrement()) - userId Int @unique - user User @relation(fields: [userId], references: [id]) - firstName String - lastName String - bio String? - githubUrl String? + id Int @id @default(autoincrement()) + userId Int @unique + user User @relation(fields: [userId], references: [id]) + firstName String + lastName String + bio String? + githubUrl String? + imageUrl String? + role String? + specialism String? + cohort String? + startDate String? + endDate String? + email String? + mobile String? + password String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Teacher { + id Int @id @default(autoincrement()) + userId Int @unique + user User @relation(fields: [userId], references: [id]) + departmentId Int + department Department @relation(fields: [departmentId], references: [id]) + notes Note[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Cohort { - id Int @id @default(autoincrement()) - users User[] - deliveryLogs DeliveryLog[] + id Int @id @default(autoincrement()) + name String + students Student[] + departmentId Int + department Department @relation(fields: [departmentId], references: [id]) + deliveryLogs DeliveryLog[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Department { + id Int @id @default(autoincrement()) + name String @unique + teachers Teacher[] + cohorts Cohort[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Post { - id Int @id @default(autoincrement()) - content String - userId Int - user User @relation(fields: [userId], references: [id]) + id Int @id @default(autoincrement()) + content String + userId Int + user User @relation(fields: [userId], references: [id]) + comments Comment[] + likes Like[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Like { + id Int @id @default(autoincrement()) + postId Int + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + userId Int + user User @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Comment { + id Int @id @default(autoincrement()) + content String + postId Int + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + userId Int + user User @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model DeliveryLog { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) date DateTime userId Int - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) cohortId Int - cohort Cohort @relation(fields: [cohortId], references: [id]) + cohort Cohort @relation(fields: [cohortId], references: [id]) lines DeliveryLogLine[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model DeliveryLogLine { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) content String logId Int - log DeliveryLog @relation(fields: [logId], references: [id]) + log DeliveryLog @relation(fields: [logId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Note { + id Int @id @default(autoincrement()) + title String + content String + studentId Int + student Student @relation(fields: [studentId], references: [id], onDelete: Cascade) + teacherId Int + teacher Teacher @relation(fields: [teacherId], references: [id]) } diff --git a/prisma/seed.js b/prisma/seed.js index e6c288f2..2c6792ce 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -1,74 +1,237 @@ import { PrismaClient } from '@prisma/client' import bcrypt from 'bcrypt' -const prisma = new PrismaClient(); +const prisma = new PrismaClient() async function seed() { - const cohort = await createCohort() + const department1 = await createDepartment('Software Development') + const department2 = await createDepartment('Data Analytics') + const cohort1 = await createCohort('Cohort 1', department1) + const cohort2 = await createCohort('Cohort 2', department2) + await createCohort('Cohort 3', department1) - const student = await createUser('student@test.com', 'Testpassword1!', cohort.id, 'Joe', 'Bloggs', 'Hello, world!', 'student1') - const teacher = await createUser('teacher@test.com', 'Testpassword1!', null, 'Rick', 'Sanchez', 'Hello there!', 'teacher1', 'TEACHER') + const student1 = await createUserWithRole( + 'student@test.com', + 'Testpassword1!', + 'STUDENT', + cohort1.id, + 'Joe', + 'Bloggs', + 'Hello, world!', + 'https://github.com/student1', + 'Software Developer' + ) + await createUserWithRole( + 'student2@test.com', + 'Testpassword1!', + 'STUDENT', + cohort2.id, + 'Lee', + 'Dev', + 'Hello, world!', + 'https://github.com/student1', + 'Data Analyst' + ) + // Creating teacher users with specific departments + const teacher1 = await createUserWithRole( + 'teacher@test.com', + 'Testpassword1!', + 'TEACHER', + null, + 'Rick', + 'Sanchez', + 'Wubba Lubba Dub Dub!', + 'https://github.com/rick', + null, + department1 + ) + await createUserWithRole( + 'teacher2@test.com', + 'Testpassword1!', + 'TEACHER', + null, + 'Max', + 'Smith', + 'Hello there', + 'https://github.com/max', + null, + department2 + ) + await createPost( + student1.id, + 'My first post!', + [ + { content: 'hi', userId: 2 }, + { content: "'sup?", userId: 2 } + ], + [{ userId: 2 }] + ) + await createPost(teacher1.id, 'Hello, students', [], [{ userId: 1 }]) - await createPost(student.id, 'My first post!') - await createPost(teacher.id, 'Hello, students') + await createNote( + student1.id, + teacher1.id, + 'note on student 1', + 'they be learnin' + ) - process.exit(0); + process.exit(0) } -async function createPost(userId, content) { - const post = await prisma.post.create({ - data: { - userId, - content - }, - include: { - user: true - } - }) +async function createPost(userId, content, comments, likes) { + const post = await prisma.post.create({ + data: { + userId, + content, + comments: { + create: comments + }, + likes: { + create: likes + } + }, + include: { + user: true, + comments: true, + likes: true + } + }) - console.info('Post created', post) + console.info('Post created', post) - return post + return post } -async function createCohort() { - const cohort = await prisma.cohort.create({ - data: {} - }) +async function createNote(studentUserId, teacherUserId, title, content) { + return await prisma.note.create({ + data: { + title, + content, + student: { connect: { userId: studentUserId } }, + teacher: { connect: { userId: teacherUserId } } + } + }) +} + +async function createDepartment(name) { + const department = await prisma.department.create({ + data: { + name + } + }) + console.info('Department created', department) + return department +} + +async function createCohort(name, department) { + const cohort = await prisma.cohort.create({ + data: { + name, + department: { + connect: { + id: department.id + } + } + }, + include: { + students: true, + department: true + } + }) - console.info('Cohort created', cohort) + console.info('Cohort created', cohort) - return cohort + return cohort } -async function createUser(email, password, cohortId, firstName, lastName, bio, githubUrl, role = 'STUDENT') { - const user = await prisma.user.create({ - data: { - email, - password: await bcrypt.hash(password, 8), - role, - cohortId, - profile: { - create: { - firstName, - lastName, - bio, - githubUrl - } - } +async function createUserWithRole( + email, + password, + role, + cohortId, + firstName, + lastName, + bio, + githubUrl, + title, + department +) { + const hashedPassword = await bcrypt.hash(password, 10) + const userData = { + email, + password: hashedPassword, + role, + profile: { + create: { + firstName, + lastName, + bio, + githubUrl + } + } + } + + const user = await prisma.user.create({ + data: userData, + include: { + profile: true + } + }) + + if (role === 'TEACHER' && department) { + await prisma.teacher.create({ + data: { + user: { + connect: { + id: user.id + } }, - include: { - profile: true + department: { + connect: { + id: department.id + } + } + }, + include: { + department: { + select: { + name: true + } } + } }) + } - console.info(`${role} created`, user) + if (role === 'STUDENT') { + await prisma.student.create({ + data: { + user: { + connect: { + id: user.id + } + }, + cohort: { + connect: { + id: cohortId + } + }, + title + }, + include: { + cohort: { + select: { + name: true + } + } + } + }) + } - return user + console.info(`${role} created:`, user.email) + return user } -seed() - .catch(async e => { - console.error(e); - await prisma.$disconnect(); - process.exit(1) - }) \ No newline at end of file +seed().catch(async (e) => { + console.error(e) + await prisma.$disconnect() + process.exit(1) +}) diff --git a/src/controllers/auth.js b/src/controllers/auth.js index 9a376a73..77494ac5 100644 --- a/src/controllers/auth.js +++ b/src/controllers/auth.js @@ -9,7 +9,7 @@ export const login = async (req, res) => { if (!email) { return sendDataResponse(res, 400, { - email: 'Invalid email and/or password provided' + error: 'Invalid email and/or password provided' }) } @@ -18,21 +18,22 @@ export const login = async (req, res) => { const areCredentialsValid = await validateCredentials(password, foundUser) if (!areCredentialsValid) { - return sendDataResponse(res, 400, { - email: 'Invalid email and/or password provided' + return sendDataResponse(res, 401, { + error: 'Invalid email and/or password provided' }) } - const token = generateJwt(foundUser.id) + const token = generateJwt(foundUser.id, foundUser.role) return sendDataResponse(res, 200, { token, ...foundUser.toJSON() }) } catch (e) { + console.log('Error Login:', e) return sendMessageResponse(res, 500, 'Unable to process request') } } -function generateJwt(userId) { - return jwt.sign({ userId }, JWT_SECRET, { expiresIn: JWT_EXPIRY }) +function generateJwt(userId, userRole = 'STUDENT') { + return jwt.sign({ userId, userRole }, JWT_SECRET, { expiresIn: JWT_EXPIRY }) } async function validateCredentials(password, user) { diff --git a/src/controllers/cohort.js b/src/controllers/cohort.js index cc39365b..12c41b4b 100644 --- a/src/controllers/cohort.js +++ b/src/controllers/cohort.js @@ -1,4 +1,5 @@ -import { createCohort } from '../domain/cohort.js' +import { createCohort, Cohort } from '../domain/cohort.js' +import Student from '../domain/student.js' import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' export const create = async (req, res) => { @@ -10,3 +11,54 @@ export const create = async (req, res) => { return sendMessageResponse(res, 500, 'Unable to create cohort') } } + +export const getCohorts = async (req, res) => { + try { + const foundCohorts = await Cohort.getAll() + return sendDataResponse(res, 200, foundCohorts) + } catch (e) { + console.log('Error retrieving cohorts', e) + return sendMessageResponse(res, 500, 'Unable to get the list of cohorts') + } +} + +export const getStudentsByCohortId = async (req, res) => { + const cohortId = parseInt(req.params.id) + + if (!cohortId) { + return sendMessageResponse(res, 400, { error: 'No provided cohort ID' }) + } + + try { + const foundCohort = await Cohort.getAll() + + const hasCohort = foundCohort.find((cohort) => cohort.id === cohortId) + + if (!hasCohort) { + return sendMessageResponse(res, 404, 'No cohort found with provided ID') + } + + const foundStudents = await Student.getAllStudentsByCohortId(cohortId) + + return res.status(200).send({ students: foundStudents }) + } catch (e) { + console.log('Error getting cohort by ID:', e) + return sendMessageResponse(res, 500, 'Unable to get students') + } +} + +export const changeStudentCohort = async (req, res) => { + const { studentId, newCohortId } = req.body + + if (!studentId || !newCohortId) { + return sendMessageResponse(res, 400, 'Missing student ID or new cohort ID') + } + + try { + await Student.changeCohort(studentId, newCohortId) + return sendMessageResponse(res, 200, 'Student cohort changed successfully') + } catch (e) { + console.error('Error changing student cohort:', e) + return sendMessageResponse(res, 500, 'Internal server error') + } +} diff --git a/src/controllers/comment.js b/src/controllers/comment.js new file mode 100644 index 00000000..3561fa6e --- /dev/null +++ b/src/controllers/comment.js @@ -0,0 +1,25 @@ +import Comment from '../domain/comment.js' +import { sendDataResponse } from '../utils/responses.js' + +export const getComments = async (req, res) => { + const comments = await Comment.getAll() + return sendDataResponse(res, 200, { comments }) +} + +export const createComment = async (req, res) => { + const json = req.body + json.userId = req.user.id + + const commentToCreate = await Comment.fromJson(req.body) + const createdComment = await commentToCreate.save() + + return sendDataResponse(res, 201, createdComment) +} + +export const getCommentsByPost = async (req, res) => { + const postId = Number(req.params.postId) + + const comments = await Comment.getCommentsByPostId(postId) + + return sendDataResponse(res, 200, { comments }) +} diff --git a/src/controllers/note.js b/src/controllers/note.js new file mode 100644 index 00000000..099d549f --- /dev/null +++ b/src/controllers/note.js @@ -0,0 +1,41 @@ +import Note from '../domain/note.js' +import Student from '../domain/student.js' +import Teacher from '../domain/teachers.js' + +import { sendDataResponse } from '../utils/responses.js' + +export const createNote = async (req, res) => { + const { title, content, studentUserId, teacherUserId } = req.body + + try { + await Student.findByUserId(studentUserId) + } catch (error) { + return sendDataResponse(res, 400, { + error: 'Student not found', + status: 400 + }) + } + + try { + await Teacher.findByUserId(teacherUserId) + } catch (error) { + return sendDataResponse(res, 400, { + error: 'Teacher not found', + status: 400 + }) + } + + try { + const createdNote = await Note.create( + title, + content, + studentUserId, + teacherUserId + ) + + return sendDataResponse(res, 201, createdNote) + } catch (error) { + console.error('Error creating note:', error) + return sendDataResponse(res, 500, { error: 'Something went wrong' }) + } +} diff --git a/src/controllers/post.js b/src/controllers/post.js index 7b168039..4ede9dca 100644 --- a/src/controllers/post.js +++ b/src/controllers/post.js @@ -1,28 +1,72 @@ +import Post from '../domain/post.js' import { sendDataResponse } from '../utils/responses.js' export const create = async (req, res) => { const { content } = req.body + const userId = req.user.id if (!content) { return sendDataResponse(res, 400, { content: 'Must provide content' }) } - return sendDataResponse(res, 201, { post: { id: 1, content } }) + try { + const post = await Post.create(content, userId) + return sendDataResponse(res, 201, post) + } catch (e) { + console.error('error creating post', e.message) + return sendDataResponse(res, 500, 'something went wrong') + } } export const getAll = async (req, res) => { - return sendDataResponse(res, 200, { - posts: [ - { - id: 1, - content: 'Hello world!', - author: { ...req.user } - }, - { - id: 2, - content: 'Hello from the void!', - author: { ...req.user } - } - ] - }) + const posts = await Post.getAll() + return sendDataResponse(res, 200, { posts }) +} + +export const deletePost = async (req, res) => { + const postId = req.post.id + + const deletedPost = await Post.deleteById(postId) + + return sendDataResponse(res, 200, deletedPost) +} + +export const editPost = async (req, res) => { + const postId = Number(req.params.postId) + const { content } = req.body + const userId = req.user.id + + if (!postId) { + console.error('postId is required') + return sendDataResponse(res, 400, { error: 'postId is required' }) + } + + try { + const result = await Post.updateByIdAndUserId(postId, userId, content) + if (result.error) { + console.error('Error updating post:', result.error) // Log the error here as well + return sendDataResponse(res, result.status, { error: result.error }) + } + + return sendDataResponse(res, 200, { + message: 'Post updated successfully', + post: result.post + }) + } catch (error) { + console.error('Exception error updating post:', error.message) // This captures exceptions thrown during the process + return sendDataResponse(res, 500, { error: 'Something went wrong' }) + } +} + +export const likePost = async (req, res) => { + const { postId } = req.params + const userId = req.user.id + + try { + const message = await Post.toggleLike(Number(postId), userId) + res.status(200).json({ message }) + } catch (error) { + console.error('Error handling like action:', error) + res.status(500).json({ error: 'Internal server error' }) + } } diff --git a/src/controllers/student.js b/src/controllers/student.js new file mode 100644 index 00000000..bd93ebf7 --- /dev/null +++ b/src/controllers/student.js @@ -0,0 +1,10 @@ +import Student from '../domain/student.js' +import { sendDataResponse } from '../utils/responses.js' + +export const getAllStudents = async (req, res) => { + const students = await Student.getAll() + + return sendDataResponse(res, 200, { students }) +} + +export const getSelf = async (req, res) => res.json({ data: req.student }) diff --git a/src/controllers/teachers.js b/src/controllers/teachers.js new file mode 100644 index 00000000..d91b66df --- /dev/null +++ b/src/controllers/teachers.js @@ -0,0 +1,16 @@ +import Teacher from '../domain/teachers.js' +import { sendDataResponse } from '../utils/responses.js' + +export const getAllTeachers = async (req, res) => { + const teachers = await Teacher.getAll() + + return sendDataResponse(res, 200, { teachers }) +} + +export const getTeacher = async (req, res) => { + const id = Number(req.params.id) + const teacher = await Teacher.getTeacherBy(id) + return sendDataResponse(res, 200, { teacher }) +} + +export const getSelf = async (req, res) => res.json({ data: req.teacher }) diff --git a/src/controllers/user.js b/src/controllers/user.js index 40ff0f1c..1d7f4cd4 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -1,5 +1,6 @@ import User from '../domain/user.js' import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/index.js' export const create = async (req, res) => { const userToCreate = await User.fromJson(req.body) @@ -8,13 +9,19 @@ export const create = async (req, res) => { const existingUser = await User.findByEmail(userToCreate.email) if (existingUser) { - return sendDataResponse(res, 400, { email: 'Email already in use' }) + return sendDataResponse(res, 409, { error: 'Email already in use' }) } const createdUser = await userToCreate.save() return sendDataResponse(res, 201, createdUser) } catch (error) { + if (error instanceof PrismaClientKnownRequestError) { + if (error.code === 'P2002') { + return sendDataResponse(res, 409, { error: 'Email already in use' }) + } + } + console.error('Error creating user', error) return sendMessageResponse(res, 500, 'Unable to create new user') } } @@ -26,7 +33,7 @@ export const getById = async (req, res) => { const foundUser = await User.findById(id) if (!foundUser) { - return sendDataResponse(res, 404, { id: 'User not found' }) + return sendDataResponse(res, 404, { error: 'User not found' }) } return sendDataResponse(res, 200, foundUser) @@ -35,14 +42,14 @@ export const getById = async (req, res) => { } } +export const getSelf = async (req, res) => res.json({ data: req.user }) + export const getAll = async (req, res) => { - // eslint-disable-next-line camelcase - const { first_name: firstName } = req.query + const { name } = req.query let foundUsers - - if (firstName) { - foundUsers = await User.findManyByFirstName(firstName) + if (name) { + foundUsers = await User.findManyByFirstNameOrLastName(name) } else { foundUsers = await User.findAll() } @@ -60,8 +67,43 @@ export const updateById = async (req, res) => { const { cohort_id: cohortId } = req.body if (!cohortId) { - return sendDataResponse(res, 400, { cohort_id: 'Cohort ID is required' }) + return sendDataResponse(res, 400, { error: 'Cohort ID is required' }) } return sendDataResponse(res, 201, { user: { cohort_id: cohortId } }) } + +export const createProfile = async (req, res) => { + const { email } = req.body + + try { + const existingUser = await User.findByEmail(email) + + if (!existingUser) { + return sendDataResponse(res, 404, { error: 'User not found' }) + } + + const profile = await User.createProfileDb(existingUser.id, req.body) + return sendDataResponse(res, 201, { profile }) + } catch (e) { + return sendMessageResponse(res, 500, 'Unable create user profile') + } +} + +export const getUserProfile = async (req, res) => { + const id = parseInt(req.params.id) + + let foundUserProfile + + try { + foundUserProfile = await User.findProfileById(id) + } catch (e) { + return sendMessageResponse(res, 500, 'Unable to get user') + } + + if (!foundUserProfile) { + return sendDataResponse(res, 404, { error: 'User not found' }) + } + + return sendDataResponse(res, 200, { profile: foundUserProfile }) +} diff --git a/src/domain/cohort.js b/src/domain/cohort.js index abdda73b..270e8fa8 100644 --- a/src/domain/cohort.js +++ b/src/domain/cohort.js @@ -13,15 +13,59 @@ export async function createCohort() { } export class Cohort { - constructor(id = null) { + constructor( + id = null, + name = 'default name', + users = [], + departmentId = null, + department = null + ) { this.id = id + this.name = name + this.users = users + this.departmentId = departmentId + this.department = department + } + + static fromDb(cohort) { + if (!cohort.department || !cohort.department.name) { + this.department.name = 'default department name' + } + const newCohort = new Cohort( + cohort.id, + cohort.name, + cohort.users, + cohort.departmentId, + cohort.department + ) + return newCohort + } + + static async _findMany() { + return dbClient.cohort.findMany({ + include: { + department: { + select: { + name: true + } + } + } + }) + } + + static async getAll() { + const foundCohorts = await Cohort._findMany() + const cohortList = foundCohorts.map(Cohort.fromDb) + return cohortList } toJSON() { return { - cohort: { - id: this.id - } + id: this.id, + name: this.name, + users: this.users, + departmentId: this.departmentId, + department: this.department } } } diff --git a/src/domain/comment.js b/src/domain/comment.js new file mode 100644 index 00000000..ed8b8460 --- /dev/null +++ b/src/domain/comment.js @@ -0,0 +1,119 @@ +import dbClient from '../utils/dbClient.js' + +export default class Comment { + /** + * @param { {id: int, content; string, postId: int, userId: int, user: { profile: {firstName: string, lastName: string }} createdAt: dateTime, updatedAt: dateTime } } comment + * @returns {id: int, content; string, postId: int, userId: int, author: {firstName: string, lastName: string } createdAt: dateTime, updatedAt: dateTime } + */ + constructor(id, content, postId, userId, author, createdAt, updatedAt) { + this.id = id + this.content = content + this.postId = postId + this.userId = userId + this.author = author + this.createdAt = createdAt + this.updatedAt = updatedAt + } + + static fromDb(comment) { + const author = { + firstName: comment.user.profile.firstName, + lastName: comment.user.profile.lastName + } + return new Comment( + comment.id, + comment.content, + comment.postId, + comment.userId, + author, + comment.createdAt, + comment.updatedAt + ) + } + + static async fromJson(json) { + return new Comment(null, json.content, json.postId, json.userId) + } + + static async _findMany() { + const comments = await dbClient.comment.findMany({ + include: { + user: { + include: { + profile: true + } + }, + post: { + select: { id: true } + } + } + }) + return comments + } + + static async _findManyWhere(key, value) { + return await dbClient.comment.findMany({ + where: { + [key]: value + }, + include: { + user: { + select: { + profile: { + select: { + firstName: true, + lastName: true + } + } + } + } + } + }) + } + + async save() { + const data = { + content: this.content, + author: this.author, + user: { + connect: { + id: Number(this.userId) + } + }, + post: { + connect: { + id: Number(this.postId) + } + } + } + const createdComment = await dbClient.comment.create({ + data, + include: { + user: { + include: { + profile: true + } + }, + post: true + } + }) + return Comment.fromDb(createdComment) + } + + static async getAll() { + const comments = await Comment._findMany() + const newCommentList = comments.map(Comment.fromDb) + return newCommentList + } + + static async createComment() { + const comment = await Comment._create(Comment.fromJSON()) + return Comment.fromDb(comment) + } + + static async getCommentsByPostId(postId) { + const foundComments = await Comment._findManyWhere('postId', postId) + const commentList = foundComments.map(Comment.fromDb) + return commentList + } +} diff --git a/src/domain/note.js b/src/domain/note.js new file mode 100644 index 00000000..5142ddf2 --- /dev/null +++ b/src/domain/note.js @@ -0,0 +1,79 @@ +import dbClient from '../utils/dbClient.js' + +export default class Note { + /** + * @param {number} id + * @param {string} title + * @param {string} content + * @param {{firstName: string, lastName: string, userId: number}} studentProfile + * @param {{firstName: string, lastName: string, userId: number}} teacherProfile + */ + constructor(id, title, content, studentProfile, teacherProfile) { + this.id = id + this.title = title + this.content = content + this.studentProfile = studentProfile + this.teacherProfile = teacherProfile + } + + static fromDb(note) { + return new Note( + note.id, + note.title, + note.content, + note.student.user.profile, + note.teacher.user.profile + ) + } + + /** + * @param {string} title + * @param {string} content + * @param {number} studentUserId + * @param {number} teacherUserId + * @returns {Promise} + */ + static async create(title, content, studentUserId, teacherUserId) { + const profileSelect = { + select: { + user: { + select: { + profile: { + select: { + userId: true, + firstName: true, + lastName: true + } + } + } + } + } + } + + const createdNote = await dbClient.note.create({ + data: { + title, + content, + student: { + connect: { + userId: studentUserId + } + }, + teacher: { + connect: { + userId: teacherUserId + } + } + }, + select: { + id: true, + title: true, + content: true, + student: profileSelect, + teacher: profileSelect + } + }) + + return Note.fromDb(createdNote) + } +} diff --git a/src/domain/post.js b/src/domain/post.js new file mode 100644 index 00000000..03564884 --- /dev/null +++ b/src/domain/post.js @@ -0,0 +1,160 @@ +import dbClient from '../utils/dbClient.js' + +export default class Post { + constructor(id, content, userId, likes, author, createdAt, updatedAt) { + this.id = id + this.content = content + this.userId = userId + this.likes = likes + this.author = author + this.createdAt = createdAt + this.updatedAt = updatedAt + } + + static async create(content, userId) { + const createdPost = await dbClient.post.create({ + data: { + content, + user: { + connect: { + id: userId + } + } + }, + include: { + user: { + select: { profile: true } + } + } + }) + + return new Post( + createdPost.id, + createdPost.content, + createdPost.user.id, + createdPost.likes, + { + firstName: createdPost.user.profile.firstName, + lastName: createdPost.user.profile.lastName + }, + createdPost.createdAt, + createdPost.updatedAt + ) + } + + static async getAll() { + const posts = await dbClient.post.findMany({ + include: { + user: { + include: { + profile: true + } + }, + likes: true + } + }) + + return posts.map((post) => { + const { profile } = post.user + if (!profile || !profile.firstName || !profile.lastName) { + throw new Error( + `Missing profile property on post.user at post with id: ${post.id}` + ) + } + + return new Post( + post.id, + post.content, + post.user.id, + post.likes, + { + firstName: profile.firstName, + lastName: profile.lastName + }, + post.createdAt, + post.updatedAt + ) + }) + } + + static async deleteById(postId) { + const deletedPost = await dbClient.post.delete({ + where: { id: Number(postId) } + }) + + return deletedPost + } + + static async updateByIdAndUserId(postId, userId, content) { + const post = await dbClient.post.findUnique({ where: { id: postId } }) + + if (!post) { + return { error: 'Post not found', status: 404 } + } + + if (post.userId !== userId) { + return { + error: 'You are not authorized to update this post', + status: 403 + } + } + + const updatedPost = await dbClient.post.update({ + where: { id: postId }, + data: { content }, + include: { + user: { + select: { + profile: { select: { firstName: true, lastName: true } } + } + } + } + }) + + return { + post: new Post( + updatedPost.id, + updatedPost.content, + updatedPost.userId, + updatedPost.likes, + { + firstName: updatedPost.user.profile.firstName, + lastName: updatedPost.user.profile.lastName + }, + updatedPost.createdAt, + updatedPost.updatedAt + ) + } + } + + static async toggleLike(postId, userId) { + const existingLike = await dbClient.like.findFirst({ + where: { + AND: [{ postId: postId }, { userId: userId }] + } + }) + + if (existingLike) { + await dbClient.like.delete({ where: { id: existingLike.id } }) + return 'Like removed successfully.' + } + + await dbClient.like.create({ + data: { + postId, + userId + } + }) + return 'Like added successfully.' + } + + static async getById(postId) { + const foundPost = await dbClient.post.findFirst({ + where: { + id: Number(postId) + } + }) + + return foundPost + } +} diff --git a/src/domain/student.js b/src/domain/student.js new file mode 100644 index 00000000..c45f0bc0 --- /dev/null +++ b/src/domain/student.js @@ -0,0 +1,137 @@ +import dbClient from '../utils/dbClient.js' + +export default class Student { + constructor( + id = null, + title = null, + user = null, + userId = null, + cohort = null, + cohortId = null + ) { + this.id = id + this.title = title + this.user = user + this.userId = userId + this.cohort = cohort + this.cohortId = cohortId + } + + static fromDb(student) { + return new Student( + student.id, + student.title, + student.user, + student.userId, + student.cohort, + student.cohortId + ) + } + + static async _findMany() { + return dbClient.student.findMany({ + include: { + cohort: true, + user: { + select: { + profile: true + } + } + } + }) + } + + static async _findManyWhere(key, value) { + return dbClient.student.findMany({ + where: { + [key]: value + }, + include: { + user: { + select: { + profile: true + } + } + } + }) + } + + static async _findUniqueWhere(key, value) { + return dbClient.student.findUnique({ + where: { + [key]: value + }, + include: { + user: { + select: { + profile: true + } + }, + cohort: { + select: { + name: true, + departmentId: true, + department: { + select: { + name: true + } + } + } + } + } + }) + } + + static async getAll() { + const foundStudents = await Student._findMany() + + const allStudents = foundStudents.map(Student.fromDb) + return allStudents + } + + static async findByUserId(userId) { + const foundStudent = await Student._findUniqueWhere('userId', userId) + if (!foundStudent) throw new Error('No student connected to this user') + const student = Student.fromDb(foundStudent) + return student + } + + static async getAllStudentsByCohortId(id) { + const foundStudents = await Student._findManyWhere('cohortId', id) + + const allStudents = foundStudents.map(Student.fromDb) + return allStudents + } + + static async changeCohort(studentId, newCohortId) { + const studentExists = await dbClient.student.findUnique({ + where: { id: studentId } + }) + if (!studentExists) throw new Error('Student not found') + + const cohortExists = await dbClient.cohort.findUnique({ + where: { id: newCohortId } + }) + if (!cohortExists) throw new Error('Cohort not found') + + const updatedStudent = await dbClient.student.update({ + where: { id: studentId }, + data: { cohortId: newCohortId } + }) + + return updatedStudent + } + + toJSON() { + return { + id: this.id, + title: this.title, + email: this.user.email, + firstName: this.user.profile.firstName, + lastName: this.user.profile.lastName, + userId: this.userId, + cohortId: this.cohortId, + cohort: this.cohort + } + } +} diff --git a/src/domain/teachers.js b/src/domain/teachers.js new file mode 100644 index 00000000..382989ee --- /dev/null +++ b/src/domain/teachers.js @@ -0,0 +1,86 @@ +import dbClient from '../utils/dbClient.js' + +export default class Teacher { + constructor(id = null, user = null, departmentId = null, department = null) { + this.id = id + this.user = user + this.departmentId = departmentId + this.department = department + } + + static fromDb(teacher) { + return new Teacher( + teacher.id, + teacher.user, + teacher.departmentId, + teacher.department + ) + } + + static async _findUnique(key, value) { + return dbClient.teacher.findUnique({ + where: { + [key]: value + }, + include: { + department: { + select: { + name: true + } + }, + user: { + select: { + profile: true + } + } + } + }) + } + + static async _findMany() { + return dbClient.teacher.findMany({ + include: { + user: { + include: { + profile: true + } + }, + department: true + } + }) + } + + static async _findManyWhere(key, value) { + return dbClient.teacher.findMany({ + where: { + [key]: value + }, + include: { + user: { + include: { + profile: true + } + }, + department: true + } + }) + } + + static async getTeacherBy(teacherId) { + const teacher = await Teacher._findUnique('id', teacherId) + return teacher + } + + static async findByUserId(userId) { + const foundTeacher = await Teacher._findUnique('userId', userId) + if (!foundTeacher) throw new Error('No teacher connected to this user') + return Teacher.fromDb(foundTeacher) + } + + static async getAll() { + const foundTeachers = await Teacher._findMany() + const allTeachers = foundTeachers.map(Teacher.fromDb) + + return allTeachers + } +} diff --git a/src/domain/user.js b/src/domain/user.js index fd7734c7..a5882519 100644 --- a/src/domain/user.js +++ b/src/domain/user.js @@ -19,6 +19,7 @@ export default class User { user.email, user.profile?.bio, user.profile?.githubUrl, + user.profile?.imageUrl, user.password, user.role ) @@ -26,7 +27,15 @@ export default class User { static async fromJson(json) { // eslint-disable-next-line camelcase - const { firstName, lastName, email, biography, githubUrl, password } = json + const { + firstName, + lastName, + email, + biography, + githubUrl, + password, + imageUrl + } = json const passwordHash = await bcrypt.hash(password, 8) @@ -38,6 +47,7 @@ export default class User { email, biography, githubUrl, + imageUrl, passwordHash ) } @@ -50,16 +60,18 @@ export default class User { email, bio, githubUrl, + imageUrl, passwordHash = null, - role = 'STUDENT' + role = 'TBA' ) { this.id = id this.cohortId = cohortId - this.firstName = firstName - this.lastName = lastName + this.firstName = firstName || 'unknown' + this.lastName = lastName || 'unknown' this.email = email this.bio = bio this.githubUrl = githubUrl + this.imageUrl = imageUrl this.passwordHash = passwordHash this.role = role } @@ -74,7 +86,8 @@ export default class User { lastName: this.lastName, email: this.email, biography: this.bio, - githubUrl: this.githubUrl + githubUrl: this.githubUrl, + imageUrl: this.imageUrl } } } @@ -98,16 +111,24 @@ export default class User { } } - if (this.firstName && this.lastName) { - data.profile = { - create: { - firstName: this.firstName, - lastName: this.lastName, - bio: this.bio, - githubUrl: this.githubUrl - } + data.profile = { + create: { + firstName: this.firstName, + lastName: this.lastName, + bio: this.bio, + githubUrl: this.githubUrl, + imageUrl: this.imageUrl, + role: this.role, + specialism: this.specialism, + cohort: this.cohort, + startDate: this.startDate, + endDate: this.endDate, + email: this.email, + mobile: this.mobile, + password: this.passwordHash } } + const createdUser = await dbClient.user.create({ data, include: { @@ -130,6 +151,40 @@ export default class User { return User._findMany('firstName', firstName) } + static async findManyByFirstNameOrLastName(name) { + const splitName = name.split(' ') + + const promise = Promise.all( + splitName.map((word) => { + return User._findManyOr( + { + key: 'firstName', + value: { mode: 'insensitive', contains: word } + }, + { key: 'lastName', value: { mode: 'insensitive', contains: word } } + ) + }) + ) + + let results = await promise + results = results.flat() + + const foundUsers = [] + results.forEach((user) => { + const { id } = user + const match = foundUsers.some((entry) => entry.id === id) + if (!match) { + user.count = 1 + foundUsers.push(user) + } else { + const dupeResult = foundUsers.find((entry) => entry.id === id) + dupeResult.count++ + } + }) + + return foundUsers.sort((a, b) => b.count - a.count) + } + static async findAll() { return User._findMany() } @@ -170,4 +225,61 @@ export default class User { return foundUsers.map((user) => User.fromDb(user)) } + + static async _findManyOr(...keyValue) { + const query = keyValue.map(({ key, value }) => ({ + [key]: value + })) + + const foundUsers = await dbClient.user.findMany({ + where: { + profile: { + OR: query + } + }, + include: { + profile: true + } + }) + + return foundUsers.map((user) => User.fromDb(user)) + } + + static async createProfileDb(id, user) { + const createdProfile = await dbClient.profile.update({ + where: { + id + }, + data: { + firstName: user.firstName, + lastName: user.lastName, + githubUrl: user.githubUrl, + imageUrl: user.imageUrl, + bio: user.bio, + role: user.role, + specialism: user.specialism, + cohort: user.cohort, + startDate: user.startDate, + endDate: user.endDate, + email: user.email, + mobile: user.mobile, + password: user.password + } + }) + return createdProfile + } + + static async findProfileById(id) { + const foundProfile = await dbClient.profile.findUnique({ + where: { + id + } + }) + + if (foundProfile) { + return foundProfile + } + + return null + } } diff --git a/src/helpers/errorCreator.js b/src/helpers/errorCreator.js new file mode 100644 index 00000000..512ca5f8 --- /dev/null +++ b/src/helpers/errorCreator.js @@ -0,0 +1,7 @@ +const errorCreator = (message, status) => { + const error = new Error(message) + error.status = status + return error +} + +export default errorCreator diff --git a/src/middleware/auth.js b/src/middleware/auth.js index baffff47..e1a30120 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -2,6 +2,8 @@ import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' import { JWT_SECRET } from '../utils/config.js' import jwt from 'jsonwebtoken' import User from '../domain/user.js' +import Teacher from '../domain/teachers.js' +import Student from '../domain/student.js' export async function validateTeacherRole(req, res, next) { if (!req.user) { @@ -17,6 +19,20 @@ export async function validateTeacherRole(req, res, next) { next() } +export async function validateStudentRole(req, res, next) { + if (!req.user) { + return sendMessageResponse(res, 500, 'Unable to verify user') + } + + if (req.user.role !== 'STUDENT') { + return sendDataResponse(res, 403, { + authorization: 'You are not authorized to perform this action' + }) + } + + next() +} + export async function validateAuthentication(req, res, next) { const header = req.header('authorization') @@ -48,6 +64,15 @@ export async function validateAuthentication(req, res, next) { req.user = foundUser + if (decodedToken.userRole === 'TEACHER') { + const foundTeacher = await Teacher.findByUserId(decodedToken.userId) + req.teacher = foundTeacher + } + if (decodedToken.userRole === 'STUDENT') { + const foundStudent = await Student.findByUserId(decodedToken.userId) + req.student = foundStudent + } + next() } diff --git a/src/middleware/commentErrors.js b/src/middleware/commentErrors.js new file mode 100644 index 00000000..3a1c4c51 --- /dev/null +++ b/src/middleware/commentErrors.js @@ -0,0 +1,32 @@ +import Post from '../domain/post.js' +import errorCreator from '../helpers/errorCreator.js' + +export const checkFields = (requiredFields) => { + return (req, res, next) => { + const fields = req.body + + requiredFields.forEach((field) => { + if (!fields[field]) { + throw errorCreator(`Missing field: ${field}`, 400) + } + }) + + next() + } +} + +export const checkPostExist = async (req, res, next) => { + const { postId } = req.params + + try { + const foundPost = await Post.getById(postId) + + if (!foundPost) { + throw errorCreator(`Post with provided id ${postId} does not exist`, 404) + } + + next() + } catch (err) { + next(err) + } +} diff --git a/src/middleware/postError.js b/src/middleware/postError.js new file mode 100644 index 00000000..c2f4aa2c --- /dev/null +++ b/src/middleware/postError.js @@ -0,0 +1,41 @@ +import Post from '../domain/post.js' +import errorCreator from '../helpers/errorCreator.js' + +export const postExist = async (req, res, next) => { + const { postId } = req.params + + const post = await Post.getById(postId) + + try { + if (!post) { + throw errorCreator('Post not found', 404) + } + } catch (err) { + next(err) + } + + const postData = { + id: post.id, + userId: post.userId + } + + req.post = postData + + next() +} + +export const checkPostOwner = async (req, res, next) => { + const postUserId = req.post.userId + const userId = req.user.id + const userRole = req.user.role + + try { + if (postUserId !== userId && userRole !== 'TEACHER') { + throw errorCreator('You are not authorized to delete this post', 403) + } + } catch (err) { + next(err) + } + + next() +} diff --git a/src/routes/cohort.js b/src/routes/cohort.js index 3cc7813d..9b61c2a3 100644 --- a/src/routes/cohort.js +++ b/src/routes/cohort.js @@ -1,5 +1,10 @@ import { Router } from 'express' -import { create } from '../controllers/cohort.js' +import { + create, + getCohorts, + getStudentsByCohortId, + changeStudentCohort +} from '../controllers/cohort.js' import { validateAuthentication, validateTeacherRole @@ -8,5 +13,13 @@ import { const router = Router() router.post('/', validateAuthentication, validateTeacherRole, create) +router.get('/', validateAuthentication, validateTeacherRole, getCohorts) +router.get('/:id/students', validateAuthentication, getStudentsByCohortId) +router.put( + '/:id/students', + validateAuthentication, + validateTeacherRole, + changeStudentCohort +) export default router diff --git a/src/routes/comment.js b/src/routes/comment.js new file mode 100644 index 00000000..c78eec72 --- /dev/null +++ b/src/routes/comment.js @@ -0,0 +1,26 @@ +import { Router } from 'express' +import { + createComment, + getComments, + getCommentsByPost +} from '../controllers/comment.js' +import { validateAuthentication } from '../middleware/auth.js' +import { checkFields, checkPostExist } from '../middleware/commentErrors.js' + +const router = Router() + +router.get('/', validateAuthentication, getComments) +router.post( + '/', + validateAuthentication, + checkFields(['postId', 'content']), + createComment +) +router.get( + '/:postId', + validateAuthentication, + checkPostExist, + getCommentsByPost +) + +export default router diff --git a/src/routes/note.js b/src/routes/note.js new file mode 100644 index 00000000..0e50b05f --- /dev/null +++ b/src/routes/note.js @@ -0,0 +1,20 @@ +import { Router } from 'express' + +import { + validateAuthentication, + validateTeacherRole +} from '../middleware/auth.js' +import { createNote } from '../controllers/note.js' +import { checkFields } from '../middleware/commentErrors.js' + +const router = Router() + +router.post( + '/', + validateAuthentication, + validateTeacherRole, + checkFields(['title', 'content', 'studentUserId', 'teacherUserId']), + createNote +) + +export default router diff --git a/src/routes/post.js b/src/routes/post.js index a7fbbfb3..f8ee9111 100644 --- a/src/routes/post.js +++ b/src/routes/post.js @@ -1,10 +1,26 @@ import { Router } from 'express' -import { create, getAll } from '../controllers/post.js' +import { + create, + getAll, + deletePost, + editPost, + likePost +} from '../controllers/post.js' import { validateAuthentication } from '../middleware/auth.js' +import { checkPostOwner, postExist } from '../middleware/postError.js' const router = Router() router.post('/', validateAuthentication, create) +router.post('/:postId/like', validateAuthentication, likePost) router.get('/', validateAuthentication, getAll) +router.put('/:postId', validateAuthentication, editPost) +router.delete( + '/:postId', + validateAuthentication, + postExist, + checkPostOwner, + deletePost +) export default router diff --git a/src/routes/student.js b/src/routes/student.js new file mode 100644 index 00000000..fa0da6ee --- /dev/null +++ b/src/routes/student.js @@ -0,0 +1,13 @@ +import { Router } from 'express' +import { getAllStudents, getSelf } from '../controllers/student.js' +import { + validateAuthentication, + validateStudentRole +} from '../middleware/auth.js' + +const router = Router() + +router.get('/', validateAuthentication, getAllStudents) +router.get('/me', validateAuthentication, validateStudentRole, getSelf) + +export default router diff --git a/src/routes/teachers.js b/src/routes/teachers.js new file mode 100644 index 00000000..8be04421 --- /dev/null +++ b/src/routes/teachers.js @@ -0,0 +1,14 @@ +import { Router } from 'express' +import { getAllTeachers, getSelf, getTeacher } from '../controllers/teachers.js' +import { + validateAuthentication, + validateTeacherRole +} from '../middleware/auth.js' + +const router = Router() + +router.get('/', validateAuthentication, getAllTeachers) +router.get('/me', validateAuthentication, validateTeacherRole, getSelf) +router.get('/:id', validateAuthentication, getTeacher) + +export default router diff --git a/src/routes/user.js b/src/routes/user.js index 9f63d162..d2707544 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -1,5 +1,13 @@ import { Router } from 'express' -import { create, getById, getAll, updateById } from '../controllers/user.js' +import { + create, + getById, + getSelf, + getAll, + updateById, + createProfile, + getUserProfile +} from '../controllers/user.js' import { validateAuthentication, validateTeacherRole @@ -9,7 +17,10 @@ const router = Router() router.post('/', create) router.get('/', validateAuthentication, getAll) +router.get('/me', validateAuthentication, getSelf) router.get('/:id', validateAuthentication, getById) router.patch('/:id', validateAuthentication, validateTeacherRole, updateById) +router.put('/:id', validateAuthentication, createProfile) +router.get('/profile/:id', validateAuthentication, getUserProfile) export default router diff --git a/src/server.js b/src/server.js index a3f67eeb..8a62959b 100644 --- a/src/server.js +++ b/src/server.js @@ -4,15 +4,21 @@ import YAML from 'yaml' import swaggerUi from 'swagger-ui-express' import express from 'express' import cors from 'cors' +import morgan from 'morgan' import userRouter from './routes/user.js' import postRouter from './routes/post.js' import authRouter from './routes/auth.js' import cohortRouter from './routes/cohort.js' import deliveryLogRouter from './routes/deliveryLog.js' +import commentRouter from './routes/comment.js' +import teachersRoute from './routes/teachers.js' +import studentsRouter from './routes/student.js' +import notesRouter from './routes/note.js' const app = express() app.disable('x-powered-by') app.use(cors()) +app.use(morgan('dev')) app.use(express.json()) app.use(express.urlencoded({ extended: true })) @@ -26,6 +32,19 @@ app.use('/posts', postRouter) app.use('/cohorts', cohortRouter) app.use('/logs', deliveryLogRouter) app.use('/', authRouter) +app.use('/comments', commentRouter) +app.use('/teachers', teachersRoute) +app.use('/students', studentsRouter) +app.use('/notes', notesRouter) + +app.use((err, req, res, next) => { + res.status(err.status ?? 500).json({ + status: 'error', + data: { + message: err.message + } + }) +}) app.get('*', (req, res) => { res.status(404).json({