From 91ae33327b51bfa964f8ecc3db7dfe1483f19555 Mon Sep 17 00:00:00 2001 From: Axel Ahlander Date: Wed, 26 Feb 2025 10:13:41 +0100 Subject: [PATCH 01/17] added password validation check --- .env.example | 5 ----- src/controllers/user.js | 10 ++++++++++ 2 files changed, 10 insertions(+), 5 deletions(-) delete mode 100644 .env.example 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/src/controllers/user.js b/src/controllers/user.js index 40ff0f1c..b93f4ef5 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -3,6 +3,16 @@ import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' export const create = async (req, res) => { const userToCreate = await User.fromJson(req.body) + const password = await req.body.password + + /* eslint-disable */ + if (!password.match(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/)) { + return sendDataResponse(res, 400, { + password: + 'Password must be at least 8 characters long, contain at least one letter, one number, and one special character' + }) +} + /* eslint-enable */ try { const existingUser = await User.findByEmail(userToCreate.email) From 6c5e059c5dba666651aff79ab259d8890683e472 Mon Sep 17 00:00:00 2001 From: Axel Ahlander Date: Wed, 26 Feb 2025 10:21:12 +0100 Subject: [PATCH 02/17] eslint issue --- src/controllers/user.js | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/controllers/user.js b/src/controllers/user.js index b93f4ef5..e861b5b1 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -5,17 +5,16 @@ export const create = async (req, res) => { const userToCreate = await User.fromJson(req.body) const password = await req.body.password - /* eslint-disable */ - if (!password.match(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/)) { - return sendDataResponse(res, 400, { - password: - 'Password must be at least 8 characters long, contain at least one letter, one number, and one special character' - }) -} - /* eslint-enable */ - try { const existingUser = await User.findByEmail(userToCreate.email) + /* eslint-disable */ + if (!password.match(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/)) { + return sendDataResponse(res, 400, { + password: + 'Password must be at least 8 characters long, contain at least one letter, one number, and one special character' + }) + } + /* eslint-enable */ if (existingUser) { return sendDataResponse(res, 400, { email: 'Email already in use' }) From af08bfff19d932c39a4abe869e0ca5a16755e70b Mon Sep 17 00:00:00 2001 From: Jone <64496635+JoneTheBuilder@users.noreply.github.com> Date: Wed, 26 Feb 2025 11:21:03 +0100 Subject: [PATCH 03/17] Implemented API - Update User #3 --- src/controllers/user.js | 101 ++++++++++++++++++++++++++++++++++++++-- src/domain/user.js | 53 +++++++++++++++++++++ src/routes/user.js | 7 +-- 3 files changed, 151 insertions(+), 10 deletions(-) diff --git a/src/controllers/user.js b/src/controllers/user.js index 40ff0f1c..cbfe51ee 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -1,5 +1,6 @@ import User from '../domain/user.js' import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' +import bcrypt from 'bcrypt' export const create = async (req, res) => { const userToCreate = await User.fromJson(req.body) @@ -57,11 +58,101 @@ export const getAll = async (req, res) => { } 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 + + const cohortId = cohortIdSnake ?? cohortIdCamel - if (!cohortId) { - return sendDataResponse(res, 400, { cohort_id: 'Cohort ID is required' }) - } + 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' - return sendDataResponse(res, 201, { user: { cohort_id: cohortId } }) + 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 || isTeacher) { + addValidField('firstName', firstName) + addValidField('lastName', lastName) + addValidField('bio', biography) + addValidField('githubUrl', githubUrl) + addValidField('email', email) + if (password) { + 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 + } + } + + // 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}` + ) + } } diff --git a/src/domain/user.js b/src/domain/user.js index fd7734c7..eb393def 100644 --- a/src/domain/user.js +++ b/src/domain/user.js @@ -170,4 +170,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/routes/user.js b/src/routes/user.js index 9f63d162..c2714f9b 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -1,15 +1,12 @@ import { Router } from 'express' import { create, getById, getAll, updateById } from '../controllers/user.js' -import { - validateAuthentication, - validateTeacherRole -} from '../middleware/auth.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) export default router From 30639c72a9ccae2bca6098b94a84b070257be149 Mon Sep 17 00:00:00 2001 From: Jone <64496635+JoneTheBuilder@users.noreply.github.com> Date: Wed, 26 Feb 2025 11:30:03 +0100 Subject: [PATCH 04/17] Implemented only owners of a profile can edit their own password --- src/controllers/user.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/controllers/user.js b/src/controllers/user.js index cbfe51ee..1ba59896 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -114,13 +114,8 @@ export const updateById = async (req, res) => { } // 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 (password) { + if (isOwnProfile) { + if (password && typeof password === 'string') { updateData.passwordHash = await bcrypt.hash(password, 8) } } @@ -138,6 +133,15 @@ export const updateById = async (req, res) => { } } + // 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, { From 835d1f907860170ba54fcc41bc2957b913527645 Mon Sep 17 00:00:00 2001 From: Axel Ahlander Date: Wed, 26 Feb 2025 11:32:18 +0100 Subject: [PATCH 05/17] added additional checks depending on what was missing in the password requirements --- src/controllers/user.js | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/controllers/user.js b/src/controllers/user.js index e861b5b1..6f2edf7d 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -4,16 +4,31 @@ import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' export const create = async (req, res) => { const userToCreate = await User.fromJson(req.body) const password = await req.body.password + const missingPasswordMatch = [] try { const existingUser = await User.findByEmail(userToCreate.email) /* eslint-disable */ - if (!password.match(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/)) { - return sendDataResponse(res, 400, { - password: - 'Password must be at least 8 characters long, contain at least one letter, one number, and one special character' - }) + 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) { From dd17a6654b36af48a833127b2eaadf3563090ffd Mon Sep 17 00:00:00 2001 From: Kristoffer Blucher Date: Wed, 26 Feb 2025 11:39:55 +0100 Subject: [PATCH 06/17] updated email validation in user.js --- package-lock.json | 15 +++++++++++++++ package.json | 1 + src/controllers/user.js | 5 +++++ 3 files changed, 21 insertions(+) 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/src/controllers/user.js b/src/controllers/user.js index 40ff0f1c..d69e4c78 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -1,3 +1,4 @@ +import validator from 'validator' import User from '../domain/user.js' import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' @@ -5,6 +6,10 @@ export const create = async (req, res) => { const userToCreate = await User.fromJson(req.body) try { + if (!validator.isEmail(userToCreate.email)) { + return sendDataResponse(res, 400, { email: 'Invalid email format' }) + } + const existingUser = await User.findByEmail(userToCreate.email) if (existingUser) { From 78c510bef892d9c5c0cc7f603e52ee38a6d0c31b Mon Sep 17 00:00:00 2001 From: Jone <64496635+JoneTheBuilder@users.noreply.github.com> Date: Wed, 26 Feb 2025 12:11:27 +0100 Subject: [PATCH 07/17] Added RegExp to validate all forms of input --- src/controllers/user.js | 63 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/controllers/user.js b/src/controllers/user.js index 1ba59896..1e16558b 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -71,6 +71,69 @@ export const updateById = async (req, res) => { 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 { From d0d88323e64b2b9f2f9ef89b979d85f75f731920 Mon Sep 17 00:00:00 2001 From: Jone <64496635+JoneTheBuilder@users.noreply.github.com> Date: Wed, 26 Feb 2025 14:36:31 +0100 Subject: [PATCH 08/17] Implemented API - Retrieve users by cohort #8 --- docs/openapi.yml | 66 +++++++++++++++++++++++++++++++++++++++ src/controllers/cohort.js | 52 ++++++++++++++++++++++++++++++ src/middleware/auth.js | 22 +++++++++++++ src/routes/cohort.js | 11 +++++-- 4 files changed, 149 insertions(+), 2 deletions(-) diff --git a/docs/openapi.yml b/docs/openapi.yml index 5f2a05f2..67b1c1a1 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -285,6 +285,72 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /cohorts/{id}/members: + get: + tags: + - cohort + summary: Get all members in a cohort + description: Get all students and teachers in a specific cohort. User must be a teacher or a member of the cohort. + operationId: getCohortMembers + security: + - bearerAuth: [] + parameters: + - name: id + in: path + description: Cohort ID + required: true + schema: + type: integer + responses: + '200': + description: Successful operation + content: + application/json: + schema: + type: object + properties: + status: + type: string + data: + type: object + properties: + 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 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Cohort not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' components: securitySchemes: diff --git a/src/controllers/cohort.js b/src/controllers/cohort.js index cc39365b..99f8f11a 100644 --- a/src/controllers/cohort.js +++ b/src/controllers/cohort.js @@ -1,5 +1,6 @@ import { createCohort } from '../domain/cohort.js' import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' +import dbClient from '../utils/dbClient.js' export const create = async (req, res) => { try { @@ -10,3 +11,54 @@ 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 + } + } + } + }) + + 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, { members: formattedMembers }) + } catch (error) { + return sendMessageResponse(res, 500, 'Unable to fetch cohort members') + } +} 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 From b0ee452c10bbbb9b0f61fabaa62b329705ba1741 Mon Sep 17 00:00:00 2001 From: Axel Ahlander Date: Wed, 26 Feb 2025 15:35:49 +0100 Subject: [PATCH 09/17] added filtering for user search --- docs/openapi.yml | 7 ++++++- src/controllers/user.js | 16 +++++++++++++--- src/domain/user.js | 18 +++++++++++++++--- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/docs/openapi.yml b/docs/openapi.yml index 5f2a05f2..938810f1 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: @@ -46,6 +46,11 @@ paths: description: Search all users by first name if provided (case-sensitive and exact string matches only) schema: type: string + - name: lastName + in: query + description: Search all users by last name if provided (case-insensitive) + schema: + type: string responses: '200': description: successful operation diff --git a/src/controllers/user.js b/src/controllers/user.js index 5b2df831..b585137b 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -67,12 +67,22 @@ 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 - - if (firstName) { + let firstNameUsers + let lastNameUsers + + 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() } diff --git a/src/domain/user.js b/src/domain/user.js index eb393def..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' } } } } From 69efa198b5de516a377e69153cb61728ac769998 Mon Sep 17 00:00:00 2001 From: Jone <64496635+JoneTheBuilder@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:44:44 +0100 Subject: [PATCH 10/17] Closes #12 --- docs/openapi.yml | 66 ------------------- .../migration.sql | 5 ++ prisma/schema.prisma | 7 ++ prisma/seed.js | 4 +- src/controllers/cohort.js | 24 ++++++- src/domain/cohort.js | 10 ++- 6 files changed, 45 insertions(+), 71 deletions(-) create mode 100644 prisma/migrations/20250226143315_add_cohort_type/migration.sql diff --git a/docs/openapi.yml b/docs/openapi.yml index 67b1c1a1..5f2a05f2 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -285,72 +285,6 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /cohorts/{id}/members: - get: - tags: - - cohort - summary: Get all members in a cohort - description: Get all students and teachers in a specific cohort. User must be a teacher or a member of the cohort. - operationId: getCohortMembers - security: - - bearerAuth: [] - parameters: - - name: id - in: path - description: Cohort ID - required: true - schema: - type: integer - responses: - '200': - description: Successful operation - content: - application/json: - schema: - type: object - properties: - status: - type: string - data: - type: object - properties: - 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 - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - '404': - description: Cohort not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' components: securitySchemes: 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/schema.prisma b/prisma/schema.prisma index 72ec5632..9f32ed38 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,8 +38,15 @@ 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[] } diff --git a/prisma/seed.js b/prisma/seed.js index 21684795..55d2cc63 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -49,7 +49,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 99f8f11a..d27ea9ce 100644 --- a/src/controllers/cohort.js +++ b/src/controllers/cohort.js @@ -2,6 +2,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 { const createdCohort = await createCohort() @@ -32,6 +38,16 @@ export const getCohortMembers = async (req, res) => { } }) + 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' @@ -57,7 +73,13 @@ export const getCohortMembers = async (req, res) => { } }) - return sendDataResponse(res, 200, { members: formattedMembers }) + 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/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 } } } From 2541b560e7259e7ee1af568c13aacbb3ed716a3d Mon Sep 17 00:00:00 2001 From: Jone <64496635+JoneTheBuilder@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:47:30 +0100 Subject: [PATCH 11/17] Add OpenAPI specification for retrieving cohort members --- docs/openapi.yml | 79 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/docs/openapi.yml b/docs/openapi.yml index 5f2a05f2..dde3a1b8 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -285,6 +285,85 @@ 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' components: securitySchemes: From 966562144d1bd0511dcb8f37f92aef5a0aaf4c29 Mon Sep 17 00:00:00 2001 From: Axel Ahlander Date: Wed, 26 Feb 2025 15:52:51 +0100 Subject: [PATCH 12/17] updated error checks and messages --- docs/openapi.yml | 2 +- src/controllers/user.js | 63 +++++++++++++++++++++++++++++------------ 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/docs/openapi.yml b/docs/openapi.yml index 938810f1..23f77ee3 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -43,7 +43,7 @@ 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 diff --git a/src/controllers/user.js b/src/controllers/user.js index b585137b..05b6cf2e 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -73,27 +73,54 @@ export const getAll = async (req, res) => { let firstNameUsers let lastNameUsers - 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() - } + const namePattern = /^[a-zA-Z\s-']{1,50}$/ - const formattedUsers = foundUsers.map((user) => { - return { - ...user.toJSON().user + 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.' }) } - }) - return sendDataResponse(res, 200, { users: formattedUsers }) + 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() + } + + 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) => { From 795039dc7362bbcef1aeb177e2024d27d31933bc Mon Sep 17 00:00:00 2001 From: Kristoffer Blucher Date: Wed, 26 Feb 2025 16:09:26 +0100 Subject: [PATCH 13/17] Added endpoint for retrieving user-progress --- docs/openapi.yml | 84 +++++++++++++++++++++++++++++++++++++++++ src/controllers/user.js | 35 +++++++++++++++++ src/routes/user.js | 9 ++++- 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/docs/openapi.yml b/docs/openapi.yml index 5f2a05f2..cb991346 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -164,6 +164,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: diff --git a/src/controllers/user.js b/src/controllers/user.js index 5b2df831..c8c09af7 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -252,3 +252,38 @@ export const updateById = async (req, res) => { ) } } + +// Function for retrieving user progress +export const getUserProgress = async (req, res) => { + const userId = 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/routes/user.js b/src/routes/user.js index c2714f9b..9587d7fe 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -1,5 +1,11 @@ import { Router } from 'express' -import { create, getById, getAll, updateById } from '../controllers/user.js' +import { + create, + getById, + getAll, + updateById, + getUserProgress +} from '../controllers/user.js' import { validateAuthentication } from '../middleware/auth.js' const router = Router() @@ -8,5 +14,6 @@ router.post('/', create) router.get('/', validateAuthentication, getAll) router.get('/:id', validateAuthentication, getById) router.patch('/:id', validateAuthentication, updateById) +router.get('/:id/progress', validateAuthentication, getUserProgress) export default router From 01b43ded54e108b283de377c4569714495f3fe82 Mon Sep 17 00:00:00 2001 From: Jone <64496635+JoneTheBuilder@users.noreply.github.com> Date: Thu, 27 Feb 2025 09:16:48 +0100 Subject: [PATCH 14/17] Closes #15 + updated seed.js to have more data. --- docs/openapi.yml | 31 ++--- .../migration.sql | 49 ++++++++ prisma/schema.prisma | 39 +++++- prisma/seed.js | 111 +++++++++++++++--- src/controllers/post.js | 64 ++++++++-- 5 files changed, 246 insertions(+), 48 deletions(-) create mode 100644 prisma/migrations/20250227080825_add_created_at/migration.sql diff --git a/docs/openapi.yml b/docs/openapi.yml index 8d30ac09..968b8ecc 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -491,30 +491,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: 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 9f32ed38..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 { @@ -52,10 +55,38 @@ model Cohort { } 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 55d2cc63..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(student.id, 'My first post!') - await createPost(teacher.id, 'Hello, students') + 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 +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 } }) diff --git a/src/controllers/post.js b/src/controllers/post.js index 7b168039..758dc810 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,55 @@ 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') + } } From cfc43b086e022bb25b88139024bb2c5238d3ca2f Mon Sep 17 00:00:00 2001 From: Kristoffer Blucher Date: Thu, 27 Feb 2025 09:28:13 +0100 Subject: [PATCH 15/17] Added new route for user progress --- docs/openapi.yml | 68 ++++++++++++++++++++--------------------- src/controllers/user.js | 2 +- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/docs/openapi.yml b/docs/openapi.yml index cb991346..6bc27c01 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -184,40 +184,40 @@ paths: '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." + 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: diff --git a/src/controllers/user.js b/src/controllers/user.js index c8c09af7..d6d32c7e 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -255,7 +255,7 @@ export const updateById = async (req, res) => { // Function for retrieving user progress export const getUserProgress = async (req, res) => { - const userId = req.params.id + const userId = parseInt(req.params.id) const currentUser = req.user // Check if user is authorized to view progress From b97906d880d638fbdccb561e17bd635f9be6d747 Mon Sep 17 00:00:00 2001 From: Jone <64496635+JoneTheBuilder@users.noreply.github.com> Date: Thu, 27 Feb 2025 11:12:28 +0100 Subject: [PATCH 16/17] Closes #18 --- docs/openapi.yml | 69 +++++++++++++++++++++++++++++++++++++++++ src/controllers/post.js | 68 ++++++++++++++++++++++++++++++++++++++++ src/routes/post.js | 3 +- 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/docs/openapi.yml b/docs/openapi.yml index 968b8ecc..0f807d88 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -221,6 +221,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: @@ -620,3 +674,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/src/controllers/post.js b/src/controllers/post.js index 758dc810..ed09b05c 100644 --- a/src/controllers/post.js +++ b/src/controllers/post.js @@ -64,3 +64,71 @@ export const getAll = async (req, res) => { 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/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 From 402f402e144562605ac6d6b6e94489634e9197d5 Mon Sep 17 00:00:00 2001 From: Jone <64496635+JoneTheBuilder@users.noreply.github.com> Date: Thu, 27 Feb 2025 13:05:43 +0100 Subject: [PATCH 17/17] Closes #19 --- docs/openapi.yml | 84 ++++++++++++++++++++++++++++++++++++++ src/controllers/comment.js | 66 ++++++++++++++++++++++++++++++ src/routes/comment.js | 9 ++++ src/server.js | 2 + 4 files changed, 161 insertions(+) create mode 100644 src/controllers/comment.js create mode 100644 src/routes/comment.js diff --git a/docs/openapi.yml b/docs/openapi.yml index bdee8ded..373eaf90 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -507,6 +507,90 @@ paths: 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: 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/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/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({