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..373eaf90 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: @@ -164,6 +169,90 @@ paths: 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 +305,60 @@ paths: 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 +428,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: @@ -407,30 +713,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: @@ -535,3 +842,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/20250226143315_add_cohort_type/migration.sql b/prisma/migrations/20250226143315_add_cohort_type/migration.sql new file mode 100644 index 00000000..4c29d4e2 --- /dev/null +++ b/prisma/migrations/20250226143315_add_cohort_type/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "CohortType" AS ENUM ('SOFTWARE_DEVELOPMENT', 'FRONTEND_DEVELOPMENT', 'DATA_ANALYTICS'); + +-- AlterTable +ALTER TABLE "Cohort" ADD COLUMN "type" "CohortType" NOT NULL DEFAULT E'SOFTWARE_DEVELOPMENT'; diff --git a/prisma/migrations/20250227080825_add_created_at/migration.sql b/prisma/migrations/20250227080825_add_created_at/migration.sql new file mode 100644 index 00000000..809be098 --- /dev/null +++ b/prisma/migrations/20250227080825_add_created_at/migration.sql @@ -0,0 +1,49 @@ +/* + Warnings: + + - Added the required column `updatedAt` to the `Post` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Post" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; + +-- 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") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PostLike_postId_userId_key" ON "PostLike"("postId", "userId"); + +-- CreateIndex +CREATE INDEX "Post_createdAt_idx" ON "Post"("createdAt" DESC); + +-- 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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 72ec5632..1c9be77d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3,6 +3,7 @@ generator client { provider = "prisma-client-js" + previewFeatures = ["extendedIndexes"] } datasource db { @@ -26,6 +27,8 @@ model User { cohort Cohort? @relation(fields: [cohortId], references: [id]) posts Post[] deliveryLogs DeliveryLog[] + postLikes PostLike[] + postComments PostComment[] } model Profile { @@ -38,17 +41,52 @@ model Profile { githubUrl String? } +enum CohortType { + SOFTWARE_DEVELOPMENT + FRONTEND_DEVELOPMENT + DATA_ANALYTICS +} + model Cohort { id Int @id @default(autoincrement()) + type CohortType @default(SOFTWARE_DEVELOPMENT) users User[] 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 + 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..317c3377 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -3,30 +3,96 @@ 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, + 'firstName1', + 'lastName1', + 'Student 1 bio', 'student1' ) - const teacher = await createUser( - 'teacher@test.com', + const student2 = await createUser( + 'student2@test.com', + 'Testpassword1!', + cohort2.id, + 'firstName2', + 'lastName2', + 'Student 2 bio', + 'student2' + ) + const student3 = await createUser( + 'student3@test.com', + 'Testpassword1!', + cohort3.id, + 'firstName3', + 'lastName3', + 'Student 3 bio', + 'student3' + ) + + // Create 3 teachers + const teacher1 = await createUser( + 'teacher1@test.com', 'Testpassword1!', null, - 'Rick', - 'Sanchez', - 'Hello there!', + 'teacherFirst1', + 'teacherLast1', + 'Teacher 1 bio', 'teacher1', 'TEACHER' ) + const teacher2 = await createUser( + 'teacher2@test.com', + 'Testpassword1!', + null, + 'teacherFirst2', + 'teacherLast2', + 'Teacher 2 bio', + 'teacher2', + 'TEACHER' + ) + const teacher3 = await createUser( + 'teacher3@test.com', + 'Testpassword1!', + null, + 'teacherFirst3', + 'teacherLast3', + 'Teacher 3 bio', + 'teacher3', + 'TEACHER' + ) + + // 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(student.id, 'My first post!') - await createPost(teacher.id, 'Hello, students') + 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 +101,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 +128,9 @@ async function createPost(userId, content) { async function createCohort() { const cohort = await prisma.cohort.create({ - data: {} + data: { + type: 'SOFTWARE_DEVELOPMENT' + } }) console.info('Cohort created', cohort) 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..42340dc1 --- /dev/null +++ b/src/controllers/comment.js @@ -0,0 +1,66 @@ +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' + }) + } + + // 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: { + include: { + profile: { + 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.profile.firstName, + lastName: comment.user.profile.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..ed09b05c 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,123 @@ 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: { + profile: { + select: { + firstName: true, + lastName: true + } + }, + 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.profile.firstName, + lastName: post.user.profile.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') + } } diff --git a/src/controllers/user.js b/src/controllers/user.js index 40ff0f1c..a73f59a4 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,260 @@ 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 + } = req.body + + // 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)$/ + } + + // 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' + } + + // Check for validation errors + if (Object.keys(validationErrors).length > 0) { + return sendDataResponse(res, 400, { validation: validationErrors }) + } + + const cohortId = cohortIdSnake ?? cohortIdCamel + + 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) + } + } - if (!cohortId) { - return sendDataResponse(res, 400, { cohort_id: 'Cohort ID is required' }) + // 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) + } + + // 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' + }) } - return sendDataResponse(res, 201, { user: { cohort_id: cohortId } }) + 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..659ed0a6 100644 --- a/src/domain/user.js +++ b/src/domain/user.js @@ -130,6 +130,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() } @@ -153,15 +157,23 @@ export default class User { static async _findMany(key, value) { const query = { - include: { - profile: true + select: { + id: true, + cohortId: true, + role: true, + profile: { + select: { + firstName: true, + lastName: true + } + } } } if (key !== undefined && value !== undefined) { query.where = { profile: { - [key]: value + [key]: { equals: value, mode: 'insensitive' } } } } @@ -170,4 +182,57 @@ export default class User { return foundUsers.map((user) => User.fromDb(user)) } + + async update(data) { + const updateData = { + where: { + id: this.id + }, + data: {}, + include: { + profile: true + } + } + + // 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 + } + + // Handle profile-related updates + const profileUpdates = {} + let hasProfileUpdates = false + + if (data.firstName && data.firstName !== 'string') { + profileUpdates.firstName = data.firstName + hasProfileUpdates = true + } + if (data.lastName && data.lastName !== 'string') { + profileUpdates.lastName = data.lastName + hasProfileUpdates = true + } + if (data.bio && data.bio !== 'string') { + profileUpdates.bio = data.bio + hasProfileUpdates = true + } + if (data.githubUrl && data.githubUrl !== 'string') { + profileUpdates.githubUrl = data.githubUrl + hasProfileUpdates = true + } + + if (hasProfileUpdates) { + updateData.data.profile = { + update: profileUpdates + } + } + + 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..9577f043 100644 --- a/src/routes/post.js +++ b/src/routes/post.js @@ -1,10 +1,11 @@ import { Router } from 'express' -import { create, getAll } from '../controllers/post.js' +import { create, getAll, 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) 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({