diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 79297ed5..00000000 --- a/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -Dockerfile -.dockerignore -node_modules -.git 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..1e2ff854 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,5 @@ name: CI on: [push, pull_request] -env: - DATABASE_URL: ${{ secrets.DATABASE_URL }} - JWT_TOKEN: ${{ secrets.JWT_TOKEN }} - JWT_EXPIRY: ${{ secrets.JWT_EXPIRY }} jobs: test: runs-on: ubuntu-latest @@ -14,4 +10,3 @@ jobs: node-version: 'lts/*' - run: npm ci - run: npx eslint src - - run: npx prisma migrate reset --force --skip-seed diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 471052aa..00000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Fly Deploy -on: - push: - branches: - - main -env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - DATABASE_URL: ${{ secrets.DATABASE_URL }} - JWT_SECRET: ${{ secrets.JWT_SECRET }} - JWT_EXPIRY: ${{ secrets.JWT_EXPIRY }} -jobs: - deploy: - name: Deploy app to fly.io - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: superfly/flyctl-actions/setup-flyctl@master - - run: echo DATABASE_URL=$DATABASE_URL >> .env - - run: echo JWT_SECRET=$JWT_SECRET >> .env - - run: echo JWT_EXPIRY=$JWT_EXPIRY >> .env - - run: flyctl deploy --remote-only diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index a41979b3..00000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npx eslint src \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index b0c8ba77..00000000 --- a/Dockerfile +++ /dev/null @@ -1,36 +0,0 @@ -FROM debian:bullseye as builder - -ARG NODE_VERSION=18.12.1 - -RUN apt-get update; apt install -y curl -RUN curl https://get.volta.sh | bash -ENV VOLTA_HOME /root/.volta -ENV PATH /root/.volta/bin:$PATH -RUN volta install node@${NODE_VERSION} - -####################################################################### - -RUN mkdir /app -WORKDIR /app - -# NPM will not install any package listed in "devDependencies" when NODE_ENV is set to "production", -# to install all modules: "npm install --production=false". -# Ref: https://docs.npmjs.com/cli/v9/commands/npm-install#description - -ENV NODE_ENV production - -COPY . . - -RUN npm install -FROM debian:bullseye - -LABEL fly_launch_runtime="nodejs" - -COPY --from=builder /root/.volta /root/.volta -COPY --from=builder /app /app - -WORKDIR /app -ENV NODE_ENV production -ENV PATH /root/.volta/bin:$PATH - -CMD [ "npm", "run", "start" ] diff --git a/docs/openapi.yml b/docs/openapi.yml index 5f2a05f2..67cfd676 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -2,10 +2,10 @@ openapi: 3.0.3 info: title: Team Dev Server API description: |- - version: 1.0 + version: '1.0' servers: - - url: http://localhost:4000/ + - url: 'http://localhost:4000/' tags: - name: user - name: post @@ -32,6 +32,12 @@ paths: application/json: schema: $ref: '#/components/schemas/CreatedUser' + '400': + description: Invalid email/password supplied + content: + application/json: + schema: + $ref: '#/components/schemas/Error' get: tags: - user @@ -85,10 +91,8 @@ paths: application/json: schema: $ref: '#/components/schemas/loginRes' - '400': description: Invalid username/password supplied - /users/{id}: get: tags: @@ -117,7 +121,6 @@ paths: type: string data: $ref: '#/components/schemas/User' - '400': description: fail content: @@ -164,6 +167,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + '400': + description: Invalid email/password/profile information supplied + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /posts: post: tags: @@ -189,8 +198,14 @@ paths: application/json: schema: $ref: '#/components/schemas/Post' - 400: - description: fail + 401: + description: Unauthorised + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error content: application/json: schema: @@ -211,11 +226,216 @@ paths: schema: $ref: '#/components/schemas/Posts' '401': - description: fail + description: Unauthorised + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /posts/{id}: + get: + tags: + - post + summary: Get a post by id + description: get a post + operationId: getPostById + security: + - bearerAuth: [] + parameters: + - name: id + in: path + description: 'The post id that needs to be updated' + required: true + schema: + type: integer + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Posts' + '401': + description: Unauthorised + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + patch: + tags: + - post + summary: Patch a post by id + description: patch a post + operationId: updatePostById + security: + - bearerAuth: [] + parameters: + - name: id + in: path + description: 'The post id that needs to be updated' + required: true + schema: + type: integer + requestBody: + description: The post description + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePost' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Posts' + '401': + description: Unauthorised + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + tags: + - post + summary: Delete a post by id + description: delete a post + operationId: deletePostById + security: + - bearerAuth: [] + parameters: + - name: id + in: path + description: 'The post id that needs to be updated' + required: true + schema: + type: integer + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Posts' + '401': + description: Unauthorised content: application/json: schema: $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /posts/{id}/like: + post: + tags: + - post + summary: Like a post + description: Allows a user to like a post. + operationId: likePost + security: + - bearerAuth: [] + parameters: + - name: id + in: path + description: ID of the post to like + required: true + schema: + type: integer + responses: + '200': + description: Post liked successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Post not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /posts/{id}/unlike: + post: + tags: + - post + summary: Unlike a post + description: Allows a user to unlike a post. + operationId: unlikePost + security: + - bearerAuth: [] + parameters: + - name: id + in: path + description: ID of the post to unlike + required: true + schema: + type: integer + responses: + '200': + description: Post unliked successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Post not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /logs: post: tags: @@ -265,6 +485,11 @@ paths: operationId: createCohort security: - bearerAuth: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateCohort' responses: 201: description: success @@ -285,6 +510,276 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + get: + tags: + - cohort + summary: Get all cohorts + operationId: getAllCohorts + security: + - bearerAuth: [] + responses: + '201': + description: success + content: + application/json: + schema: + type: object + properties: + status: + type: string + data: + properties: + cohort: + $ref: '#/components/schemas/Cohort' + '401': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /cohorts/{id}: + get: + tags: + - cohort + summary: Get a cohort by id + operationId: getCohortById + security: + - bearerAuth: [] + parameters: + - name: id + in: path + description: 'The cohort id' + required: true + schema: + type: integer + responses: + '201': + description: success + content: + application/json: + schema: + type: object + properties: + status: + type: string + data: + properties: + cohort: + $ref: '#/components/schemas/Cohort' + '401': + description: Unautorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + patch: + tags: + - cohort + summary: Patch a cohort by id + description: This can only be done by the logged in user with role TEACHER. + operationId: updateCohortById + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateCohort' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Posts' + '401': + description: Unauthorised + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + tags: + - cohort + summary: Delete a cohort by id + description: This can only be done by the logged in user with role TEACHER. + operationId: deleteCohortById + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Posts' + '401': + description: Unauthorised + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /comments: + post: + tags: + - comment + summary: Create a comment + description: Create a new comment on a post + operationId: createComment + security: + - bearerAuth: [] + requestBody: + description: Comment details + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComment' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '400': + description: Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /comments/{commentId}: + patch: + tags: + - comment + summary: Update a comment + description: Update an existing comment + operationId: updateComment + security: + - bearerAuth: [] + parameters: + - name: commentId + in: path + description: The ID of the comment to update + required: true + schema: + type: integer + requestBody: + description: Updated comment details + required: true + content: + application/json: + schema: + type: object + properties: + content: + type: string + userId: + type: integer + required: + - content + - userId + responses: + '200': + description: Updated + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '400': + description: Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Comment not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + tags: + - comment + summary: Delete a comment + description: Delete an existing comment + operationId: deleteComment + security: + - bearerAuth: [] + parameters: + - name: commentId + in: path + description: The ID of the comment to delete + required: true + schema: + type: integer + responses: + '200': + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '404': + description: Comment not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' components: securitySchemes: @@ -306,18 +801,40 @@ components: type: integer content: type: string + likedBy: + type: array + items: + $ref: '#/components/schemas/User' Cohort: type: object properties: id: type: integer + cohortName: + type: string createdAt: type: string - format: string + format: date-time updatedAt: type: string - format: string + format: date-time + users: + type: array + items: + $ref: '#/components/schemas/User' + + UpdateCohort: + type: object + properties: + cohortName: + type: string + startDate: + type: string + format: date-time + endDate: + type: string + format: date-time AllUsers: type: object @@ -351,6 +868,20 @@ components: type: string githubUrl: type: string + username: + type: string + mobile: + type: string + specialism: + type: string + startDate: + type: string + format: date-time + endDate: + type: string + format: date-time + profileImage: + type: string CreateUser: type: object @@ -365,8 +896,22 @@ components: type: string githubUrl: type: string + username: + type: string + mobile: + type: string + specialism: + type: string + startDate: + type: string + format: date-time + endDate: + type: string + format: date-time password: type: string + profileImage: + type: string UpdateUser: type: object @@ -387,6 +932,20 @@ components: type: string githubUrl: type: string + username: + type: string + mobile: + type: string + specialism: + type: string + startDate: + type: string + format: date-time + endDate: + type: string + format: date-time + profileImage: + type: string Posts: type: object @@ -407,10 +966,10 @@ components: type: string createdAt: type: string - format: string + format: date-time updatedAt: type: string - format: string + format: date-time author: type: object properties: @@ -428,9 +987,31 @@ components: type: string githubUrl: type: string - profileImageUrl: + username: + type: string + mobile: + type: string + specialism: + type: string + startDate: type: string - + format: date-time + endDate: + type: string + format: date-time + profileImage: + type: string + likedBy: + type: array + items: + $ref: '#/components/schemas/User' + + UpdatePost: + type: object + properties: + content: + type: string + CreatedUser: type: object properties: @@ -457,6 +1038,21 @@ components: type: string githubUrl: type: string + username: + type: string + mobile: + type: string + specialism: + type: string + startDate: + type: string + format: date-time + endDate: + type: string + format: date-time + profileImage: + type: string + login: type: object properties: @@ -492,6 +1088,21 @@ components: type: string githubUrl: type: string + username: + type: string + mobile: + type: string + specialism: + type: string + startDate: + type: string + format: date-time + endDate: + type: string + format: date-time + profileImage: + type: string + Error: type: object properties: @@ -535,3 +1146,38 @@ components: type: integer content: type: string + Comment: + type: object + properties: + id: + type: integer + content: + type: string + userId: + type: integer + postId: + type: integer + CreateComment: + type: object + properties: + content: + type: string + postId: + type: integer + userId: + type: integer + UpdateComment: + type: object + properties: + content: + type: string + userId: + type: integer + required: + - content + - userId + Success: + type: object + properties: + message: + type: string \ No newline at end of file diff --git a/fly.toml b/fly.toml deleted file mode 100644 index eac058e7..00000000 --- a/fly.toml +++ /dev/null @@ -1,42 +0,0 @@ -# fly.toml file generated for team-dev-backend-api on 2022-11-30T11:30:34Z - -app = "team-dev-backend-api" -kill_signal = "SIGINT" -kill_timeout = 5 -processes = [] - -[env] - PORT = "8080" - -[experimental] - allowed_public_ports = [] - auto_rollback = true - -[deploy] - release_command = "npx prisma migrate deploy" - -[[services]] - http_checks = [] - internal_port = 8080 - processes = ["app"] - protocol = "tcp" - script_checks = [] - [services.concurrency] - hard_limit = 25 - soft_limit = 20 - type = "connections" - - [[services.ports]] - force_https = true - handlers = ["http"] - port = 80 - - [[services.ports]] - handlers = ["tls", "http"] - port = 443 - - [[services.tcp_checks]] - grace_period = "1s" - interval = "15s" - restart_limit = 0 - timeout = "2s" diff --git a/package.json b/package.json index b5997d22..3021b44f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "scripts": { "start": "node src/index.js", "dev": "nodemon src/index.js", - "prepare": "husky install", "db-reset": "prisma migrate reset", "lint": "eslint .", "lint:fix": "eslint --fix", @@ -36,7 +35,6 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-promise": "^5.1.0", - "husky": "^7.0.4", "nodemon": "^2.0.15", "prettier": "^2.6.2", "prisma": "^3.12.0" diff --git a/prisma/imageBase64Strings.js b/prisma/imageBase64Strings.js new file mode 100644 index 00000000..c65c2827 --- /dev/null +++ b/prisma/imageBase64Strings.js @@ -0,0 +1,10 @@ +export const imageBase64Strings = { + student1: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...', + jonas: + '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', + thomas: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...', + magnus: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...', + teacher: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...', + nigel: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...', + dave: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...' +} diff --git a/prisma/migrations/20241029082651_add_profile_fields/migration.sql b/prisma/migrations/20241029082651_add_profile_fields/migration.sql new file mode 100644 index 00000000..4ca11f21 --- /dev/null +++ b/prisma/migrations/20241029082651_add_profile_fields/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Profile" ADD COLUMN "endDate" TIMESTAMP(3), +ADD COLUMN "mobile" TEXT NOT NULL DEFAULT E'', +ADD COLUMN "specialism" TEXT NOT NULL DEFAULT E'', +ADD COLUMN "startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "username" TEXT NOT NULL DEFAULT E''; diff --git a/prisma/migrations/20241029090014_add_profile_image/migration.sql b/prisma/migrations/20241029090014_add_profile_image/migration.sql new file mode 100644 index 00000000..b47489b4 --- /dev/null +++ b/prisma/migrations/20241029090014_add_profile_image/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Profile" ADD COLUMN "profileImage" TEXT; diff --git a/prisma/migrations/20241029124851_added_comments_table/migration.sql b/prisma/migrations/20241029124851_added_comments_table/migration.sql new file mode 100644 index 00000000..1c5829f7 --- /dev/null +++ b/prisma/migrations/20241029124851_added_comments_table/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "Comment" ( + "id" SERIAL NOT NULL, + "content" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "postId" INTEGER NOT NULL, + + CONSTRAINT "Comment_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- 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/20241030083020_new_user_values/migration.sql b/prisma/migrations/20241030083020_new_user_values/migration.sql new file mode 100644 index 00000000..af5102c8 --- /dev/null +++ b/prisma/migrations/20241030083020_new_user_values/migration.sql @@ -0,0 +1 @@ +-- This is an empty migration. \ No newline at end of file diff --git a/prisma/migrations/20241030125841_add_created_updated_at_columns_to_post/migration.sql b/prisma/migrations/20241030125841_add_created_updated_at_columns_to_post/migration.sql new file mode 100644 index 00000000..d19aa2d3 --- /dev/null +++ b/prisma/migrations/20241030125841_add_created_updated_at_columns_to_post/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Post" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20241031081436_add_cohort_and_seeding/migration.sql b/prisma/migrations/20241031081436_add_cohort_and_seeding/migration.sql new file mode 100644 index 00000000..4f3056d0 --- /dev/null +++ b/prisma/migrations/20241031081436_add_cohort_and_seeding/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - Added the required column `cohortName` to the `Cohort` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Cohort" ADD COLUMN "cohortName" TEXT NOT NULL, +ADD COLUMN "endDate" TIMESTAMP(3), +ADD COLUMN "startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20241031114304_add_users_liked_to_post/migration.sql b/prisma/migrations/20241031114304_add_users_liked_to_post/migration.sql new file mode 100644 index 00000000..0f35091f --- /dev/null +++ b/prisma/migrations/20241031114304_add_users_liked_to_post/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "_UserLikesPosts" ( + "A" INTEGER NOT NULL, + "B" INTEGER NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_UserLikesPosts_AB_unique" ON "_UserLikesPosts"("A", "B"); + +-- CreateIndex +CREATE INDEX "_UserLikesPosts_B_index" ON "_UserLikesPosts"("B"); + +-- AddForeignKey +ALTER TABLE "_UserLikesPosts" ADD CONSTRAINT "_UserLikesPosts_A_fkey" FOREIGN KEY ("A") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_UserLikesPosts" ADD CONSTRAINT "_UserLikesPosts_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 72ec5632..0ee07965 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,6 +26,8 @@ model User { cohort Cohort? @relation(fields: [cohortId], references: [id]) posts Post[] deliveryLogs DeliveryLog[] + comments Comment[] + likedPosts Post[] @relation("UserLikesPosts", references: [id]) } model Profile { @@ -36,12 +38,21 @@ model Profile { lastName String bio String? githubUrl String? + username String @default("") + mobile String @default("") + specialism String @default("") + startDate DateTime @default(now()) + endDate DateTime? + profileImage String? } model Cohort { id Int @id @default(autoincrement()) - users User[] - deliveryLogs DeliveryLog[] + cohortName String + startDate DateTime @default(now()) + endDate DateTime? + students User[] + deliveryLogs DeliveryLog[] } model Post { @@ -49,6 +60,20 @@ model Post { content String userId Int user User @relation(fields: [userId], references: [id]) + comments Comment[] + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + likedBy User[] @relation("UserLikesPosts", references: [id]) +} + +model Comment { + id Int @id @default(autoincrement()) + content String + userId Int + user User @relation(fields: [userId], references: [id]) + postId Int + post Post @relation(fields: [postId], references: [id]) + } model DeliveryLog { diff --git a/prisma/seed.js b/prisma/seed.js index 21684795..cf154bd3 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -3,32 +3,189 @@ import bcrypt from 'bcrypt' const prisma = new PrismaClient() async function seed() { - const cohort = await createCohort() + const cohort = await createCohort( + 'Boolean 2024', + new Date('2024-08-08'), + new Date('2024-11-01') + ) + + const cohort2 = await createCohort( + 'Experis 2024', + new Date('2024-06-09'), + new Date('2024-12-13') + ) const student = await createUser( 'student@test.com', - 'Testpassword1!', cohort.id, 'Joe', 'Bloggs', 'Hello, world!', - 'student1' + 'student1@github.com', + 'student1', + '123-456-7890', // mobile + 'Software Engineering', // specialism + new Date('2023-01-01'), // startDate + new Date('2023-12-31'), + null, + 'STUDENT', + 'Testpassword1!' + ) + const jonas = await createUser( + 'jonas.halvorsen@test.com', + cohort.id, + 'Jonas', + 'Halvorsen', + 'Hello, I am Jonas Halvorsen!', + 'jonas.halvorsen@github.com', + 'jonas.halvorsen', + '123-456-7891', // mobile + 'Software Engineering', // specialism + new Date('2023-01-01'), // startDate + new Date('2023-12-31'), + null, + 'STUDENT', + 'Testpassword1!' ) const teacher = await createUser( 'teacher@test.com', - 'Testpassword1!', null, 'Rick', 'Sanchez', 'Hello there!', + 'teacher1@git.com', 'teacher1', - 'TEACHER' + '987-654-3210', + 'Teaching', + new Date('2022-01-01'), + new Date('2022-12-31'), + null, + 'TEACHER', + 'Testpassword1!' + ) + const nigel = await createUser( + 'nigel.sibbert@test.com', + null, + 'Nigel', + 'Sibbert', + 'Hello, I am Nigel Sibbert!', + 'nigel.sibbert@github.com', + 'nigel.sibbert', + '987-654-3211', + 'Teaching', + new Date('2022-01-01'), + new Date('2022-12-31'), + null, + 'TEACHER', + 'Testpassword1!' ) + const dave = await createUser( + 'dave.ames@test.com', + null, + 'Dave', + 'Ames', + 'Hello, I am Dave Ames!', + 'dave.ames@github.com', + 'dave.ames', + '987-654-3212', + 'Teaching', + new Date('2022-01-01'), + new Date('2022-12-31'), + null, + 'TEACHER', + 'Testpassword1!' + ) + const thomas = await createUser( + 'thomas.wiik@test.com', + cohort.id, + 'Thomas', + 'Wiik', + 'Hello, I am Thomas Wiik!', + 'thomas.wiik@github.com', + 'thomas.wiik', + '123-456-7892', // mobile + 'Software Engineering', // specialism + new Date('2023-01-01'), // startDate + new Date('2023-12-31'), + null, + 'STUDENT', + 'Testpassword1!' + ) + const magnus = await createUser( + 'magnus.brandsegg@test.com', + cohort.id, + 'Magnus', + 'Brandsegg', + 'Hello, I am Magnus Brandsegg!', + 'magnus.brandsegg@github.com', + 'magnus.brandsegg', + '123-456-7893', // mobile + 'Software Engineering', // specialism + new Date('2023-01-01'), // startDate + new Date('2023-12-31'), + null, + 'STUDENT', + 'Testpassword1!' + ) + +const posts = [ + await createPost( + student.id, + 'This is the first post. It contains some interesting information about our project. We are excited to share more updates soon.' + ), + await createPost( + jonas.id, + 'Here is another post. This one talks about the progress we have made so far. Stay tuned for more details.' + ), + await createPost( + thomas.id, + 'In this post, we discuss the challenges we faced and how we overcame them. It has been a learning experience.' + ), + await createPost( + magnus.id, + 'This post is about the new features we are planning to add. We hope you find them useful and exciting.' + ), + await createPost( + teacher.id, + 'As a teacher, I am proud of the progress my students have made. This post highlights their achievements.' + ) + ] + + const comments = [ + 'Great post!', + 'Very informative.', + 'Thanks for sharing.', + 'Looking forward to more updates.' + ] - await createPost(student.id, 'My first post!') - await createPost(teacher.id, 'Hello, students') + for (const post of posts) { + for (const comment of comments) { + await createComment(student.id, post.id, comment) + await createComment(jonas.id, post.id, comment) + await createComment(thomas.id, post.id, comment) + await createComment(magnus.id, post.id, comment) + } + } - process.exit(0) + for (const post of posts) { + await likePost(student.id, post.id) + await likePost(jonas.id, post.id) + await likePost(thomas.id, post.id) + await likePost(magnus.id, post.id) + await likePost(teacher.id, post.id) + } +} + +async function likePost(userId, postId) { + await prisma.post.update({ + where: { id: postId }, + data: { + likedBy: { + connect: { id: userId } + } + } + }) + console.info(`User ${userId} liked Post ${postId}`) } async function createPost(userId, content) { @@ -47,25 +204,51 @@ async function createPost(userId, content) { return post } -async function createCohort() { - const cohort = await prisma.cohort.create({ - data: {} +async function createComment(userId, postId, content) { + const comment = await prisma.comment.create({ + data: { + userId, + postId, + content + }, + include: { + user: true, + post: true + } }) - console.info('Cohort created', cohort) + console.info('Comment created', comment) + + return comment +} +async function createCohort(cohortName, startDate, endDate) { + const cohort = await prisma.cohort.create({ + data: { + cohortName, + startDate, + endDate + } + }) + console.info('Cohort created', cohort) return cohort } async function createUser( email, - password, cohortId, firstName, lastName, bio, githubUrl, - role = 'STUDENT' + username, + mobile, + specialism, + startDate, + endDate, + profileImage, + role = 'STUDENT', + password ) { const user = await prisma.user.create({ data: { @@ -78,7 +261,13 @@ async function createUser( firstName, lastName, bio, - githubUrl + githubUrl, + profileImage, + username, + mobile, + specialism, + startDate: new Date(startDate), + endDate: new Date(endDate) } } }, diff --git a/src/controllers/cohort.js b/src/controllers/cohort.js index cc39365b..3435a989 100644 --- a/src/controllers/cohort.js +++ b/src/controllers/cohort.js @@ -1,12 +1,67 @@ -import { createCohort } from '../domain/cohort.js' -import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' - -export const create = async (req, res) => { - try { - const createdCohort = await createCohort() - - return sendDataResponse(res, 201, createdCohort) - } catch (e) { - return sendMessageResponse(res, 500, 'Unable to create cohort') - } -} +import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' +import Cohort from '../domain/cohort.js' + +export const create = async (req, res) => { + const cohort = await Cohort.fromJson(req.body) + try { + const createdCohort = await cohort.save() + + return sendDataResponse(res, 201, { cohort: createdCohort }) + } catch (e) { + return sendMessageResponse(res, 500, `Unable to create cohort ${e}`) + } +} + +export const getAll = async (req, res) => { + const cohorts = await Cohort.getAllCohorts() + if (!cohorts) { + return sendMessageResponse(res, 500, 'Internal server error') + } + + return sendDataResponse(res, 200, cohorts) +} + +export const getById = async (req, res) => { + const { id } = req.params + const cohort = await Cohort.getCohortById(Number(id)) + + if (!cohort) { + return sendMessageResponse(res, 404, `Cohort with id ${id} not found`) + } + + return sendDataResponse(res, 200, cohort) +} + +export const updateById = async (req, res) => { + const { id } = req.params + const { cohortName, startDate, endDate } = req.body + + try { + const cohort = await Cohort.getCohortById(Number(id)) + if (cohort) { + const content = { cohortName, startDate, endDate } + const updatedCohort = await Cohort.updateById(Number(id), content) + return sendDataResponse(res, 200, updatedCohort.toJSON()) + } else { + return sendMessageResponse(res, 404, `Cohort with id ${id} not found`) + } + } catch (error) { + return sendDataResponse(res, 500, `Internal server error ${error}`) + } +} + +export const deleteById = async (req, res) => { + const { id } = req.params + + try { + const cohort = await Cohort.getCohortById(Number(id)) + if (cohort) { + const deletedCohort = await Cohort.deleteById(Number(id)) + return sendDataResponse(res, 200, deletedCohort) + } else { + return sendMessageResponse(res, 404, `Cohort with id ${id} not found`) + } + } catch (error) { + 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..d12b7079 --- /dev/null +++ b/src/controllers/comment.js @@ -0,0 +1,84 @@ +import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' +import Comment from '../domain/comment.js' +import User from '../domain/user.js' + +// Create a new comment +export const create = async (req, res) => { + const { content, postId } = req.body + const user = await User.findById(req.user.id) + + if (!content || !postId) { + return sendDataResponse(res, 400, { + error: 'Must provide content, postId, and userId' + }) + } + + try { + const createdComment = await Comment.createComment(content, user, postId) + if (!createdComment) { + return sendDataResponse(res, 404, { id: 'Comment not found' }) + } + + return sendDataResponse(res, 201, createdComment) + } catch (error) { + console.error('Error creating comment:', error) + return sendMessageResponse(res, 500, 'Unable to create comment') + } +} + +// Update an existing comment +export const update = async (req, res) => { + const { content, userId } = req.body + const { id } = req.params + const commentIdInt = parseInt(id, 10) + + if (!content) { + return sendDataResponse(res, 400, { error: 'Must provide content' }) + } + + if (isNaN(commentIdInt)) { + return sendDataResponse(res, 400, { error: 'Invalid comment ID' }) + } + + try { + const comment = await Comment.getCommentById(commentIdInt) + + if (!comment) { + return sendDataResponse(res, 404, { error: 'Comment not found' }) + } + + const updatedComment = await Comment.updateContentById( + commentIdInt, + content, + userId + ) + return sendDataResponse(res, 200, { comment: updatedComment }) + } catch (error) { + console.error('Error updating comment:', error) + return sendDataResponse(res, 500, { error: 'Internal Server Error' }) + } +} + +// Delete an existing comment +export const remove = async (req, res) => { + const { id } = req.params + const commentIdInt = parseInt(id, 10) + + if (isNaN(commentIdInt)) { + return sendDataResponse(res, 400, { error: 'Invalid comment ID' }) + } + + try { + const comment = await Comment.getCommentById(commentIdInt) + + if (!comment) { + return res.status(404).json({ error: 'Comment not found' }) + } + + await Comment.deleteCommentById(commentIdInt) + return res.status(200).json({ message: 'Comment deleted successfully' }) + } catch (error) { + console.error('Error deleting comment:', error) + return res.status(500).json({ error: 'Internal Server Error' }) + } +} diff --git a/src/controllers/post.js b/src/controllers/post.js index 7b168039..47446fa8 100644 --- a/src/controllers/post.js +++ b/src/controllers/post.js @@ -1,28 +1,121 @@ import { sendDataResponse } from '../utils/responses.js' +import Post from '../domain/post.js' +import User from '../domain/user.js' export const create = async (req, res) => { const { content } = req.body + const user = await User.findById(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.createPost(content, user) + if (post) { + return sendDataResponse(res, 201, { post }) + } else { + return sendDataResponse(res, 500, { content: 'Failed to create post' }) + } + } catch (error) { + return sendDataResponse(res, 500, { content: 'Internal server error' }) + } } 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 postsUnformatted = await Post.getAllPosts() + if (!postsUnformatted) { + return sendDataResponse(res, 500, { + content: 'Internal server error' + }) + } + + const posts = postsUnformatted.map((post) => post.toJSON()) + return sendDataResponse(res, 200, { posts }) +} + +export const getById = async (req, res) => { + const { id } = req.params + const post = await Post.getPostById(Number(id)) + + if (!post) { + return sendDataResponse(res, 404, { + content: `Post with id ${id} not found` + }) + } + + return sendDataResponse(res, 200, { post: post.toJSON() }) +} + +export const updateById = async (req, res) => { + const { id } = req.params + const { content } = req.body + + try { + const post = await Post.getPostById(Number(id)) + if (post) { + const updatedPost = await Post.updateContentById(Number(id), content) + return sendDataResponse(res, 200, { post: updatedPost }) + } else { + return sendDataResponse(res, 404, { + content: `Post with id ${id} not found` + }) + } + } catch (error) { + return sendDataResponse(res, 500, { + content: 'Internal server error' + }) + } +} + +export const deleteById = async (req, res) => { + const { id } = req.params + + try { + const post = await Post.getPostById(Number(id)) + if (post) { + const deletedPost = await Post.deletePostById(Number(id)) + return sendDataResponse(res, 200, { post: deletedPost }) + } else { + return sendDataResponse(res, 404, { + content: `Post with id ${id} not found` + }) + } + } catch (error) { + return sendDataResponse(res, 500, { + content: 'Internal server error' + }) + } +} + +export const likePost = async (req, res) => { + const { id: postId } = req.params + const userId = req.user.id + + try { + const post = await Post.likePost(Number(postId), userId) + if (post) { + return sendDataResponse(res, 200, { post: post.toJSON() }) + } else { + return sendDataResponse(res, 404, { content: 'Post not found' }) + } + } catch (error) { + return sendDataResponse(res, 500, { content: 'Internal server error' }) + } +} + +export const unlikePost = async (req, res) => { + const { id: postId } = req.params + const userId = req.user.id + + try { + const post = await Post.unlikePost(Number(postId), userId) + if (post) { + return sendDataResponse(res, 200, { post: post.toJSON() }) + } else { + return sendDataResponse(res, 404, { content: 'Post not found' }) + } + } catch (error) { + return sendDataResponse(res, 500, { content: 'Internal server error' }) + } } diff --git a/src/controllers/user.js b/src/controllers/user.js index 40ff0f1c..173f6e9c 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -1,10 +1,11 @@ import User from '../domain/user.js' import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' +/* CREATES A NEW USER */ export const create = async (req, res) => { - const userToCreate = await User.fromJson(req.body) - try { + const userToCreate = await User.fromJson(req.body) + const existingUser = await User.findByEmail(userToCreate.email) if (existingUser) { @@ -15,10 +16,11 @@ export const create = async (req, res) => { return sendDataResponse(res, 201, createdUser) } catch (error) { + console.error('Error creating user:', error) return sendMessageResponse(res, 500, 'Unable to create new user') } } - +/* GETS A USER BY ID */ export const getById = async (req, res) => { const id = parseInt(req.params.id) @@ -35,6 +37,7 @@ export const getById = async (req, res) => { } } +/* GETS ALL USERS */ export const getAll = async (req, res) => { // eslint-disable-next-line camelcase const { first_name: firstName } = req.query @@ -55,13 +58,28 @@ export const getAll = async (req, res) => { return sendDataResponse(res, 200, { users: formattedUsers }) } +/* Updates a user by ID */ export const updateById = async (req, res) => { - const { cohort_id: cohortId } = req.body + const id = parseInt(req.params.id) + const userToUpdate = await User.fromJson(req.body) - if (!cohortId) { - return sendDataResponse(res, 400, { cohort_id: 'Cohort ID is required' }) - } + // Add id, cohortId and role (could be done in the domain) + userToUpdate.id = id + userToUpdate.cohortId = req.body.cohortId + userToUpdate.role = req.body.role - return sendDataResponse(res, 201, { user: { cohort_id: cohortId } }) + try { + if (!userToUpdate.cohortId) { + return sendDataResponse(res, 400, { cohort_id: 'Cohort ID is required' }) + } + const updatedUser = await userToUpdate.update() + + return sendDataResponse(res, 201, updatedUser) + } catch (error) { + console.log(error) + return sendMessageResponse(res, 500, 'Unable to update user') + } } + +/* Test Commit statement */ diff --git a/src/domain/cohort.js b/src/domain/cohort.js index abdda73b..285ff25a 100644 --- a/src/domain/cohort.js +++ b/src/domain/cohort.js @@ -1,27 +1,129 @@ import dbClient from '../utils/dbClient.js' +import User from './user.js' -/** - * Create a new Cohort in the database - * @returns {Cohort} - */ -export async function createCohort() { - const createdCohort = await dbClient.cohort.create({ - data: {} - }) +export default class Cohort { + static fromDb(cohort) { + return new Cohort( + cohort.id, + cohort.cohortName, + cohort.startDate, + cohort.endDate, + cohort.students + ? cohort.students.map((student) => + User.fromDb({ + ...student, + profileImage: student.profile + ? student.profile.profileImage + : null + }) + ) + : [] + ) + } - return new Cohort(createdCohort.id) -} + static async fromJson(json) { + const { cohortId, cohortName, startDate, endDate, students } = json + return new Cohort( + cohortId, + cohortName, + startDate, + endDate, + students ? students.map((student) => User.fromJson(student)) : [] + ) + } -export class Cohort { - constructor(id = null) { + constructor(id, cohortName, startDate, endDate = null, students = []) { this.id = id + this.cohortName = cohortName + this.startDate = startDate + this.endDate = endDate + this.students = students } toJSON() { return { cohort: { - id: this.id + id: this.id, + cohortName: this.cohortName, + startDate: this.startDate, + endDate: this.endDate, + students: this.students.map((student) => student.toJSON()) } } } + + async save() { + const data = { + cohortName: this.cohortName, + startDate: this.startDate, + endDate: this.endDate !== undefined ? this.endDate : null + } + + const createdCohort = await dbClient.cohort.create({ data }) + return Cohort.fromDb(createdCohort) + } + + async update() { + const updatedCohort = await dbClient.cohort.update({ + where: { + id: this.id + }, + data: { + cohortName: this.cohortName, + startDate: this.startDate, + endDate: this.endDate + } + }) + + return Cohort.fromDb(updatedCohort) + } + + static async getAllCohorts() { + const cohorts = await dbClient.cohort.findMany({ + include: { + students: { + include: { + profile: true + } + } + } + }) + return cohorts.map((cohort) => Cohort.fromDb(cohort)) + } + + static async getCohortById(id) { + const cohort = await dbClient.cohort.findUnique({ + where: { id }, + include: { + students: { + include: { + profile: true + } + } + } + }) + return cohort ? Cohort.fromDb(cohort) : null + } + + static async updateById(id, cohort) { + const data = {} + if (cohort.cohortName !== undefined) data.cohortName = cohort.cohortName + if (cohort.startDate !== undefined) data.startDate = cohort.startDate + if (cohort.endDate !== undefined) data.endDate = cohort.endDate + + return this.fromDb( + await dbClient.cohort.update({ + where: { id }, + data + }) + ) + } + + static async deleteById(id) { + return this.fromDb( + await dbClient.cohort.delete({ + where: { id } + }) + ) + } } diff --git a/src/domain/comment.js b/src/domain/comment.js new file mode 100644 index 00000000..171005a3 --- /dev/null +++ b/src/domain/comment.js @@ -0,0 +1,165 @@ +import dbClient from '../utils/dbClient.js' + +export default class Comment { + /** + * Converts a database comment object to a Comment instance + * @param { { id: int, content: string, userId: int, postId: int, user: object, post: object } } comment + * @returns {Comment} + */ + static fromDb(comment) { + return new Comment( + comment.id, + comment.content, + comment.userId, + comment.postId, + comment.post + ) + } + + constructor({ + id = null, + content, + user = null, + post = null, + userId, + postId + }) { + this.id = id + this.content = content + this.userId = userId + this.user = user + this.post = post + this.postId = postId + } + + toJSON() { + return { + comment: { + id: this.id, + content: this.content, + userId: this.userId, + postId: this.postId, + user: this.user, + post: this.post + } + } + } + + /** + * Saves the comment to the database + * @param {string} content + * @param {object} user + * @param {int} postId + * @returns {Comment} + */ + static async createComment(content, user, postId) { + return dbClient.Comment.create({ + data: { + content, + userId: user.id, + postId + } + }) + } + + /** + * Gets a comment by its ID + * @param {int} id + * @returns {Comment} + */ + static async getCommentById(id) { + const comment = await dbClient.comment.findUnique({ + where: { id }, + include: { + user: true, + post: true + } + }) + return comment + ? new Comment({ + id: comment.id, + content: comment.content, + userId: comment.userId, + postId: comment.postId + }) + : null + } + + /** + * Updates the content of a comment by its ID + * @param {int} id + * @param {string} content + * @returns {Comment} + */ + static async updateContentById(id, content, userId) { + return dbClient.comment.update({ + where: { id }, + data: { content, userId } + }) + } + + /** + * Deletes a comment by its ID + * @param {int} id + * @returns {Comment} + */ + static async deleteCommentById(id) { + return dbClient.comment.delete({ + where: { id } + }) + } + + /** + * Finds a comment by its ID + * @param {int} id + * @returns {Comment} + */ + static async findById(id) { + console.log(id) + if (!id || isNaN(id)) { + throw new Error('Invalid comment ID') + } + const comment = await dbClient.comment.findUnique({ + where: { id: parseInt(id, 10) }, + include: { + user: { + include: { profile: true } + }, + post: true + } + }) + + return comment + ? new Comment({ + id: comment.id, + content: comment.content, + userId: comment.userId, + postId: comment.postId, + user: comment.user, + post: comment.post + }) + : null + } + + /** + * Finds comments by post ID + * @param {int} postId + * @returns {Comment[]} + */ + static async findByPostId(postId) { + try { + const foundComments = await dbClient.comment.findMany({ + where: { postId }, + include: { + user: true, + post: true + } + }) + + return foundComments.map((comment) => Comment.fromDb(comment)) + } catch (error) { + console.error('Error finding comments by post ID:', error) + throw new Error('Error finding comments by post ID') + } + } +} diff --git a/src/domain/post.js b/src/domain/post.js new file mode 100644 index 00000000..fd5886cb --- /dev/null +++ b/src/domain/post.js @@ -0,0 +1,213 @@ +import dbClient from '../utils/dbClient.js' + +export default class Post { + constructor( + id = null, + content = '', + user = null, + createdAt = null, + updatedAt = null, + comments = [], + likedBy = [] + ) { + this.id = id + this.content = content + this.user = user + this.createdAt = createdAt + this.updatedAt = updatedAt + this.comments = comments + this.likedBy = likedBy + } + + toJSON() { + return { + id: this.id, + content: this.content, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + author: { + id: this.user.id, + cohortId: this.user.cohortId, + role: this.user.role, + firstName: this.user.profile.firstName, + lastName: this.user.profile.lastName, + bio: this.user.profile.bio, + githubUrl: this.user.profile.githubUrl, + username: this.user.profile.username, + mobile: this.user.profile.mobile, + specialism: this.user.profile.specialism, + startDate: this.user.profile.startDate, + endDate: this.user.profile.endDate, + profileImage: this.user.profile.profileImage + }, + comments: this.comments.map((comment) => ({ + id: comment.id, + content: comment.content + })), + likedBy: this.likedBy.map((user) => ({ + id: user.id, + firstName: user.profile.firstName, + lastName: user.profile.lastName, + email: user.email + })) + } + } + + static async createPost(content, user) { + const post = await dbClient.post.create({ + data: { content: content, userId: user.id }, + include: { + user: { + select: { + id: true, + email: true, + role: true, + cohortId: true, + profile: { + select: { + firstName: true, + lastName: true, + bio: true, + githubUrl: true, + username: true, + mobile: true, + specialism: true, + startDate: true, + endDate: true + } + } + } + } + } + }) + + return post + } + + static async getAllPosts() { + const posts = await dbClient.post.findMany({ + include: { + user: { + include: { profile: true } + }, + likedBy: { + include: { profile: true } + }, + comments: true + } + }) + const returnPosts = posts.map( + (post) => + new Post( + post.id, + post.content, + post.user, + post.createdAt, + post.updatedAt, + post.comments, + post.likedBy + ) + ) + const sortedPosts = returnPosts.sort( + (a, b) => new Date(b.createdAt) - new Date(a.createdAt) + ) + return sortedPosts + } + + static async getPostById(id) { + const post = await dbClient.post.findUnique({ + where: { id }, + include: { + user: { + include: { profile: true } + }, + likedBy: { + include: { profile: true } + }, + comments: true + } + }) + return post + ? new Post( + post.id, + post.content, + post.user, + post.createdAt, + post.updatedAt, + post.comments, + post.likedBy + ) + : null + } + + static async updateContentById(id, content) { + return dbClient.post.update({ + where: { id: id }, + data: { content: content } + }) + } + + static async deletePostById(id) { + return dbClient.post.delete({ + where: { id: id } + }) + } + + static async likePost(postId, userId) { + const updatedPost = await dbClient.post.update({ + where: { id: postId }, + data: { + likedBy: { + connect: { id: userId } + } + }, + include: { + user: { + include: { profile: true } + }, + likedBy: { + include: { profile: true } + }, + comments: true + } + }) + return new Post( + updatedPost.id, + updatedPost.content, + updatedPost.user, + updatedPost.createdAt, + updatedPost.updatedAt, + updatedPost.comments, + updatedPost.likedBy + ) + } + + static async unlikePost(postId, userId) { + const updatedPost = await dbClient.post.update({ + where: { id: postId }, + data: { + likedBy: { + disconnect: { id: userId } + } + }, + include: { + user: { + include: { profile: true } + }, + likedBy: { + include: { profile: true } + }, + comments: true + } + }) + return new Post( + updatedPost.id, + updatedPost.content, + updatedPost.user, + updatedPost.createdAt, + updatedPost.updatedAt, + updatedPost.comments, + updatedPost.likedBy + ) + } +} diff --git a/src/domain/testComment.js b/src/domain/testComment.js new file mode 100644 index 00000000..b0540746 --- /dev/null +++ b/src/domain/testComment.js @@ -0,0 +1,16 @@ +const Comment = require('./path/to/comment') // Adjust the path as necessary + +async function testComment() { + // Create a new comment instance + const comment = new Comment({ + id: 1, + content: 'Initial content', + userId: 1, + postId: 1 + }) + + // Update the comment content + await comment.update('Updated content') +} + +testComment().catch(console.error) diff --git a/src/domain/user.js b/src/domain/user.js index fd7734c7..56f49526 100644 --- a/src/domain/user.js +++ b/src/domain/user.js @@ -7,7 +7,7 @@ export default class User { * take as inputs, what types they return, and other useful information that JS doesn't have built in * @tutorial https://www.valentinog.com/blog/jsdoc * - * @param { { id: int, cohortId: int, email: string, profile: { firstName: string, lastName: string, bio: string, githubUrl: string } } } user + * @param { { id: int, cohortId: int, email: string, role: string, profile: { firstName: string, lastName: string, bio: string, githubUrl: string, username:string, mobile, profileImage: string } } } user * @returns {User} */ static fromDb(user) { @@ -19,38 +19,73 @@ export default class User { user.email, user.profile?.bio, user.profile?.githubUrl, + user.profile?.username, + user.profile?.mobile, + user.profile?.specialism, + user.profile?.startDate, + user.profile?.endDate, user.password, + user.profile?.profileImage, user.role ) } static async fromJson(json) { // eslint-disable-next-line camelcase - const { firstName, lastName, email, biography, githubUrl, password } = json + const { + cohortId, + firstName, + lastName, + email, + bio, + githubUrl, + username, + mobile, + specialism, + startDate, + endDate, + password, + profileImage + } = json - const passwordHash = await bcrypt.hash(password, 8) + let passwordHash = null + if (password) { + passwordHash = await bcrypt.hash(password, 8) + } return new User( null, - null, + cohortId, firstName, lastName, email, - biography, + bio, githubUrl, - passwordHash + username, + mobile, + specialism, + startDate, + endDate, + passwordHash, + profileImage ) } constructor( id, - cohortId, + cohortId = 1, firstName, lastName, email, bio, githubUrl, + username, + mobile, + specialism = 'Software Developer', + startDate = new Date('2023-01-01'), + endDate = new Date('2023-06-30'), passwordHash = null, + profileImage = null, role = 'STUDENT' ) { this.id = id @@ -60,8 +95,14 @@ export default class User { this.email = email this.bio = bio this.githubUrl = githubUrl + this.username = username + this.mobile = mobile + this.specialism = specialism + this.startDate = startDate + this.endDate = endDate this.passwordHash = passwordHash this.role = role + this.profileImage = profileImage } toJSON() { @@ -73,8 +114,14 @@ export default class User { firstName: this.firstName, lastName: this.lastName, email: this.email, - biography: this.bio, - githubUrl: this.githubUrl + bio: this.bio, + githubUrl: this.githubUrl, + profileImage: this.profileImage, + username: this.username, + mobile: this.mobile, + specialism: this.specialism, + startDate: this.startDate, + endDate: this.endDate } } } @@ -87,16 +134,18 @@ export default class User { const data = { email: this.email, password: this.passwordHash, - role: this.role + role: this.role, + cohortId: this.cohortId } - if (this.cohortId) { + // This will break the code currently, needs a fix if you want to include cohort as its own table + /* if (this.cohortId) { data.cohort = { connectOrCreate: { id: this.cohortId } } - } + } */ if (this.firstName && this.lastName) { data.profile = { @@ -104,7 +153,28 @@ export default class User { firstName: this.firstName, lastName: this.lastName, bio: this.bio, - githubUrl: this.githubUrl + githubUrl: this.githubUrl, + profileImage: this.profileImage, + username: this.username, + mobile: this.mobile, + specialism: this.specialism, + startDate: this.startDate, + endDate: this.endDate + } + } + } else { + data.profile = { + create: { + firstName: '', + lastName: '', + bio: this.bio, + githubUrl: this.githubUrl, + profileImage: this.profileImage, + username: this.username, + mobile: this.mobile, + specialism: this.specialism, + startDate: this.startDate, + endDate: this.endDate } } } @@ -118,6 +188,48 @@ export default class User { return User.fromDb(createdUser) } + /** + * @returns {User} + * A user instance containing the updated user data + */ + async update() { + const data = { + email: this.email, + role: this.role, + cohortId: this.cohortId, + profile: { + update: { + firstName: this.firstName, + lastName: this.lastName, + bio: this.bio, + githubUrl: this.githubUrl, + profileImage: this.profileImage, + username: this.username, + mobile: this.mobile, + specialism: this.specialism, + startDate: this.startDate, + endDate: this.endDate + } + } + } + + if (this.passwordHash) { + data.password = this.passwordHash + } + + const updatedUser = await dbClient.user.update({ + where: { + id: this.id + }, + data, + include: { + profile: true + } + }) + + return User.fromDb(updatedUser) + } + static async findByEmail(email) { return User._findByUnique('email', email) } diff --git a/src/middleware/auth.js b/src/middleware/auth.js index baffff47..449ba393 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -4,6 +4,9 @@ import jwt from 'jsonwebtoken' import User from '../domain/user.js' export async function validateTeacherRole(req, res, next) { + if (res.locals.skipTeacherValidation) { + return next() + } if (!req.user) { return sendMessageResponse(res, 500, 'Unable to verify user') } @@ -17,6 +20,34 @@ export async function validateTeacherRole(req, res, next) { next() } +// Function that checks if the currently logged in user is the same +// one that is being requested, if so, we skip the teacher validation +export async function validateLoggedInUser(req, res, next) { + if (!req.user) { + return sendMessageResponse(res, 500, 'Unable to verify user') + } + + if (req.user.id === parseInt(req.params.id)) { + // Skip teacher validation if the user is updating their own profile + res.locals.skipTeacherValidation = true + + // Overwrite the request body with pre-existing values for cohortId and role, + // if the logged in user is a STUDENT + if (req.user.role === 'STUDENT') { + const existingUser = await User.findById(parseInt(req.params.id)) + req.body.cohortId = existingUser.cohortId + req.body.role = existingUser.role + } + } + + // If no password was supplied, skip the password validation + if (!req.body.password) { + res.locals.skipPasswordValidation = true + } + + next() +} + export async function validateAuthentication(req, res, next) { const header = req.header('authorization') diff --git a/src/middleware/post.js b/src/middleware/post.js new file mode 100644 index 00000000..33ed071e --- /dev/null +++ b/src/middleware/post.js @@ -0,0 +1,56 @@ +import { sendDataResponse } from '../utils/responses.js' +import Post from '../domain/post.js' + +export async function validatePostOwnership(req, res, next) { + const { id: postId } = req.params + const { id: userId, role: userRole } = req.user + + try { + const post = await Post.getPostById(Number(postId)) + if (!post) { + return sendDataResponse(res, 404, { content: 'Post not found' }) + } + + if (post.user.id === userId || userRole === 'TEACHER') { + return next() + } + + return sendDataResponse(res, 401, { content: 'Unauthorized' }) + } catch (error) { + return sendDataResponse(res, 500, { content: 'Internal server error' }) + } +} + +export async function validatePostContent(req, res, next) { + const { content } = req.body + const maxLength = 200 + + if (!content || content.trim() === '') { + return sendDataResponse(res, 400, { + content: 'Content cannot be empty or null' + }) + } + + if (content.length > maxLength) { + return sendDataResponse(res, 400, { + content: `Content cannot exceed ${maxLength} characters` + }) + } + + next() +} + +export async function validatePostExists(req, res, next) { + const { id: postId } = req.params + + try { + const post = await Post.getPostById(Number(postId)) + if (!post) { + return sendDataResponse(res, 404, { content: 'Post not found' }) + } + req.post = post + next() + } catch (error) { + return sendDataResponse(res, 500, { content: 'Internal server error' }) + } +} diff --git a/src/middleware/user.js b/src/middleware/user.js new file mode 100644 index 00000000..e41cc518 --- /dev/null +++ b/src/middleware/user.js @@ -0,0 +1,88 @@ +import { sendDataResponse } from '../utils/responses.js' + +export async function validateUser(req, res, next) { + const validateEmail = (email) => { + if ( + email.length < 7 || + email.indexOf('@') <= 0 || + !email.slice(-4, -1).includes('.') || + (email.match(/@/g) || []).length > 1 || + email.charAt(email.length - 5) === '@' + ) { + return 'Email is invalid' + } + return null + } + + const emailError = validateEmail(req.body.email) + if (emailError) { + return sendDataResponse(res, 400, { email: emailError }) + } + + if (res.locals.skipPasswordValidation) { + return next() + } + + const validatePassword = (password) => { + const minLength = 8 + const hasUpperCase = /[A-Z]/.test(password) + const hasNumber = /\d/.test(password) + const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password) + + if (password.length < minLength) { + return 'Password must be at least 8 characters long' + } + if (!hasUpperCase) { + return 'Password must contain at least one uppercase letter' + } + if (!hasNumber) { + return 'Password must contain at least one number' + } + if (!hasSpecialChar) { + return 'Password must contain at least one special character' + } + return null + } + + const passwordError = validatePassword(req.body.password) + if (passwordError) { + return sendDataResponse(res, 400, { password: passwordError }) + } + + next() +} + +export async function validateProfile(req, res, next) { + if (!req.body.firstName) { + return sendDataResponse(res, 400, { firstName: 'First name is required' }) + } + if (!req.body.lastName) { + return sendDataResponse(res, 400, { lastName: 'Last name is required' }) + } + if (!req.body.username) { + return sendDataResponse(res, 400, { username: 'Username is required' }) + } + if (!req.body.githubUrl) { + return sendDataResponse(res, 400, { githubUrl: 'Github URL is required' }) + } + if (!req.body.mobile) { + return sendDataResponse(res, 400, { mobile: 'Mobile is required' }) + } + if (!req.body.specialism) { + return sendDataResponse(res, 400, { specialism: 'Specialism is required' }) + } + if (!req.body.startDate) { + return sendDataResponse(res, 400, { startDate: 'Start date is required' }) + } + if (!req.body.endDate) { + return sendDataResponse(res, 400, { endDate: 'End date is required' }) + } + if (!req.body.role && req.user.role === 'TEACHER') { + return sendDataResponse(res, 400, { role: 'Role is required' }) + } + if (!req.body.cohortId && req.user.role === 'TEACHER') { + return sendDataResponse(res, 400, { cohortId: 'Cohort ID is required' }) + } + + next() +} diff --git a/src/routes/cohort.js b/src/routes/cohort.js index 3cc7813d..dcb40dde 100644 --- a/src/routes/cohort.js +++ b/src/routes/cohort.js @@ -1,5 +1,11 @@ import { Router } from 'express' -import { create } from '../controllers/cohort.js' +import { + create, + getAll, + getById, + updateById, + deleteById +} from '../controllers/cohort.js' import { validateAuthentication, validateTeacherRole @@ -8,5 +14,9 @@ import { const router = Router() router.post('/', validateAuthentication, validateTeacherRole, create) +router.get('/', validateAuthentication, getAll) +router.get('/:id', validateAuthentication, getById) +router.patch('/:id', validateAuthentication, validateTeacherRole, updateById) +router.delete('/:id', validateAuthentication, validateTeacherRole, deleteById) export default router diff --git a/src/routes/comment.js b/src/routes/comment.js new file mode 100644 index 00000000..2e6b8948 --- /dev/null +++ b/src/routes/comment.js @@ -0,0 +1,11 @@ +import { Router } from 'express' +import { create, remove, update } from '../controllers/comment.js' +import { validateAuthentication } from '../middleware/auth.js' + +const router = Router() + +router.post('/', validateAuthentication, create) +router.patch('/:id', validateAuthentication, update) +router.delete('/:id', validateAuthentication, remove) + +export default router diff --git a/src/routes/post.js b/src/routes/post.js index a7fbbfb3..9093deb6 100644 --- a/src/routes/post.js +++ b/src/routes/post.js @@ -1,10 +1,39 @@ import { Router } from 'express' -import { create, getAll } from '../controllers/post.js' +import { + create, + getAll, + updateById, + getById, + deleteById, + likePost, + unlikePost +} from '../controllers/post.js' import { validateAuthentication } from '../middleware/auth.js' +import { + validatePostOwnership, + validatePostContent, + validatePostExists +} from '../middleware/post.js' const router = Router() -router.post('/', validateAuthentication, create) +router.post('/', validateAuthentication, validatePostContent, create) router.get('/', validateAuthentication, getAll) +router.get('/:id', validateAuthentication, getById) +router.patch( + '/:id', + validateAuthentication, + validatePostOwnership, + validatePostContent, + updateById +) +router.delete('/:id', validateAuthentication, validatePostOwnership, deleteById) +router.post('/:id/like', validateAuthentication, validatePostExists, likePost) +router.post( + '/:id/unlike', + validateAuthentication, + validatePostExists, + unlikePost +) export default router diff --git a/src/routes/user.js b/src/routes/user.js index 9f63d162..5f616892 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -2,14 +2,24 @@ import { Router } from 'express' import { create, getById, getAll, updateById } from '../controllers/user.js' import { validateAuthentication, - validateTeacherRole + validateTeacherRole, + validateLoggedInUser } from '../middleware/auth.js' +import { validateProfile, validateUser } from '../middleware/user.js' const router = Router() -router.post('/', create) +router.post('/', validateUser, create) router.get('/', validateAuthentication, getAll) router.get('/:id', validateAuthentication, getById) -router.patch('/:id', validateAuthentication, validateTeacherRole, updateById) +router.patch( + '/:id', + validateAuthentication, + validateLoggedInUser, + validateUser, + validateProfile, + validateTeacherRole, + updateById +) export default router diff --git a/src/server.js b/src/server.js index a3f67eeb..2f89075e 100644 --- a/src/server.js +++ b/src/server.js @@ -7,6 +7,7 @@ import cors from 'cors' import userRouter from './routes/user.js' import postRouter from './routes/post.js' import authRouter from './routes/auth.js' +import commentRouter from './routes/comment.js' import cohortRouter from './routes/cohort.js' import deliveryLogRouter from './routes/deliveryLog.js' @@ -24,6 +25,7 @@ app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDoc)) app.use('/users', userRouter) app.use('/posts', postRouter) app.use('/cohorts', cohortRouter) +app.use('/comments', commentRouter) app.use('/logs', deliveryLogRouter) app.use('/', authRouter)