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..6b8e923c 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: @@ -88,7 +88,6 @@ paths: '400': description: Invalid username/password supplied - /users/{id}: get: tags: @@ -183,18 +182,78 @@ 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: you can delete your own post + 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: + schema: + $ref: '#/components/schemas/Error' get: tags: - post @@ -204,13 +263,13 @@ paths: security: - bearerAuth: [] responses: - '200': + 200: description: Successful operation content: application/json: schema: $ref: '#/components/schemas/Posts' - '401': + 401: description: fail content: application/json: @@ -285,7 +344,22 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - + /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' components: securitySchemes: bearerAuth: @@ -405,11 +479,6 @@ components: type: integer content: type: string - createdAt: - type: string - format: string - updatedAt: - type: string format: string author: type: object @@ -430,7 +499,79 @@ components: type: string profileImageUrl: type: string - + comments: + type: array + items: + type: object + properties: + id: + type: integer + postId: + type: integer + userId: + type: integer + content: + type: string + format: string + author: + type: object + properties: + firstName: + type: string + lastName: + type: string + createdAt: + type: string + format: string + updatedAt: + type: string + format: 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 + CreatedUser: type: object properties: 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/schema.prisma b/prisma/schema.prisma index 72ec5632..6824ac6b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,53 +17,91 @@ enum Role { } 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? + cohortId Int? + cohort Cohort? @relation(fields: [cohortId], references: [id]) + posts Post[] + comments Comment[] + likes Like[] + deliveryLogs DeliveryLog[] + 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? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Cohort { - id Int @id @default(autoincrement()) - users User[] - deliveryLogs DeliveryLog[] + id Int @id @default(autoincrement()) + users User[] + deliveryLogs DeliveryLog[] + 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]) + 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]) + 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 } + diff --git a/prisma/seed.js b/prisma/seed.js index e6c288f2..70df48ff 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -1,74 +1,115 @@ 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 cohort = await createCohort() - 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 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' + ) - await createPost(student.id, 'My first post!') - await createPost(teacher.id, 'Hello, students') + await createPost( + student.id, + 'My first post!', + [ + { content: 'hi', userId: 2 }, + { content: "'sup?", userId: 2 } + ], + [{ userId: 2 }] + ) + await createPost(teacher.id, 'Hello, students', [], [{ userId: 1 }]) - 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: {} - }) + const cohort = await prisma.cohort.create({ + data: {} + }) - 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 - } - } - }, - include: { - profile: true +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 } - }) + } + }, + include: { + profile: true + } + }) - console.info(`${role} created`, user) + console.info(`${role} created`, user) - return user + 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/comment.js b/src/controllers/comment.js new file mode 100644 index 00000000..eb760e17 --- /dev/null +++ b/src/controllers/comment.js @@ -0,0 +1,7 @@ +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 }) +} diff --git a/src/controllers/comments.js b/src/controllers/comments.js new file mode 100644 index 00000000..e6cb241d --- /dev/null +++ b/src/controllers/comments.js @@ -0,0 +1,13 @@ +import { sendDataResponse } from '../utils/responses.js' + +// DB +import { createCommentDb } from '../domain/comments.js' + +export const createComment = async (req, res) => { + const { postId, content } = req.body + const userId = req.user.id + + const createdComment = await createCommentDb({ userId, postId, content }) + + return sendDataResponse(res, 201, createdComment) +} diff --git a/src/controllers/post.js b/src/controllers/post.js index 7b168039..ba1503e0 100644 --- a/src/controllers/post.js +++ b/src/controllers/post.js @@ -1,28 +1,89 @@ +import { + createPost, + getPosts, + deletePostByIdAndUserId, + updatePostByIdAndUserId, + toggleLike +} 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 createPost(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 getPosts() + return sendDataResponse(res, 200, { posts }) +} + +export const deletePost = async (req, res) => { + const postId = Number(req.params.postId) + const userId = req.user.id + + try { + const result = await deletePostByIdAndUserId(postId, userId) + if (result && result.error) { + return sendDataResponse(res, result.status, { error: result.error }) + } else { + return sendDataResponse(res, 200, { + message: 'Post deleted successfully' + }) + } + } catch (error) { + console.error('Error deleting post:', error) + return sendDataResponse(res, 500, { error: 'Something went wrong' }) + } +} + +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 updatePostByIdAndUserId(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 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/user.js b/src/controllers/user.js index 40ff0f1c..284adae4 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -8,13 +8,14 @@ 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, { email: 'Email already in use' }) } const createdUser = await userToCreate.save() return sendDataResponse(res, 201, createdUser) } catch (error) { + console.error('Error creating user', error) return sendMessageResponse(res, 500, 'Unable to create new user') } } @@ -36,13 +37,11 @@ export const getById = async (req, res) => { } 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() } diff --git a/src/domain/comment.js b/src/domain/comment.js new file mode 100644 index 00000000..cb0922c1 --- /dev/null +++ b/src/domain/comment.js @@ -0,0 +1,55 @@ +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.post.id, + comment.user.id, + comment.createdAt, + author, + comment.updatedAt + ) + } + + static async _findMany() { + const comments = await dbClient.comment.findMany({ + include: { + user: { + include: { + profile: true + } + }, + post: { + select: { id: true } + } + } + }) + return comments + } + + static async getAll() { + const comments = await Comment._findMany() + const newCommentList = comments.map(Comment.fromDb) + return newCommentList + } +} diff --git a/src/domain/comments.js b/src/domain/comments.js new file mode 100644 index 00000000..ab82c921 --- /dev/null +++ b/src/domain/comments.js @@ -0,0 +1,21 @@ +import dbClient from '../utils/dbClient.js' + +export const createCommentDb = async ({ userId, postId, content }) => { + const createdComment = await dbClient.comment.create({ + data: { + content, + user: { + connect: { + id: Number(userId) + } + }, + post: { + connect: { + id: Number(postId) + } + } + } + }) + + return createdComment +} diff --git a/src/domain/post.js b/src/domain/post.js new file mode 100644 index 00000000..9cdd61ea --- /dev/null +++ b/src/domain/post.js @@ -0,0 +1,110 @@ +import dbClient from '../utils/dbClient.js' + +export async function createPost(content, userId) { + const createdPost = await dbClient.post.create({ + data: { + content, + user: { + connect: { + id: userId + } + } + }, + include: { + user: true + } + }) + return createdPost +} + +export async function getPosts() { + const posts = await dbClient.post.findMany({ + include: { + user: { + include: { + profile: true + } + }, + comments: 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 { + id: post.id, + content: post.content, + createdAt: post.createdAt, + updatedAt: post.updatedAt, + userId: post.user.id, + comments: post.comments, + likes: post.likes, + author: { + firstName: profile.firstName, + lastName: profile.lastName + } + } + }) +} + +export async function deletePostByIdAndUserId(postId, userId) { + 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 delete this post', status: 403 } + } + + await dbClient.post.delete({ where: { id: postId } }) + return { message: 'Post deleted successfully' } +} + +export async function updatePostByIdAndUserId(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 } + }) + + return { post: updatedPost } +} + +export async function 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.' +} diff --git a/src/domain/user.js b/src/domain/user.js index fd7734c7..bffa5f77 100644 --- a/src/domain/user.js +++ b/src/domain/user.js @@ -130,6 +130,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 +204,23 @@ 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)) + } } diff --git a/src/middleware/commentErrors.js b/src/middleware/commentErrors.js new file mode 100644 index 00000000..6c0463da --- /dev/null +++ b/src/middleware/commentErrors.js @@ -0,0 +1,19 @@ +const errorCreator = (message, status) => { + const error = new Error(message) + error.status = status + return error +} + +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() + } +} diff --git a/src/routes/comment.js b/src/routes/comment.js new file mode 100644 index 00000000..921f0800 --- /dev/null +++ b/src/routes/comment.js @@ -0,0 +1,9 @@ +import { Router } from 'express' +import { getComments } from '../controllers/comment.js' +import { validateAuthentication } from '../middleware/auth.js' + +const router = Router() + +router.get('/', validateAuthentication, getComments) + +export default router diff --git a/src/routes/comments.js b/src/routes/comments.js new file mode 100644 index 00000000..4088b5f7 --- /dev/null +++ b/src/routes/comments.js @@ -0,0 +1,17 @@ +import { Router } from 'express' +import { createComment } from '../controllers/comments.js' + +// Error handlers +import { checkFields } from '../middleware/commentErrors.js' +import { validateAuthentication } from '../middleware/auth.js' + +const router = Router() + +router.post( + '/', + validateAuthentication, + checkFields(['postId', 'content']), + createComment +) + +export default router diff --git a/src/routes/post.js b/src/routes/post.js index a7fbbfb3..0015dc40 100644 --- a/src/routes/post.js +++ b/src/routes/post.js @@ -1,10 +1,19 @@ 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' 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, deletePost) export default router diff --git a/src/server.js b/src/server.js index a3f67eeb..e2e55e8b 100644 --- a/src/server.js +++ b/src/server.js @@ -4,15 +4,19 @@ 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 commentsRouter from './routes/comments.js' import deliveryLogRouter from './routes/deliveryLog.js' +import commentRouter from './routes/comment.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 })) @@ -25,7 +29,18 @@ app.use('/users', userRouter) app.use('/posts', postRouter) app.use('/cohorts', cohortRouter) app.use('/logs', deliveryLogRouter) +app.use('/comments', commentsRouter) app.use('/', authRouter) +app.use('/comments', commentRouter) + +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({