diff --git a/docs/openapi.yml b/docs/openapi.yml index 5f2a05f2..adaf4321 100644 --- a/docs/openapi.yml +++ b/docs/openapi.yml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: Team Dev Server API description: |- - version: 1.0 + version: '1.0' servers: - url: http://localhost:4000/ @@ -65,6 +65,37 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + + /users/profile/{id}: + post: + tags: + - profile + summary: Create profile + description: Create new profile + operationId: createProfile + security: + - bearerAuth: [] + parameters: + - name: id + in: path + description: 'The name that needs to be fetched. Use user1 for testing. ' + required: true + schema: + type: string + requestBody: + description: User registration details + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProfile' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/CreatedProfile' + /login: post: tags: @@ -88,6 +119,46 @@ paths: '400': description: Invalid username/password supplied + + /users/search: + get: + tags: + - user + summary: Search users by first name and/or last name + description: 'Search for users by their first name, last name, or both. Either parameter is optional.' + operationId: searchUsers + security: + - bearerAuth: [] + parameters: + - name: firstName + in: query + description: 'Search users by their first name (optional).' + schema: + type: string + - name: lastName + in: query + description: 'Search users by their last name (optional).' + schema: + type: string + responses: + '200': + description: 'Users found successfully' + content: + application/json: + schema: + $ref: '#/components/schemas/AllUsers' + '400': + description: 'Bad request, invalid query parameters.' + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: 'No users found matching the query.' + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /users/{id}: get: @@ -183,13 +254,13 @@ paths: content: type: string responses: - 201: + '201': description: success content: application/json: schema: $ref: '#/components/schemas/Post' - 400: + '400': description: fail content: application/json: @@ -244,7 +315,7 @@ paths: content: type: string responses: - 201: + '201': description: success content: application/json: @@ -266,7 +337,7 @@ paths: security: - bearerAuth: [] responses: - 201: + '201': description: success content: application/json: @@ -279,7 +350,70 @@ paths: properties: cohort: $ref: '#/components/schemas/Cohort' - 400: + '400': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + get: + tags: + - cohort + summary: Get all cohorts + description: This can only be done by a logged in user. + operationId: getAllCohorts + security: + - bearerAuth: [] + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/AllUsers' + '500': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /cohorts/{id}: + get: + tags: + - cohort + summary: Get cohort by id + description: This can only be done by a logged in user. + operationId: getCohortById + security: + - bearerAuth: [] + parameters: + - name: id + in: path + description: 'Id of the cohort to fetch' + required: true + schema: + type: string + responses: + '200': + description: success + content: + application/json: + schema: + type: object + properties: + status: + type: string + data: + properties: + cohort: + $ref: '#/components/schemas/Cohort' + '404': + description: fail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': description: fail content: application/json: @@ -307,6 +441,19 @@ components: content: type: string + AllCohorts: + type: object + properties: + status: + type: string + data: + type: object + properties: + cohorts: + type: array + items: + $ref: '#/components/schemas/Cohort' + Cohort: type: object properties: @@ -368,6 +515,30 @@ components: password: type: string + CreateProfile: + type: object + properties: + firstName: + type: string + lastName: + type: string + bio: + type: string + githubUrl: + type: string + username: + type: string + mobile: + type: string + startDate: + type: string + endDate: + type: string + specialism: + type: string + jobTitle: + type: string + UpdateUser: type: object properties: @@ -457,6 +628,40 @@ components: type: string githubUrl: type: string + + CreatedProfile: + type: object + properties: + status: + type: string + example: success + data: + properties: + user: + properties: + id: + type: integer + firstName: + type: string + lastName: + type: string + bio: + type: string + githubUrl: + type: string + username: + type: string + mobile: + type: string + startDate: + type: string + endDate: + type: string + specialism: + type: string + jobTitle: + type: string + login: type: object properties: diff --git a/prisma/migrations/20250226143811_update_user_profile/migration.sql b/prisma/migrations/20250226143811_update_user_profile/migration.sql new file mode 100644 index 00000000..c4c143da --- /dev/null +++ b/prisma/migrations/20250226143811_update_user_profile/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "Profile" ADD COLUMN "endDate" TEXT, +ADD COLUMN "jobTitle" TEXT, +ADD COLUMN "mobile" TEXT, +ADD COLUMN "specialism" TEXT, +ADD COLUMN "startDate" TEXT, +ADD COLUMN "username" TEXT; diff --git a/prisma/migrations/20250227124254_update_cohort_added_specialism/migration.sql b/prisma/migrations/20250227124254_update_cohort_added_specialism/migration.sql new file mode 100644 index 00000000..5d970e7f --- /dev/null +++ b/prisma/migrations/20250227124254_update_cohort_added_specialism/migration.sql @@ -0,0 +1,31 @@ +/* + Warnings: + + - You are about to drop the column `endDate` on the `Profile` table. All the data in the column will be lost. + - You are about to drop the column `jobTitle` on the `Profile` table. All the data in the column will be lost. + - You are about to drop the column `specialism` on the `Profile` table. All the data in the column will be lost. + - You are about to drop the column `startDate` on the `Profile` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Cohort" ADD COLUMN "endDate" TEXT, +ADD COLUMN "jobTitle" TEXT, +ADD COLUMN "specialismId" INTEGER, +ADD COLUMN "startDate" TEXT; + +-- AlterTable +ALTER TABLE "Profile" DROP COLUMN "endDate", +DROP COLUMN "jobTitle", +DROP COLUMN "specialism", +DROP COLUMN "startDate"; + +-- CreateTable +CREATE TABLE "Specialism" ( + "id" SERIAL NOT NULL, + "specialismName" TEXT NOT NULL, + + CONSTRAINT "Specialism_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Cohort" ADD CONSTRAINT "Cohort_specialismId_fkey" FOREIGN KEY ("specialismId") REFERENCES "Specialism"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 72ec5632..6127d7db 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -36,12 +36,25 @@ model Profile { lastName String bio String? githubUrl String? + username String? + mobile String? } model Cohort { id Int @id @default(autoincrement()) + specialismId Int? + specialism Specialism? @relation(fields: [specialismId], references: [id]) users User[] deliveryLogs DeliveryLog[] + startDate String? + endDate String? + jobTitle String? +} + +model Specialism { + id Int @id @default(autoincrement()) + specialismName String + cohorts Cohort[] } model Post { @@ -66,4 +79,4 @@ model DeliveryLogLine { content String logId Int log DeliveryLog @relation(fields: [logId], references: [id]) -} +} \ No newline at end of file diff --git a/prisma/seed.js b/prisma/seed.js index 21684795..25c32b23 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -3,7 +3,8 @@ import bcrypt from 'bcrypt' const prisma = new PrismaClient() async function seed() { - const cohort = await createCohort() + const specialism = await createSpecialism('Software Developer') + const cohort = await createCohort(specialism.id, 'January 2025', 'June 2025') const student = await createUser( 'student@test.com', @@ -12,7 +13,9 @@ async function seed() { 'Joe', 'Bloggs', 'Hello, world!', - 'student1' + 'student1', + 'username', + 'mobile' ) const teacher = await createUser( 'teacher@test.com', @@ -22,6 +25,8 @@ async function seed() { 'Sanchez', 'Hello there!', 'teacher1', + 'username', + 'mobile', 'TEACHER' ) @@ -47,9 +52,14 @@ async function createPost(userId, content) { return post } -async function createCohort() { +async function createCohort(specialismId, startDate, endDate, jobTitle) { const cohort = await prisma.cohort.create({ - data: {} + data: { + specialismId, + startDate, + endDate, + jobTitle + } }) console.info('Cohort created', cohort) @@ -57,6 +67,18 @@ async function createCohort() { return cohort } +async function createSpecialism(specialismName) { + const specialism = await prisma.specialism.create({ + data: { + specialismName + } + }) + + console.info('Specialism created', specialism) + + return specialism +} + async function createUser( email, password, @@ -65,6 +87,8 @@ async function createUser( lastName, bio, githubUrl, + username, + mobile, role = 'STUDENT' ) { const user = await prisma.user.create({ @@ -78,7 +102,9 @@ async function createUser( firstName, lastName, bio, - githubUrl + githubUrl, + username, + mobile } } }, diff --git a/src/controllers/cohort.js b/src/controllers/cohort.js index cc39365b..4e33f775 100644 --- a/src/controllers/cohort.js +++ b/src/controllers/cohort.js @@ -1,4 +1,4 @@ -import { createCohort } from '../domain/cohort.js' +import { Cohort, createCohort } from '../domain/cohort.js' import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' export const create = async (req, res) => { @@ -10,3 +10,31 @@ export const create = async (req, res) => { return sendMessageResponse(res, 500, 'Unable to create cohort') } } + +export const getAll = async (req, res) => { + const foundCohorts = await Cohort.findAll() + + const formattedCohorts = foundCohorts.map((cohort) => { + return { + ...cohort.toJSON().cohort + } + }) + + return sendDataResponse(res, 200, { cohorts: formattedCohorts }) +} + +export const getById = async (req, res) => { + const id = parseInt(req.params.id) + + try { + const foundCohort = await Cohort.findById(id) + + if (!foundCohort) { + return sendDataResponse(res, 404, { id: 'Cohort not found' }) + } + + return sendDataResponse(res, 200, foundCohort) + } catch (e) { + return sendMessageResponse(res, 500, 'Unable to get cohort') + } +} diff --git a/src/controllers/user.js b/src/controllers/user.js index 40ff0f1c..5279a1eb 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -2,8 +2,28 @@ import User from '../domain/user.js' import { sendDataResponse, sendMessageResponse } from '../utils/responses.js' export const create = async (req, res) => { + const rawPassword = req.body.password const userToCreate = await User.fromJson(req.body) + // validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(userToCreate.email)) { + return sendDataResponse(res, 400, { email: 'Invalid email format' }) + } + + // validate password format + // - At least 8 characters in length + // - Contains at least one uppercase letter + // - Contains at least one number + // - Contains at least one special character + const passwordRegex = /^(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/ + if (!passwordRegex.test(rawPassword)) { + return sendDataResponse(res, 400, { + password: + 'Password must be at least 8 characters long, contain at least one uppercase letter, one number, and one special character' + }) + } + try { const existingUser = await User.findByEmail(userToCreate.email) @@ -35,6 +55,30 @@ export const getById = async (req, res) => { } } +export const getByName = async (req, res) => { + try { + const { firstName, lastName } = req.query + if (!firstName && !lastName) { + return sendMessageResponse(res, 400, 'Search query is required') + } + + let usersList = [] + + if (firstName && lastName) { + usersList = await User.findManyThatContainsBothNames(firstName, lastName) + } else if (firstName) { + usersList = await User.findManyThatContainsFirstName(firstName) + } else if (lastName) { + usersList = await User.findManyThatContainsLastName(lastName) + } + + return sendDataResponse(res, 200, usersList) + } catch (error) { + console.error('Error fetching users:', error) + return sendMessageResponse(res, 500, 'Unable to get users') + } +} + export const getAll = async (req, res) => { // eslint-disable-next-line camelcase const { first_name: firstName } = req.query @@ -65,3 +109,17 @@ export const updateById = async (req, res) => { return sendDataResponse(res, 201, { user: { cohort_id: cohortId } }) } + +export const createProfile = async (req, res) => { + const paramId = parseInt(req.params.id) + const user = await User.findById(paramId) + + if (user == null) { + return sendDataResponse(res, 404, 'user not found!') + } + + const profile = await User.fromJson(req.body) + const createdProfile = await profile.createProfile(paramId) + + return sendDataResponse(res, 201, createdProfile) +} diff --git a/src/domain/cohort.js b/src/domain/cohort.js index abdda73b..1df26ae8 100644 --- a/src/domain/cohort.js +++ b/src/domain/cohort.js @@ -13,15 +13,86 @@ export async function createCohort() { } export class Cohort { - constructor(id = null) { + constructor( + id = null, + deliveryLogs = null, + users = null, + startDate, + endDate, + specialism = null, + jobTitle + ) { this.id = id + this.deliveryLogs = deliveryLogs + this.users = users + this.startDate = startDate + this.endDate = endDate + this.specialism = specialism + this.jobTitle = jobTitle } toJSON() { return { cohort: { - id: this.id + id: this.id, + deliveryLogs: this.deliveryLogs, + users: this.users, + startDate: this.startDate, + endDate: this.endDate, + specialism: this.specialism, + jobTitle: this.jobTitle } } } + + static fromDb(cohort) { + return new Cohort( + cohort.id, + cohort.deliveryLogs, + cohort.users, + cohort.startDate, + cohort.endDate, + cohort.specialism, + cohort.jobTitle + ) + } + + static async findAll() { + return Cohort._findMany() + } + + static async findById(id) { + return Cohort._findByUnique('id', id) + } + + static async _findByUnique(key, value) { + const foundCohort = await dbClient.cohort.findUnique({ + where: { + [key]: value + }, + include: { + deliveryLogs: true, + users: true + } + }) + + if (foundCohort) { + return Cohort.fromDb(foundCohort) + } + + return null + } + + static async _findMany(key, value) { + const query = { + include: { + deliveryLogs: true, + users: true + } + } + + const foundCohorts = await dbClient.cohort.findMany(query) + + return foundCohorts.map((cohort) => Cohort.fromDb(cohort)) + } } diff --git a/src/domain/specialism.js b/src/domain/specialism.js new file mode 100644 index 00000000..c1607898 --- /dev/null +++ b/src/domain/specialism.js @@ -0,0 +1,61 @@ +import dbClient from '../utils/dbClient.js' + +/** + * Create a new specialism in the database + * @returns {Specialism} + */ +export async function createSpecialism() { + const createdSpecialism = await dbClient.specialism.create({ + data: {} + }) + + return new Specialism(createdSpecialism.id) +} + +export class Specialism { + constructor(id = null, specialismName, cohorts = null) { + this.id = id + this.specialismName = specialismName + this.cohorts = cohorts + } + + toJSON() { + return { + specialism: { + id: this.id, + specialismName: this.specialismName, + cohorts: this.cohorts + } + } + } + + static fromDb(specialism) { + return new Specialism( + specialism.id, + specialism.specialismName, + specialism.cohorts + ) + } + + static async findAll() { + return Specialism._findMany() + } + + static async findById(id) { + return Specialism._findByUnique('id', id) + } + + static async _findByUnique(key, value) { + const foundSpecialism = await dbClient.specialism.findUnique({ + where: { + [key]: value + } + }) + + if (foundSpecialism) { + return Specialism.fromDb(foundSpecialism) + } + + return null + } +} diff --git a/src/domain/user.js b/src/domain/user.js index fd7734c7..0a991fee 100644 --- a/src/domain/user.js +++ b/src/domain/user.js @@ -7,7 +7,7 @@ export default class User { * take as inputs, what types they return, and other useful information that JS doesn't have built in * @tutorial https://www.valentinog.com/blog/jsdoc * - * @param { { id: int, cohortId: int, email: string, profile: { firstName: string, lastName: string, bio: string, githubUrl: string } } } user + * @param { { id: int, cohortId: int, email: string, profile: { firstName: string, lastName: string, bio: string, githubUrl: string, username: string, mobile: string } } } user * @returns {User} */ static fromDb(user) { @@ -19,6 +19,8 @@ export default class User { user.email, user.profile?.bio, user.profile?.githubUrl, + user.profile?.username, + user.profile?.mobile, user.password, user.role ) @@ -26,7 +28,16 @@ export default class User { static async fromJson(json) { // eslint-disable-next-line camelcase - const { firstName, lastName, email, biography, githubUrl, password } = json + const { + firstName, + lastName, + email, + biography, + githubUrl, + username, + mobile, + password + } = json const passwordHash = await bcrypt.hash(password, 8) @@ -38,6 +49,8 @@ export default class User { email, biography, githubUrl, + username, + mobile, passwordHash ) } @@ -50,6 +63,8 @@ export default class User { email, bio, githubUrl, + username, + mobile, passwordHash = null, role = 'STUDENT' ) { @@ -60,6 +75,8 @@ export default class User { this.email = email this.bio = bio this.githubUrl = githubUrl + this.username = username + this.mobile = mobile this.passwordHash = passwordHash this.role = role } @@ -74,7 +91,9 @@ export default class User { lastName: this.lastName, email: this.email, biography: this.bio, - githubUrl: this.githubUrl + githubUrl: this.githubUrl, + username: this.username, + mobile: this.mobile } } } @@ -118,6 +137,28 @@ export default class User { return User.fromDb(createdUser) } + async createProfile(id) { + console.log(typeof id) + const data = { + firstName: this.firstName, + lastName: this.lastName, + githubUrl: this.githubUrl, + bio: this.bio, + username: this.username, + mobile: this.mobile, + user: { + connect: { + id: id + } + } + } + const updatedUser = await dbClient.profile.create({ + data + }) + + return User.fromDb(updatedUser) + } + static async findByEmail(email) { return User._findByUnique('email', email) } @@ -130,6 +171,23 @@ export default class User { return User._findMany('firstName', firstName) } + static async findManyThatContainsFirstName(firstName) { + return User._findManyContains('firstName', firstName) + } + + static async findManyThatContainsLastName(lastName) { + return User._findManyContains('lastName', lastName) + } + + static async findManyThatContainsBothNames(firstName, lastName) { + return User._findManyContainsTwoKeys( + 'firstName', + firstName, + 'lastName', + lastName + ) + } + static async findAll() { return User._findMany() } @@ -170,4 +228,51 @@ export default class User { return foundUsers.map((user) => User.fromDb(user)) } + + static async _findManyContains(key, value) { + const foundUsers = await dbClient.user.findMany({ + where: { + profile: { + [key]: { + contains: value, + mode: 'insensitive' + } + } + }, + include: { + profile: true + } + }) + + return foundUsers.map((user) => User.fromDb(user)) + } + + static async _findManyContainsTwoKeys(key1, value1, key2, value2) { + const whereConditions = { + profile: {} + } + + if (value1) { + whereConditions.profile[key1] = { + contains: value1, + mode: 'insensitive' + } + } + + if (value2) { + whereConditions.profile[key2] = { + contains: value2, + mode: 'insensitive' + } + } + + const foundUsers = await dbClient.user.findMany({ + where: whereConditions, + include: { + profile: true + } + }) + + return foundUsers.map((user) => User.fromDb(user)) + } } diff --git a/src/routes/cohort.js b/src/routes/cohort.js index 3cc7813d..ac5d7b63 100644 --- a/src/routes/cohort.js +++ b/src/routes/cohort.js @@ -1,5 +1,5 @@ import { Router } from 'express' -import { create } from '../controllers/cohort.js' +import { create, getAll, getById } from '../controllers/cohort.js' import { validateAuthentication, validateTeacherRole @@ -8,5 +8,7 @@ import { const router = Router() router.post('/', validateAuthentication, validateTeacherRole, create) +router.get('/', validateAuthentication, getAll) +router.get('/:id', validateAuthentication, getById) export default router diff --git a/src/routes/user.js b/src/routes/user.js index 9f63d162..de16e9c5 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -1,5 +1,12 @@ import { Router } from 'express' -import { create, getById, getAll, updateById } from '../controllers/user.js' +import { + create, + getById, + getAll, + updateById, + getByName, + createProfile +} from '../controllers/user.js' import { validateAuthentication, validateTeacherRole @@ -9,7 +16,9 @@ const router = Router() router.post('/', create) router.get('/', validateAuthentication, getAll) +router.get('/search', validateAuthentication, getByName) router.get('/:id', validateAuthentication, getById) router.patch('/:id', validateAuthentication, validateTeacherRole, updateById) +router.post('/profile/:id', validateAuthentication, createProfile) export default router