diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js index 3810ab7..dd53f39 100644 --- a/src/controllers/user.controller.js +++ b/src/controllers/user.controller.js @@ -1,39 +1,6 @@ import prisma from '../config/prismaClient.js'; - +import { updateUserAccountValidation } from '../validations/user.validation.js'; /* eslint no-undef:off */ -/** - * @swagger - * /api/users: - * get: - * summary: Retrieve all users - * tags: [Users] - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: List of users retrieved successfully - * content: - * application/json: - * schema: - * type: array - * items: - * type: object - * properties: - * id: - * type: string - * email: - * type: string - * firstName: - * type: string - * lastName: - * type: string - * role: - * type: string - * 401: - * description: Unauthorized - * 500: - * description: Server error - */ export const getAllUsers = async (req, res, next) => { try { // Fetch all users from the database @@ -56,47 +23,10 @@ export const getAllUsers = async (req, res, next) => { } }; -/** - * @swagger - * /api/users/{id}: - * get: - * summary: Get user by ID - * tags: [Users] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * format: uuid - * description: User ID - * responses: - * 200: - * description: User details retrieved successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * id: - * type: string - * email: - * type: string - * firstName: - * type: string - * lastName: - * type: string - * role: - * type: string - * 404: - * description: User not found - * 500: - * description: Server error - */ export const getUserById = async (req, res, next) => { const { id } = req.params; try { - // Fetch the user by ID from the database + // Ensure req.user is defined const user = await prisma.user.findFirst({ where: { id }, select: { @@ -109,7 +39,7 @@ export const getUserById = async (req, res, next) => { }); if (!user) { - return res.status(404).json({ message: 'User not found' }); + return next(error); // Ensure next is called with the error } return res.status(200).json(user); @@ -117,3 +47,97 @@ export const getUserById = async (req, res, next) => { next(error); // Ensure next is called with the error } }; + +export const updateUserAccount = async (req, res, next) => { + try { + const userId = req.params.id; + if (!userId) { + return res.status(400).json({ message: 'User ID is required' }); + } + + const { error, value } = updateUserAccountValidation(req.body); + if (error) { + return res.status(400).json({ message: error.details[0].message }); + } + + const user = await prisma.user.findFirst({ + where: { id: userId }, + }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + if (!user.isActive) { + return res.status(403).json({ message: 'Account is not active' }); + } + + const updateData = {}; + + // Update basic profile fields + if (value.firstName) { + updateData.firstName = value.firstName; + } + if (value.lastName) { + updateData.lastName = value.lastName; + } + if (value.phoneNumber) { + updateData.phoneNumber = encrypt(value.phoneNumber); + } + if (value.jobTitle) { + updateData.jobTitle = value.jobTitle; + } + if (value.timezone) { + updateData.timezone = value.timezone; + } + if (value.bio) { + updateData.bio = value.bio; + } + + // Admin-only fields + if (req.user.role === 'ADMIN') { + if (value.role) { + updateData.role = value.role; + } + if (value.departmentId) { + updateData.departmentId = value.departmentId; + } + if (value.organizationId) { + updateData.organizationId = value.organizationId; + } + + // Set updatedBy if admin is modifying another user + if (req.user.id !== userId) { + updateData.updatedBy = req.user.id; + } + } + + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: updateData, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + phoneNumber: true, + jobTitle: true, + timezone: true, + bio: true, + departmentId: true, + organizationId: true, + profilePic: true, + createdAt: true, + updatedAt: true, + }, + }); + + res.status(200).json({ + message: 'User account updated successfully', + user: updatedUser, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/docs/swagger.json b/src/docs/swagger.json index 85989f3..572d546 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -726,7 +726,7 @@ } } }, - "/api/users": { + "/api/users/all": { "get": { "tags": ["Users"], "summary": "List users", @@ -745,7 +745,107 @@ } }, "401": { - "description": "Unauthorized" + "description": "Unauthorized", + "$ref": "#/components/responses/UnauthorizedError" + } + } + } + }, + "/api/users/{id}": { + "get": { + "tags": ["Users"], + "summary": "Get user by ID", + "description": "Get detailed information about a specific user", + "operationId": "getUserById", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "ID of the user to retrieve", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "User details", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + } + } + } + }, + "400": { + "description": "Invalid user ID", + "$ref": "#/components/responses/ValidationError" + }, + "404": { + "description": "User not found", + "$ref": "#/components/responses/NotFoundError" + } + } + }, + "put": { + "tags": ["Users"], + "summary": "Update user account", + "description": "Update user profile information", + "operationId": "updateUserAccount", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "ID of the user to update", + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "description": "User update data", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserRequest" + } + } + } + }, + "responses": { + "200": { + "description": "User updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + } + } + } + }, + "400": { + "description": "Invalid input", + "$ref": "#/components/responses/ValidationError" + }, + "401": { + "description": "Unauthorized", + "$ref": "#/components/responses/UnauthorizedError" + }, + "403": { + "description": "Forbidden - Account not active", + "$ref": "#/components/responses/ForbiddenError" + }, + "404": { + "description": "User not found", + "$ref": "#/components/responses/NotFoundError" } } } @@ -1571,6 +1671,37 @@ } } }, + "UserResponse": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "email": { + "type": "string", + "format": "email" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "role": { + "type": "string", + "enum": ["MEMBER", "ADMIN", "MANAGER"] + }, + "gender": { + "type": "string", + "enum": ["MALE", "FEMALE", "OTHER"] + }, + "DOB": { + "type": "string", + "format": "date" + } + } + }, "UserListResponse": { "type": "object", "properties": { @@ -1581,26 +1712,77 @@ "users": { "type": "array", "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "email": { - "type": "string" - }, - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" - }, - "role": { - "type": "string", - "enum": ["MEMBER", "ADMIN", "MANAGER"] - } - } + "$ref": "#/components/schemas/UserResponse" + } + } + } + }, + "UpdateUserRequest": { + "type": "object", + "properties": { + "firstName": { + "type": "string", + "minLength": 2, + "example": "John" + }, + "lastName": { + "type": "string", + "minLength": 2, + "example": "Doe" + }, + "gender": { + "type": "string", + "enum": ["MALE", "FEMALE", "OTHER"] + }, + "DOB": { + "type": "string", + "format": "date", + "example": "1990-01-01" + }, + "mobileNumber": { + "type": "string", + "example": "+1234567890" + } + } + } + }, + "responses": { + "UnauthorizedError": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "ValidationError": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "NotFoundError": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "ForbiddenError": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } diff --git a/src/middlewares/verifyUserPermission.middleware.js b/src/middlewares/verifyUserPermission.middleware.js new file mode 100644 index 0000000..5969def --- /dev/null +++ b/src/middlewares/verifyUserPermission.middleware.js @@ -0,0 +1,9 @@ +export const verifyUserPermission = (req, res, next) => { + if (req.params.id === req.user.id || req.user.role === 'ADMIN') { + return next(); + } + // Otherwise deny access + return res + .status(403) + .json({ message: 'You do not have permission to do this operation' }); +}; diff --git a/src/routes/user.routes.js b/src/routes/user.routes.js index 1602d7e..a15de4e 100644 --- a/src/routes/user.routes.js +++ b/src/routes/user.routes.js @@ -1,112 +1,35 @@ import { Router } from 'express'; -import { getAllUsers, getUserById } from '../controllers/user.controller.js'; +import { + getAllUsers, + getUserById, + updateUserAccount, +} from '../controllers/user.controller.js'; +import { verifyAccessToken } from '../middlewares/auth.middleware.js'; +import { verifyAdminPermission } from '../middlewares/verifyAdminPermission.middleware.js'; +import { verifyUserPermission } from '../middlewares/verifyUserPermission.middleware.js'; // import{authorizeUser} from '../middlewares/auth.middleware.js'; const router = Router(); -/** - * @swagger - * tags: - * name: Users - * description: User management endpoints - */ +router.get( + '/api/users/all', + verifyAccessToken, + verifyAdminPermission, + getAllUsers, +); -/** - * @swagger - * components: - * securitySchemes: - * bearerAuth: - * type: http - * scheme: bearer - * bearerFormat: JWT - * schemas: - * User: - * type: object - * properties: - * id: - * type: string - * format: uuid - * description: Unique user ID - * email: - * type: string - * format: email - * username: - * type: string - * firstName: - * type: string - * lastName: - * type: string - * role: - * type: string - * enum: [USER, ADMIN, MANAGER] - * description: User role - * profilePic: - * type: string - * format: uri - * nullable: true - * isActive: - * type: boolean - * createdAt: - * type: string - * format: date-time - * updatedAt: - * type: string - * format: date-time - */ +router.get( + '/api/users/:id', + verifyAccessToken, + verifyUserPermission, + getUserById, +); -/** - * @swagger - * /api/users: - * get: - * summary: Retrieve all users - * tags: [Users] - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: A list of users - * content: - * application/json: - * schema: - * type: object - * properties: - * success: - * type: boolean - * data: - * type: array - * items: - * $ref: '#/components/schemas/User' - * 401: - * description: Unauthorized - User must be authenticated - * 500: - * description: Server error - */ -router.get('/api/users', getAllUsers); - -/** - * @swagger - * /api/users/{id}: - * get: - * summary: Get user by ID - * tags: [Users] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * format: uuid - * description: User ID - * responses: - * 200: - * description: User details retrieved successfully - * 400: - * description: Invalid ID format - * 404: - * description: User not found - * 500: - * description: Server error - */ -router.get('/api/users/:id', getUserById); +router.put( + '/api/users/:id', + verifyAccessToken, + verifyUserPermission, + updateUserAccount, +); export default router; diff --git a/src/validations/user.validation.js b/src/validations/user.validation.js index 84da1c8..20c9ef4 100644 --- a/src/validations/user.validation.js +++ b/src/validations/user.validation.js @@ -2,38 +2,65 @@ import Joi from 'joi'; export const updateUserAccountValidation = (obj) => { const schema = Joi.object({ - firstName: Joi.string().trim().min(3).max(30).messages({ - 'string.min': 'First name must be at least 3 characters long.', - 'string.max': 'First name must be at most 30 characters long.', + firstName: Joi.string().trim().min(3).max(100).messages({ + 'string.min': 'First name must be at least 3 characters long', + 'string.max': 'First name cannot exceed 100 characters', + 'string.empty': 'First name cannot be empty', }), - lastName: Joi.string().trim().messages({ - 'string.min': 'Last name must be at least 3 characters long.', - 'string.max': 'Last name must be at most 30 characters long.', + lastName: Joi.string().trim().min(3).max(100).messages({ + 'string.min': 'Last name must be at least 3 characters long', + 'string.max': 'Last name cannot exceed 100 characters', + 'string.empty': 'Last name cannot be empty', }), - gender: Joi.string().valid('Male', 'Female').messages({ - 'any.only': "Gender must be either 'male' or 'female'.", - }), - DOB: Joi.date() - .max(new Date(new Date().setFullYear(new Date().getFullYear() - 18))) // Ensures the user is at least 18 years old + phoneNumber: Joi.string() + .trim() + .max(50) + .pattern(/^\+?[0-9\s\-()]{7,}$/) .messages({ - 'date.base': 'Invalid date format for DOB.', - 'date.max': 'You must be at least 18 years old.', + 'string.pattern.base': 'Invalid phone number format', + 'string.max': 'Phone number cannot exceed 50 characters', }), - mobileNumber: Joi.string().trim().messages({ - 'string.empty': 'Mobile number cannot be empty if provided', + jobTitle: Joi.string().trim().max(100).messages({ + 'string.max': 'Job title cannot exceed 100 characters', + }), + timezone: Joi.string().trim().max(50).messages({ + 'string.max': 'Timezone cannot exceed 50 characters', + }), + bio: Joi.string().max(1000).messages({ + 'string.max': 'Bio cannot exceed 1000 characters', }), - }).min(1); // At least one field must be provided + }); - return schema.validate(obj); + return schema.validate(obj, { abortEarly: false }); }; export const updateUserPasswordValidation = (obj) => { const schema = Joi.object({ - password: Joi.string().required().trim().min(8).max(32).messages({ - 'string.empty': 'Password is required.', - 'string.min': 'Password must be at least 8 characters long.', - 'string.max': 'Password must be at most 32 characters long.', + currentPassword: Joi.string().required().messages({ + 'any.required': 'Current password is required', }), + newPassword: Joi.string() + .required() + .min(8) + .max(32) + .regex( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, + ) + .messages({ + 'string.empty': 'New password cannot be empty', + 'string.min': 'Password must be at least 8 characters', + 'string.max': 'Password cannot exceed 32 characters', + 'string.pattern.base': + 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character', + }), + confirmPassword: Joi.string() + .valid(Joi.ref('newPassword')) + .required() + .messages({ + 'any.only': 'Passwords do not match', + 'any.required': 'Confirm password is required', + }), }); - return schema.validate(obj); + + return schema.validate(obj, { abortEarly: false }); };