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/docs/openapi.yml b/docs/openapi.yml index 5f2a05f2..8f3410d7 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -35,7 +35,7 @@ paths: get: tags: - user - summary: Get all users by first name if provided + summary: Get all users by first name or last name if provided description: '' operationId: getAllUsers security: @@ -43,7 +43,12 @@ paths: parameters: - name: firstName in: query - description: Search all users by first name if provided (case-sensitive and exact string matches only) + description: Search all users by first name if provided (case-insensitive) + schema: + type: string + - name: lastName + in: query + description: Search all users by last name if provided (case-insensitive) schema: type: string responses: @@ -134,7 +139,12 @@ paths: tags: - user summary: Update a user - description: Only users with a TEACHER role can update the cohortId or role. Users with Students role can only update their own details. + description: | + Updates user profile information with the following rules: + - Users can only update their own profile unless they have TEACHER role + - Only TEACHER role can update cohortId and role fields + - All fields are optional and only specified valid fields will be updated + - All fields must pass validation patterns (names, email, password, etc.) operationId: userUpdate security: - bearerAuth: [] @@ -146,24 +156,132 @@ paths: schema: type: string requestBody: - description: The profile info + description: The profile info - all fields are optional content: application/json: schema: $ref: '#/components/schemas/UpdateUser' responses: - '201': - description: Successful operation + '200': + description: User successfully updated content: application/json: schema: $ref: '#/components/schemas/CreatedUser' + '400': + description: Validation error or no valid fields provided + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '401': - description: fail + description: Unauthorized - Invalid or missing authentication + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Forbidden - Insufficient permissions or attempting to modify restricted fields + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: User not found content: application/json: schema: $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /users/{id}/progress: + get: + tags: + - User + summary: Retrieve user progress + description: Returns progress data for a user, accessible by the user or a teacher. + operationId: getUserProgress + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + description: The ID of the user whose progress is being retrieved. + schema: + type: string + responses: + '200': + description: Successfully retrieved user progress. + content: + application/json: + schema: + type: object + properties: + CompletedModules: + type: object + properties: + completed: + type: integer + example: 2 + total: + type: integer + example: 7 + description: "Number of modules completed out of the total modules." + CompletedUnits: + type: object + properties: + completed: + type: integer + example: 4 + total: + type: integer + example: 10 + description: "Number of units completed out of the total units." + CompletedExercises: + type: object + properties: + completed: + type: integer + example: 34 + total: + type: integer + example: 58 + description: "Number of exercises completed out of the total exercises." + '403': + description: Access denied - user does not have permission to view this progress. + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'Access Denied: You can only view your own or your students progress.' + '401': + description: Unauthorized - Missing or invalid authentication token. + content: + application/json: + schema: + type: object + properties: + authorization: + type: string + example: 'Missing Authorization header' + '500': + description: Internal Server Error + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Internal Server Error: Unable to retrieve user progress' /posts: post: tags: @@ -216,6 +334,102 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /posts/{id}: + patch: + tags: + - post + summary: Update a post + description: Only the author of the post can update it. + operationId: postEdit + security: + - bearerAuth: [] + parameters: + - name: id + in: path + description: 'The post id that needs to be updated' + required: true + schema: + type: string + requestBody: + description: The post info + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePost' + responses: + '201': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + '401': + description: Unauthorized access to edit post + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Post not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /posts/{id}/toggle-like: + post: + tags: + - post + summary: Toggle like on a post + description: Like or unlike a post. If the post is already liked by the user, it will unlike it, and vice versa. Requires authentication. + operationId: togglePostLike + security: + - bearerAuth: [] + parameters: + - name: id + in: path + description: ID of the post to toggle like + required: true + schema: + type: integer + responses: + '200': + description: Successfully toggled like status + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + data: + type: object + properties: + liked: + type: boolean + description: Indicates whether the post is now liked (true) or unliked (false) + likeCount: + type: integer + description: The updated total number of likes for the post + '401': + description: Unauthorized - User must be logged in + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Post not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /logs: post: tags: @@ -285,6 +499,169 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /cohorts/{id}/members: + get: + tags: + - cohort + summary: Get members of a specific cohort + description: Returns all members (teachers and students) of a specific cohort. Teachers can access any cohort, students can only access their own cohort. + operationId: getCohortMembers + security: + - bearerAuth: [] + parameters: + - name: id + in: path + description: ID of the cohort to get members from + required: true + schema: + type: integer + responses: + '200': + description: Successful operation + content: + application/json: + schema: + type: object + properties: + status: + type: string + data: + type: object + properties: + cohort: + type: object + properties: + id: + type: integer + type: + type: string + members: + type: object + properties: + students: + type: array + items: + type: object + properties: + id: + type: integer + firstName: + type: string + lastName: + type: string + teachers: + type: array + items: + type: object + properties: + id: + type: integer + firstName: + type: string + lastName: + type: string + '403': + description: Unauthorized access to cohort + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: No members found in cohort + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /posts/{postId}/comments: + post: + tags: + - post + summary: Create a comment on a post + description: Add a new comment to a specific post. Requires authentication. + operationId: createComment + security: + - bearerAuth: [] + parameters: + - name: postId + in: path + description: ID of the post to comment on + required: true + schema: + type: integer + requestBody: + description: Comment content + required: true + content: + application/json: + schema: + type: object + properties: + content: + type: string + description: The content of the comment + responses: + '201': + description: Comment created successfully + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + data: + type: object + properties: + comment: + type: object + properties: + id: + type: integer + content: + type: string + createdAt: + type: string + format: date-time + author: + type: object + properties: + id: + type: integer + firstName: + type: string + lastName: + type: string + '400': + description: Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Unauthorized - User must be logged in + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Post not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' components: securitySchemes: @@ -337,16 +714,18 @@ components: properties: id: type: integer - email: - type: string - role: - type: string cohortId: type: integer + role: + type: string firstName: type: string lastName: type: string + userName: + type: string + email: + type: string bio: type: string githubUrl: @@ -359,6 +738,8 @@ components: type: string lastName: type: string + userName: + type: string email: type: string bio: @@ -368,25 +749,39 @@ components: password: type: string + UpdatePost: + type: object + properties: + content: + type: string + UpdateUser: type: object properties: email: type: string + nullable: true password: type: string + nullable: true cohortId: type: integer + nullable: true role: type: string + nullable: true firstName: type: string + nullable: true lastName: type: string + nullable: true bio: type: string + nullable: true githubUrl: type: string + nullable: true Posts: type: object @@ -407,30 +802,31 @@ components: type: string createdAt: type: string - format: string - updatedAt: - type: string - format: string + format: date-time author: type: object properties: id: type: integer - cohortId: - type: integer - role: - type: string firstName: type: string lastName: type: string - bio: - type: string - githubUrl: - type: string - profileImageUrl: - type: string - + cohort: + type: object + properties: + id: + type: integer + type: + type: string + stats: + type: object + properties: + likes: + type: integer + comments: + type: integer + CreatedUser: type: object properties: @@ -443,8 +839,6 @@ components: properties: id: type: integer - email: - type: string cohortId: type: integer role: @@ -453,6 +847,10 @@ components: type: string lastName: type: string + userName: + type: string + email: + type: string bio: type: string githubUrl: @@ -535,3 +933,18 @@ components: type: integer content: type: string + + ToggleLikeResponse: + type: object + properties: + status: + type: string + data: + type: object + properties: + liked: + type: boolean + description: Indicates whether the post is now liked (true) or unliked (false) + likeCount: + type: integer + description: The updated total number of likes for the post diff --git a/package-lock.json b/package-lock.json index 044145e5..5886dc3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "express": "^4.17.3", "jsonwebtoken": "^8.5.1", "swagger-ui-express": "^5.0.0", + "validator": "^13.12.0", "yaml": "^2.3.4" }, "devDependencies": { @@ -3491,6 +3492,15 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -6155,6 +6165,11 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==" + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index b5997d22..f5f04abe 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "express": "^4.17.3", "jsonwebtoken": "^8.5.1", "swagger-ui-express": "^5.0.0", + "validator": "^13.12.0", "yaml": "^2.3.4" } } diff --git a/prisma/migrations/20220407150157_create_user_profile/migration.sql b/prisma/migrations/20220407150157_create_user_profile/migration.sql deleted file mode 100644 index ac6a38a8..00000000 --- a/prisma/migrations/20220407150157_create_user_profile/migration.sql +++ /dev/null @@ -1,29 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" SERIAL NOT NULL, - "email" TEXT NOT NULL, - "password" TEXT NOT NULL, - - CONSTRAINT "User_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Profile" ( - "id" SERIAL NOT NULL, - "userId" INTEGER NOT NULL, - "firstName" TEXT NOT NULL, - "lastName" TEXT NOT NULL, - "bio" TEXT, - "githubUrl" TEXT, - - CONSTRAINT "Profile_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); - --- CreateIndex -CREATE UNIQUE INDEX "Profile_userId_key" ON "Profile"("userId"); - --- AddForeignKey -ALTER TABLE "Profile" ADD CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20220408083401_add_cohort_model/migration.sql b/prisma/migrations/20220408083401_add_cohort_model/migration.sql deleted file mode 100644 index a052ca37..00000000 --- a/prisma/migrations/20220408083401_add_cohort_model/migration.sql +++ /dev/null @@ -1,12 +0,0 @@ --- AlterTable -ALTER TABLE "User" ADD COLUMN "cohortId" INTEGER; - --- CreateTable -CREATE TABLE "Cohort" ( - "id" SERIAL NOT NULL, - - CONSTRAINT "Cohort_pkey" PRIMARY KEY ("id") -); - --- AddForeignKey -ALTER TABLE "User" ADD CONSTRAINT "User_cohortId_fkey" FOREIGN KEY ("cohortId") REFERENCES "Cohort"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20220408085613_add_user_role/migration.sql b/prisma/migrations/20220408085613_add_user_role/migration.sql deleted file mode 100644 index 306fa4b0..00000000 --- a/prisma/migrations/20220408085613_add_user_role/migration.sql +++ /dev/null @@ -1,5 +0,0 @@ --- CreateEnum -CREATE TYPE "Role" AS ENUM ('STUDENT', 'TEACHER'); - --- AlterTable -ALTER TABLE "User" ADD COLUMN "role" "Role" NOT NULL DEFAULT E'STUDENT'; diff --git a/prisma/migrations/20220408104349_add_post_model/migration.sql b/prisma/migrations/20220408104349_add_post_model/migration.sql deleted file mode 100644 index 0be857f3..00000000 --- a/prisma/migrations/20220408104349_add_post_model/migration.sql +++ /dev/null @@ -1,11 +0,0 @@ --- CreateTable -CREATE TABLE "Post" ( - "id" SERIAL NOT NULL, - "content" TEXT NOT NULL, - "userId" INTEGER NOT NULL, - - CONSTRAINT "Post_pkey" PRIMARY KEY ("id") -); - --- AddForeignKey -ALTER TABLE "Post" ADD CONSTRAINT "Post_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20220408160514_add_delivery_log_models/migration.sql b/prisma/migrations/20220408160514_add_delivery_log_models/migration.sql deleted file mode 100644 index 3bf9dece..00000000 --- a/prisma/migrations/20220408160514_add_delivery_log_models/migration.sql +++ /dev/null @@ -1,27 +0,0 @@ --- CreateTable -CREATE TABLE "DeliveryLog" ( - "id" SERIAL NOT NULL, - "date" TIMESTAMP(3) NOT NULL, - "userId" INTEGER NOT NULL, - "cohortId" INTEGER NOT NULL, - - CONSTRAINT "DeliveryLog_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "DeliveryLogLine" ( - "id" SERIAL NOT NULL, - "content" TEXT NOT NULL, - "logId" INTEGER NOT NULL, - - CONSTRAINT "DeliveryLogLine_pkey" PRIMARY KEY ("id") -); - --- AddForeignKey -ALTER TABLE "DeliveryLog" ADD CONSTRAINT "DeliveryLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "DeliveryLog" ADD CONSTRAINT "DeliveryLog_cohortId_fkey" FOREIGN KEY ("cohortId") REFERENCES "Cohort"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "DeliveryLogLine" ADD CONSTRAINT "DeliveryLogLine_logId_fkey" FOREIGN KEY ("logId") REFERENCES "DeliveryLog"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250227083654_migration_1/migration.sql b/prisma/migrations/20250227083654_migration_1/migration.sql new file mode 100644 index 00000000..bb675b66 --- /dev/null +++ b/prisma/migrations/20250227083654_migration_1/migration.sql @@ -0,0 +1,130 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('STUDENT', 'TEACHER'); + +-- CreateEnum +CREATE TYPE "CohortType" AS ENUM ('SOFTWARE_DEVELOPMENT', 'FRONTEND_DEVELOPMENT', 'DATA_ANALYTICS'); + +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "role" "Role" NOT NULL DEFAULT E'STUDENT', + "cohortId" INTEGER, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Profile" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "bio" TEXT, + "githubUrl" TEXT, + + CONSTRAINT "Profile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Cohort" ( + "id" SERIAL NOT NULL, + "type" "CohortType" NOT NULL DEFAULT E'SOFTWARE_DEVELOPMENT', + + CONSTRAINT "Cohort_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Post" ( + "id" SERIAL NOT NULL, + "content" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Post_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PostLike" ( + "id" SERIAL NOT NULL, + "postId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PostLike_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PostComment" ( + "id" SERIAL NOT NULL, + "content" TEXT NOT NULL, + "postId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PostComment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeliveryLog" ( + "id" SERIAL NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "userId" INTEGER NOT NULL, + "cohortId" INTEGER NOT NULL, + + CONSTRAINT "DeliveryLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeliveryLogLine" ( + "id" SERIAL NOT NULL, + "content" TEXT NOT NULL, + "logId" INTEGER NOT NULL, + + CONSTRAINT "DeliveryLogLine_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Profile_userId_key" ON "Profile"("userId"); + +-- CreateIndex +CREATE INDEX "Post_createdAt_idx" ON "Post"("createdAt" DESC); + +-- CreateIndex +CREATE UNIQUE INDEX "PostLike_postId_userId_key" ON "PostLike"("postId", "userId"); + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_cohortId_fkey" FOREIGN KEY ("cohortId") REFERENCES "Cohort"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Profile" ADD CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Post" ADD CONSTRAINT "Post_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PostLike" ADD CONSTRAINT "PostLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PostLike" ADD CONSTRAINT "PostLike_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PostComment" ADD CONSTRAINT "PostComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PostComment" ADD CONSTRAINT "PostComment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeliveryLog" ADD CONSTRAINT "DeliveryLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeliveryLog" ADD CONSTRAINT "DeliveryLog_cohortId_fkey" FOREIGN KEY ("cohortId") REFERENCES "Cohort"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeliveryLogLine" ADD CONSTRAINT "DeliveryLogLine_logId_fkey" FOREIGN KEY ("logId") REFERENCES "DeliveryLog"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250227120848_first/migration.sql b/prisma/migrations/20250227120848_first/migration.sql new file mode 100644 index 00000000..65a8a40b --- /dev/null +++ b/prisma/migrations/20250227120848_first/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - You are about to drop the `Profile` table. If the table is not empty, all the data it contains will be lost. + - Added the required column `cohortNumber` to the `Cohort` table without a default value. This is not possible if the table is not empty. + - Added the required column `firstName` to the `User` table without a default value. This is not possible if the table is not empty. + - Added the required column `lastName` to the `User` table without a default value. This is not possible if the table is not empty. + - Added the required column `userName` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "Profile" DROP CONSTRAINT "Profile_userId_fkey"; + +-- AlterTable +ALTER TABLE "Cohort" ADD COLUMN "cohortNumber" INTEGER NOT NULL; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "bio" TEXT, +ADD COLUMN "firstName" TEXT NOT NULL, +ADD COLUMN "githubUrl" TEXT, +ADD COLUMN "lastName" TEXT NOT NULL, +ADD COLUMN "userName" TEXT NOT NULL; + +-- DropTable +DROP TABLE "Profile"; diff --git a/prisma/migrations/20250227125913_add_comment_length_limit/migration.sql b/prisma/migrations/20250227125913_add_comment_length_limit/migration.sql new file mode 100644 index 00000000..03cf0f91 --- /dev/null +++ b/prisma/migrations/20250227125913_add_comment_length_limit/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to alter the column `content` on the `PostComment` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(2560)`. + +*/ +-- AlterTable +ALTER TABLE "PostComment" ALTER COLUMN "content" SET DATA TYPE VARCHAR(2560); diff --git a/prisma/migrations/20250228080802_add_mobile_field/migration.sql b/prisma/migrations/20250228080802_add_mobile_field/migration.sql new file mode 100644 index 00000000..c964a50a --- /dev/null +++ b/prisma/migrations/20250228080802_add_mobile_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "mobile" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 72ec5632..fff962c9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3,6 +3,7 @@ generator client { provider = "prisma-client-js" + previewFeatures = ["extendedIndexes"] } datasource db { @@ -20,35 +21,68 @@ model User { id Int @id @default(autoincrement()) email String @unique password String + userName String + firstName String + lastName String + mobile String? + githubUrl String? + bio String? role Role @default(STUDENT) - profile Profile? cohortId Int? cohort Cohort? @relation(fields: [cohortId], references: [id]) posts Post[] deliveryLogs DeliveryLog[] + postLikes PostLike[] + postComments PostComment[] } -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? +enum CohortType { + SOFTWARE_DEVELOPMENT + FRONTEND_DEVELOPMENT + DATA_ANALYTICS } model Cohort { id Int @id @default(autoincrement()) + type CohortType @default(SOFTWARE_DEVELOPMENT) users User[] + cohortNumber Int deliveryLogs DeliveryLog[] } 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]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + likes PostLike[] + comments PostComment[] + + @@index([createdAt(sort: Desc)]) +} + +model PostLike { + id Int @id @default(autoincrement()) + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + postId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + createdAt DateTime @default(now()) + + @@unique([postId, userId]) +} + +model PostComment { + id Int @id @default(autoincrement()) + content String @db.VarChar(2560) + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + postId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model DeliveryLog { diff --git a/prisma/seed.js b/prisma/seed.js index 21684795..bda9ce53 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -3,30 +3,101 @@ import bcrypt from 'bcrypt' const prisma = new PrismaClient() async function seed() { - const cohort = await createCohort() + // Create 3 cohorts + const cohort1 = await createCohort() + const cohort2 = await createCohort() + const cohort3 = await createCohort() - const student = await createUser( - 'student@test.com', + // Create 3 students + const student1 = await createUser( + 'student1@test.com', 'Testpassword1!', - cohort.id, - 'Joe', - 'Bloggs', - 'Hello, world!', + cohort1.id, + 'student1username', + 'firstName1', + 'lastName1', + 'Student 1 bio', 'student1' ) - const teacher = await createUser( - 'teacher@test.com', + const student2 = await createUser( + 'student2@test.com', + 'Testpassword1!', + cohort2.id, + 'student2username', + 'firstName2', + 'lastName2', + 'Student 2 bio', + 'student2' + ) + const student3 = await createUser( + 'student3@test.com', + 'Testpassword1!', + cohort3.id, + 'student3username', + 'firstName3', + 'lastName3', + 'Student 3 bio', + 'student3' + ) + // Create 3 teachers + const teacher1 = await createUser( + 'teacher1@test.com', 'Testpassword1!', null, - 'Rick', - 'Sanchez', - 'Hello there!', + 'teacher1username', + 'teacherFirst1', + 'teacherLast1', + 'Teacher 1 bio', 'teacher1', 'TEACHER' ) + const teacher2 = await createUser( + 'teacher2@test.com', + 'Testpassword1!', + null, + 'teacher2username', + 'teacherFirst2', + 'teacherLast2', + 'Teacher 2 bio', + 'teacher2', + 'TEACHER' + ) + const teacher3 = await createUser( + 'teacher3@test.com', + 'Testpassword1!', + null, + 'teacher3username', + 'teacherFirst3', + 'teacherLast3', + 'Teacher 3 bio', + 'teacher3', + 'TEACHER' + ) - await createPost(student.id, 'My first post!') - await createPost(teacher.id, 'Hello, students') + // Create 3 posts for each user + await createPost(student1.id, 'Student 1 first post!') + await createPost(student1.id, 'Student 1 second post!') + await createPost(student1.id, 'Student 1 third post!') + + await createPost(student2.id, 'Student 2 first post!') + await createPost(student2.id, 'Student 2 second post!') + await createPost(student2.id, 'Student 2 third post!') + + await createPost(student3.id, 'Student 3 first post!') + await createPost(student3.id, 'Student 3 second post!') + await createPost(student3.id, 'Student 3 third post!') + + await createPost(teacher1.id, 'Teacher 1 first post!') + await createPost(teacher1.id, 'Teacher 1 second post!') + await createPost(teacher1.id, 'Teacher 1 third post!') + + await createPost(teacher2.id, 'Teacher 2 first post!') + await createPost(teacher2.id, 'Teacher 2 second post!') + await createPost(teacher2.id, 'Teacher 2 third post!') + + await createPost(teacher3.id, 'Teacher 3 first post!') + await createPost(teacher3.id, 'Teacher 3 second post!') + await createPost(teacher3.id, 'Teacher 3 third post!') process.exit(0) } @@ -35,10 +106,23 @@ async function createPost(userId, content) { const post = await prisma.post.create({ data: { userId, - content + content, + likes: { + create: [{ userId: userId === 1 ? 2 : 1 }] + }, + comments: { + create: [ + { + content: 'Great post!', + userId: userId === 1 ? 2 : 1 + } + ] + } }, include: { - user: true + user: true, + likes: true, + comments: true } }) @@ -49,7 +133,10 @@ async function createPost(userId, content) { async function createCohort() { const cohort = await prisma.cohort.create({ - data: {} + data: { + type: 'SOFTWARE_DEVELOPMENT', + cohortNumber: 1 + } }) console.info('Cohort created', cohort) @@ -61,6 +148,7 @@ async function createUser( email, password, cohortId, + userName, firstName, lastName, bio, @@ -71,19 +159,13 @@ async function createUser( data: { email, password: await bcrypt.hash(password, 8), - role, cohortId, - profile: { - create: { - firstName, - lastName, - bio, - githubUrl - } - } - }, - include: { - profile: true + userName, + firstName, + lastName, + bio, + githubUrl, + role } }) diff --git a/src/controllers/cohort.js b/src/controllers/cohort.js index cc39365b..d27ea9ce 100644 --- a/src/controllers/cohort.js +++ b/src/controllers/cohort.js @@ -1,5 +1,12 @@ import { createCohort } from '../domain/cohort.js' import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' +import dbClient from '../utils/dbClient.js' + +export const cohortTypeDisplay = { + SOFTWARE_DEVELOPMENT: 'Software Development', + FRONTEND_DEVELOPMENT: 'Front-End Development', + DATA_ANALYTICS: 'Data Analytics' +} export const create = async (req, res) => { try { @@ -10,3 +17,70 @@ export const create = async (req, res) => { return sendMessageResponse(res, 500, 'Unable to create cohort') } } + +export const getCohortMembers = async (req, res) => { + const cohortId = parseInt(req.params.id) + + try { + const cohortMembers = await dbClient.user.findMany({ + where: { + cohortId: cohortId + }, + select: { + id: true, + role: true, + profile: { + select: { + firstName: true, + lastName: true + } + } + } + }) + + const cohort = await dbClient.cohort.findUnique({ + where: { + id: cohortId + }, + select: { + id: true, + type: true + } + }) + + if (!cohortMembers.length) { + return sendDataResponse(res, 404, { + cohort: 'No members found in this cohort' + }) + } + + const formattedMembers = { + students: [], + teachers: [] + } + + cohortMembers.forEach((member) => { + const formattedMember = { + id: member.id, + firstName: member.profile.firstName, + lastName: member.profile.lastName + } + + if (member.role === 'TEACHER') { + formattedMembers.teachers.push(formattedMember) + } else { + formattedMembers.students.push(formattedMember) + } + }) + + return sendDataResponse(res, 200, { + cohort: { + id: cohort.id, + type: cohortTypeDisplay[cohort.type] + }, + members: formattedMembers + }) + } catch (error) { + return sendMessageResponse(res, 500, 'Unable to fetch cohort members') + } +} diff --git a/src/controllers/comment.js b/src/controllers/comment.js new file mode 100644 index 00000000..18f28ff9 --- /dev/null +++ b/src/controllers/comment.js @@ -0,0 +1,69 @@ +import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' +import dbClient from '../utils/dbClient.js' + +export const createComment = async (req, res) => { + const { content } = req.body + const postId = parseInt(req.params.postId) + const userId = req.user.id + + try { + // Input validation + if (!content || content.trim().length === 0) { + return sendDataResponse(res, 400, { + content: 'Comment content cannot be empty' + }) + } + + const MAX_COMMENT_LENGTH = 2560 + if (content.length > MAX_COMMENT_LENGTH) { + return sendDataResponse(res, 400, { + content: `Comment content cannot exceed ${MAX_COMMENT_LENGTH} characters` + }) + } + + // Check if post exists + const post = await dbClient.post.findUnique({ + where: { id: postId } + }) + + if (!post) { + return sendDataResponse(res, 404, { + post: 'Post not found' + }) + } + + // Create comment + const comment = await dbClient.postComment.create({ + data: { + content: content.trim(), + postId, + userId + }, + include: { + user: { + select: { + firstName: true, + lastName: true + } + } + } + }) + + // Format response + const formattedComment = { + id: comment.id, + content: comment.content, + createdAt: comment.createdAt, + author: { + id: comment.user.id, + firstName: comment.user.firstName, + lastName: comment.user.lastName + } + } + + return sendDataResponse(res, 201, { comment: formattedComment }) + } catch (error) { + console.error('Error creating comment:', error) + return sendMessageResponse(res, 500, 'Unable to create comment') + } +} diff --git a/src/controllers/post.js b/src/controllers/post.js index 7b168039..d4957c57 100644 --- a/src/controllers/post.js +++ b/src/controllers/post.js @@ -1,4 +1,5 @@ -import { sendDataResponse } from '../utils/responses.js' +import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' +import dbClient from '../utils/dbClient.js' export const create = async (req, res) => { const { content } = req.body @@ -11,18 +12,149 @@ export const create = async (req, res) => { } export const getAll = async (req, res) => { - return sendDataResponse(res, 200, { - posts: [ - { - id: 1, - content: 'Hello world!', - author: { ...req.user } + try { + const posts = await dbClient.post.findMany({ + orderBy: { + createdAt: 'desc' }, - { - id: 2, - content: 'Hello from the void!', - author: { ...req.user } + include: { + user: { + include: { + cohort: { + select: { + id: true, + type: true + } + } + } + }, + _count: { + select: { + likes: true, + comments: true + } + } } - ] + }) + + const formattedPosts = posts.map((post) => ({ + id: post.id, + content: post.content, + createdAt: post.createdAt, + author: { + id: post.user.id, + firstName: post.user.firstName, + lastName: post.user.lastName, + cohort: post.user.cohort + }, + stats: { + likes: post._count.likes, + comments: post._count.comments + } + })) + + return sendDataResponse(res, 200, { posts: formattedPosts }) + } catch (error) { + return sendMessageResponse(res, 500, 'Unable to fetch posts') + } +} + +export const toggleLike = async (req, res) => { + const postId = parseInt(req.params.id) + const userId = req.user.id + + try { + // Check if user is authenticated + if (!req.user) { + return sendDataResponse(res, 401, { + authorization: 'You must be logged in to perform this action' + }) + } + + // Check if post exists + const post = await dbClient.post.findUnique({ + where: { id: postId } + }) + + if (!post) { + return sendDataResponse(res, 404, { error: 'Post not found' }) + } + + // Check if user has already liked the post + const existingLike = await dbClient.postLike.findUnique({ + where: { + postId_userId: { + postId, + userId + } + } + }) + + if (existingLike) { + // Unlike: Remove the like + await dbClient.postLike.delete({ + where: { + postId_userId: { + postId, + userId + } + } + }) + } else { + // Like: Create new like + await dbClient.postLike.create({ + data: { + postId, + userId + } + }) + } + + // Get updated like count + const likeCount = await dbClient.postLike.count({ + where: { + postId + } + }) + + return sendDataResponse(res, 200, { + liked: !existingLike, + likeCount + }) + } catch (error) { + console.error('Error toggling post like:', error) + return sendMessageResponse(res, 500, 'Unable to process like/unlike action') + } +} + +export const editPostById = async (req, res) => { + const { id } = req.params + const { content } = req.body + + const currPost = await dbClient.post.findUnique({ + where: { + id: parseInt(id) + } }) + + const userId = req.user.id + + if (currPost.userId !== userId) { + return sendDataResponse(res, 403, { error: 'Unauthorized' }) + } + + if (currPost === null) { + return sendDataResponse(res, 404, { content: 'Not found' }) + } + + try { + const updatedPost = await dbClient.post.update({ + where: { id: parseInt(id) }, + data: { content } + }) + + return sendDataResponse(res, 201, { post: updatedPost }) + } catch (error) { + return sendMessageResponse(res, 500, 'Unable to update post') + } } diff --git a/src/controllers/user.js b/src/controllers/user.js index 40ff0f1c..36e961dc 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -1,11 +1,41 @@ +import validator from 'validator' import User from '../domain/user.js' import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' +import bcrypt from 'bcrypt' export const create = async (req, res) => { const userToCreate = await User.fromJson(req.body) + const password = await req.body.password + const missingPasswordMatch = [] try { + if (!validator.isEmail(userToCreate.email)) { + return sendDataResponse(res, 400, { email: 'Invalid email format' }) + } + const existingUser = await User.findByEmail(userToCreate.email) + /* eslint-disable */ + if (password.length < 8) { + missingPasswordMatch.push('Password must be at least 8 characters long') + } + if (!/[A-Za-z]/.test(password)) { + missingPasswordMatch.push('Password must contain at least one letter') + } + if (!/[A-Z]/.test(password)) { + missingPasswordMatch.push('Password must contain at least one uppercase letter') + } + if (!/\d/.test(password)) { + missingPasswordMatch.push('Password must contain at least one number') + } + if (!/[@$!%*?&]/.test(password)) { + missingPasswordMatch.push('Password must contain at least one special character') + } + + if (missingPasswordMatch.length > 0) { + return sendDataResponse(res, 400, { password: missingPasswordMatch }) + } + + /* eslint-enable */ if (existingUser) { return sendDataResponse(res, 400, { email: 'Email already in use' }) @@ -37,31 +67,266 @@ export const getById = async (req, res) => { export const getAll = async (req, res) => { // eslint-disable-next-line camelcase - const { first_name: firstName } = req.query + const { firstName, lastName } = req.query let foundUsers + let firstNameUsers + let lastNameUsers - if (firstName) { - foundUsers = await User.findManyByFirstName(firstName) - } else { - foundUsers = await User.findAll() - } + const namePattern = /^[a-zA-Z\s-']{1,50}$/ + + try { + if ( + firstName && + !namePattern.test(firstName) && + lastName && + !namePattern.test(lastName) + ) { + return sendDataResponse(res, 400, { + error: 'Invalid first name and last name format.' + }) + } + if (firstName && !namePattern.test(firstName)) { + return sendDataResponse(res, 400, { error: 'Invalid first name format.' }) + } + if (lastName && !namePattern.test(lastName)) { + return sendDataResponse(res, 400, { error: 'Invalid last name format.' }) + } - const formattedUsers = foundUsers.map((user) => { - return { - ...user.toJSON().user + if (firstName && lastName) { + firstNameUsers = await User.findManyByFirstName(firstName) + lastNameUsers = await User.findManyByLastName(lastName) + foundUsers = firstNameUsers.filter((user) => + lastNameUsers.some((u) => u.id === user.id) + ) + } else if (firstName) { + foundUsers = await User.findManyByFirstName(firstName) + } else if (lastName) { + foundUsers = await User.findManyByLastName(lastName) + } else { + foundUsers = await User.findAll() } - }) - return sendDataResponse(res, 200, { users: formattedUsers }) + if (!foundUsers || foundUsers.length === 0) { + return sendDataResponse(res, 404, { error: 'No users found.' }) + } + const formattedUsers = foundUsers.map((user) => { + return { + ...user.toJSON().user + } + }) + return sendDataResponse(res, 200, { users: formattedUsers }) + } catch (error) { + return sendDataResponse(res, 500, { + error: 'An unexpected error occurred.' + }) + } } export const updateById = async (req, res) => { - const { cohort_id: cohortId } = req.body + const userId = parseInt(req.params.id) + const { + firstName, + lastName, + email, + biography, + githubUrl, + password, + cohort_id: cohortIdSnake, + cohortId: cohortIdCamel, + role, + mobile + } = req.body - if (!cohortId) { - return sendDataResponse(res, 400, { cohort_id: 'Cohort ID is required' }) + // Input validation patterns + const validationPatterns = { + name: /^[a-zA-Z\s-']{2,50}$/, + email: /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, + password: + /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/, + biography: /^[\s\S]{0,500}$/, + githubUrl: /^https:\/\/github\.com\/[a-zA-Z0-9-]+$/, + role: /^(STUDENT|TEACHER)$/, + mobile: /^\+?[1-9]\d{1,14}$/ + } + + // Validation helper function + const validateField = (field, value, pattern) => { + if (value === undefined || value === null || value === '') return true + return pattern.test(value) + } + + // Validate input fields + const validationErrors = {} + + if ( + firstName && + !validateField('firstName', firstName, validationPatterns.name) + ) { + validationErrors.firstName = 'Invalid first name format' + } + if ( + lastName && + !validateField('lastName', lastName, validationPatterns.name) + ) { + validationErrors.lastName = 'Invalid last name format' + } + if (email && !validateField('email', email, validationPatterns.email)) { + validationErrors.email = 'Invalid email format' } + if ( + biography && + !validateField('biography', biography, validationPatterns.biography) + ) { + validationErrors.biography = 'Biography must not exceed 500 characters' + } + if ( + githubUrl && + !validateField('githubUrl', githubUrl, validationPatterns.githubUrl) + ) { + validationErrors.githubUrl = 'Invalid GitHub URL format' + } + if ( + password && + !validateField('password', password, validationPatterns.password) + ) { + validationErrors.password = + 'Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*#?&)' + } + if (role && !validateField('role', role, validationPatterns.role)) { + validationErrors.role = 'Invalid role' + } + if (mobile && !validateField('mobile', mobile, validationPatterns.mobile)) { + validationErrors.mobile = 'Invalid mobile number format' + } + + // Check for validation errors + if (Object.keys(validationErrors).length > 0) { + return sendDataResponse(res, 400, { validation: validationErrors }) + } + + const cohortId = cohortIdSnake ?? cohortIdCamel - return sendDataResponse(res, 201, { user: { cohort_id: cohortId } }) + try { + const userToUpdate = await User.findById(userId) + + if (!userToUpdate) { + return sendDataResponse(res, 404, { id: 'User not found' }) + } + + // Check if user is updating their own profile or is a teacher + const isOwnProfile = req.user.id === userId + const isTeacher = req.user.role === 'TEACHER' + + if (!isOwnProfile && !isTeacher) { + return sendDataResponse(res, 403, { + authorization: 'You are not authorized to update this profile' + }) + } + + // Check if student is trying to update restricted fields + if (!isTeacher && (cohortId || role)) { + return sendDataResponse(res, 403, { + authorization: + 'Students cannot modify cohort or role information. Please contact a teacher for these changes.' + }) + } + + // Create update data object + const updateData = {} + + // Helper function to validate and add field if it has a valid value + const addValidField = (field, value) => { + if ( + value !== undefined && + value !== null && + value !== '' && + value !== 'string' + ) { + updateData[field] = value + } + } + + // Profile fields any user can update on their own profile + if (isOwnProfile) { + if (password && typeof password === 'string') { + updateData.passwordHash = await bcrypt.hash(password, 8) + } + } + + // Fields only teachers can update + if (isTeacher) { + if (cohortId !== undefined) { + const cohortIdInt = parseInt(cohortId, 10) + if (!isNaN(cohortIdInt)) { + updateData.cohortId = cohortIdInt + } + } + if (role === 'STUDENT' || role === 'TEACHER') { + updateData.role = role + } + } + + // Profile fields any user can update on their own profile + if (isOwnProfile || isTeacher) { + addValidField('firstName', firstName) + addValidField('lastName', lastName) + addValidField('bio', biography) + addValidField('githubUrl', githubUrl) + addValidField('email', email) + addValidField('mobile', mobile) + } + + // If no valid fields to update + if (Object.keys(updateData).length === 0) { + return sendDataResponse(res, 400, { + fields: 'No valid fields provided for update' + }) + } + + const updatedUser = await userToUpdate.update(updateData) + + return sendDataResponse(res, 200, updatedUser) + } catch (error) { + return sendMessageResponse( + res, + 500, + `Unable to update user: ${error.message}` + ) + } +} + +// Function for retrieving user progress +export const getUserProgress = async (req, res) => { + const userId = parseInt(req.params.id) + const currentUser = req.user + + // Check if user is authorized to view progress + if (currentUser.role !== 'TEACHER' && currentUser.id !== userId) { + return sendDataResponse(res, 403, { + error: 'You are not authorized to view this user progress' + }) + } + + try { + // Using json sample-data as requested + const progressData = { + CompletedModules: { + completed: 2, + total: 7 + }, + CompletedUnits: { + completed: 4, + total: 10 + }, + CompletedExercises: { + completed: 34, + total: 58 + } + } + return sendDataResponse(res, 200, progressData) + } catch (error) { + console.error('Failed to retrieve user progress:', error) + return sendMessageResponse(res, 500, 'Failed to retrieve user progress') + } } diff --git a/src/domain/cohort.js b/src/domain/cohort.js index abdda73b..5df64357 100644 --- a/src/domain/cohort.js +++ b/src/domain/cohort.js @@ -6,21 +6,25 @@ import dbClient from '../utils/dbClient.js' */ export async function createCohort() { const createdCohort = await dbClient.cohort.create({ - data: {} + data: { + type: 'SOFTWARE_DEVELOPMENT' // Default type + } }) return new Cohort(createdCohort.id) } export class Cohort { - constructor(id = null) { + constructor(id = null, type = null) { this.id = id + this.type = type } toJSON() { return { cohort: { - id: this.id + id: this.id, + type: this.type } } } diff --git a/src/domain/user.js b/src/domain/user.js index fd7734c7..21164592 100644 --- a/src/domain/user.js +++ b/src/domain/user.js @@ -7,34 +7,45 @@ 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, firstName: string, lastName: string, userName: string, email: string bio: string, githubUrl: string } } user * @returns {User} */ static fromDb(user) { return new User( user.id, user.cohortId, - user.profile?.firstName, - user.profile?.lastName, + user.role, + user.firstName, + user.lastName, + user.userName, user.email, - user.profile?.bio, - user.profile?.githubUrl, - user.password, - user.role + user.bio, + user.githubUrl, + user.password ) } static async fromJson(json) { // eslint-disable-next-line camelcase - const { firstName, lastName, email, biography, githubUrl, password } = json + const { + firstName, + lastName, + userName, + email, + biography, + githubUrl, + password + } = json const passwordHash = await bcrypt.hash(password, 8) return new User( null, null, + 'STUDENT', firstName, lastName, + userName, email, biography, githubUrl, @@ -45,23 +56,25 @@ export default class User { constructor( id, cohortId, + role = 'STUDENT', firstName, lastName, + userName, email, bio, githubUrl, - passwordHash = null, - role = 'STUDENT' + passwordHash = null ) { this.id = id this.cohortId = cohortId + this.role = role this.firstName = firstName this.lastName = lastName + this.userName = userName this.email = email this.bio = bio this.githubUrl = githubUrl this.passwordHash = passwordHash - this.role = role } toJSON() { @@ -72,6 +85,7 @@ export default class User { role: this.role, firstName: this.firstName, lastName: this.lastName, + userName: this.userName, email: this.email, biography: this.bio, githubUrl: this.githubUrl @@ -85,9 +99,14 @@ export default class User { */ async save() { const data = { + role: this.role, + firstName: this.firstName, + lastName: this.lastName, + userName: this.userName, email: this.email, - password: this.passwordHash, - role: this.role + bio: this.bio, + githubUrl: this.githubUrl, + password: this.passwordHash } if (this.cohortId) { @@ -98,21 +117,8 @@ export default class User { } } - if (this.firstName && this.lastName) { - data.profile = { - create: { - firstName: this.firstName, - lastName: this.lastName, - bio: this.bio, - githubUrl: this.githubUrl - } - } - } const createdUser = await dbClient.user.create({ - data, - include: { - profile: true - } + data }) return User.fromDb(createdUser) @@ -130,6 +136,10 @@ export default class User { return User._findMany('firstName', firstName) } + static async findManyByLastName(lastName) { + return User._findMany('lastName', lastName) + } + static async findAll() { return User._findMany() } @@ -138,9 +148,6 @@ export default class User { const foundUser = await dbClient.user.findUnique({ where: { [key]: value - }, - include: { - profile: true } }) @@ -153,16 +160,18 @@ export default class User { static async _findMany(key, value) { const query = { - include: { - profile: true + select: { + id: true, + cohortId: true, + role: true, + firstName: true, + lastName: true } } if (key !== undefined && value !== undefined) { query.where = { - profile: { - [key]: value - } + [key]: { equals: value, mode: 'insensitive' } } } @@ -170,4 +179,27 @@ export default class User { return foundUsers.map((user) => User.fromDb(user)) } + + async update(data) { + const updateData = { + where: { + id: this.id + }, + data: {} + } + + // Handle user-level updates + if (data.email && data.email !== 'string') + updateData.data.email = data.email + if (data.passwordHash) updateData.data.password = data.passwordHash + if (data.role && (data.role === 'STUDENT' || data.role === 'TEACHER')) { + updateData.data.role = data.role + } + if (typeof data.cohortId === 'number') { + updateData.data.cohortId = data.cohortId + } + + const updatedUser = await dbClient.user.update(updateData) + return User.fromDb(updatedUser) + } } diff --git a/src/middleware/auth.js b/src/middleware/auth.js index baffff47..490fca40 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -72,3 +72,25 @@ function validateTokenType(type) { return true } + +export async function validateCohortAccess(req, res, next) { + const cohortId = parseInt(req.params.id) + + if (!req.user) { + return sendMessageResponse(res, 500, 'Unable to verify user') + } + + // Teachers can access any cohort + if (req.user.role === 'TEACHER') { + return next() + } + + // Students can only access their own cohort + if (req.user.cohortId !== cohortId) { + return sendDataResponse(res, 403, { + authorization: 'You are not authorized to access this cohort' + }) + } + + next() +} diff --git a/src/routes/cohort.js b/src/routes/cohort.js index 3cc7813d..d0df9de8 100644 --- a/src/routes/cohort.js +++ b/src/routes/cohort.js @@ -1,12 +1,19 @@ import { Router } from 'express' -import { create } from '../controllers/cohort.js' +import { create, getCohortMembers } from '../controllers/cohort.js' import { validateAuthentication, - validateTeacherRole + validateTeacherRole, + validateCohortAccess } from '../middleware/auth.js' const router = Router() router.post('/', validateAuthentication, validateTeacherRole, create) +router.get( + '/:id/members', + validateAuthentication, + validateCohortAccess, + getCohortMembers +) export default router diff --git a/src/routes/comment.js b/src/routes/comment.js new file mode 100644 index 00000000..28d70cbf --- /dev/null +++ b/src/routes/comment.js @@ -0,0 +1,9 @@ +import { Router } from 'express' +import { createComment } from '../controllers/comment.js' +import { validateAuthentication } from '../middleware/auth.js' + +const router = Router() + +router.post('/posts/:postId/comments', validateAuthentication, createComment) + +export default router diff --git a/src/routes/post.js b/src/routes/post.js index a7fbbfb3..126f5837 100644 --- a/src/routes/post.js +++ b/src/routes/post.js @@ -1,10 +1,17 @@ import { Router } from 'express' -import { create, getAll } from '../controllers/post.js' +import { + create, + getAll, + editPostById, + toggleLike +} from '../controllers/post.js' import { validateAuthentication } from '../middleware/auth.js' const router = Router() router.post('/', validateAuthentication, create) router.get('/', validateAuthentication, getAll) +router.post('/:id/toggle-like', validateAuthentication, toggleLike) +router.patch('/:id', validateAuthentication, editPostById) export default router diff --git a/src/routes/user.js b/src/routes/user.js index 9f63d162..9587d7fe 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -1,15 +1,19 @@ import { Router } from 'express' -import { create, getById, getAll, updateById } from '../controllers/user.js' import { - validateAuthentication, - validateTeacherRole -} from '../middleware/auth.js' + create, + getById, + getAll, + updateById, + getUserProgress +} from '../controllers/user.js' +import { validateAuthentication } from '../middleware/auth.js' const router = Router() router.post('/', create) router.get('/', validateAuthentication, getAll) router.get('/:id', validateAuthentication, getById) -router.patch('/:id', validateAuthentication, validateTeacherRole, updateById) +router.patch('/:id', validateAuthentication, updateById) +router.get('/:id/progress', validateAuthentication, getUserProgress) export default router diff --git a/src/server.js b/src/server.js index a3f67eeb..3dfb96b3 100644 --- a/src/server.js +++ b/src/server.js @@ -9,6 +9,7 @@ import postRouter from './routes/post.js' import authRouter from './routes/auth.js' import cohortRouter from './routes/cohort.js' import deliveryLogRouter from './routes/deliveryLog.js' +import commentRouter from './routes/comment.js' const app = express() app.disable('x-powered-by') @@ -26,6 +27,7 @@ app.use('/posts', postRouter) app.use('/cohorts', cohortRouter) app.use('/logs', deliveryLogRouter) app.use('/', authRouter) +app.use('/', commentRouter) app.get('*', (req, res) => { res.status(404).json({