diff --git a/src/modules/project/models/project.model.js b/src/modules/project/models/project.model.js index 8eb9c40..b4f7410 100644 --- a/src/modules/project/models/project.model.js +++ b/src/modules/project/models/project.model.js @@ -88,10 +88,33 @@ const updateProjectModel = joi.object().keys({ }); const getAllProjectsModel = joi.object().keys({ - status: joi.string().valid(...PROJECT_STATUS_VALUES).optional().messages({ - 'string.base': 'Project status must be a string.', - 'any.only': `Project status must be one of: ${PROJECT_STATUS_VALUES.join(', ')}.`, - }), + status: joi + .alternatives() + .try( + joi.string(), + joi.array().items(joi.string()) + ) + .optional() + .custom((value, helpers) => { + const statuses = Array.isArray(value) + ? value.flatMap((status) => String(status).split(',')) + : String(value).split(','); + + const invalidStatuses = statuses + .map((status) => status.trim().toLowerCase()) + .filter(Boolean) + .filter((status) => !PROJECT_STATUS_VALUES.includes(status)); + + if (invalidStatuses.length > 0) { + return helpers.error('any.only'); + } + + return value; + }) + .messages({ + 'alternatives.types': 'Project status must be a string or an array of strings.', + 'any.only': `Project status must be one of: ${PROJECT_STATUS_VALUES.join(', ')}.`, + }), }); module.exports = { diff --git a/src/modules/project/repositories/project.repository.js b/src/modules/project/repositories/project.repository.js index e140deb..43ebc2a 100644 --- a/src/modules/project/repositories/project.repository.js +++ b/src/modules/project/repositories/project.repository.js @@ -58,11 +58,13 @@ class ProjectRepository { }); } - async findAll({ status, actor }) { + async findAll({ statuses, actor }) { const where = {}; - if (status) { - where.status = status; + if (statuses && statuses.length > 0) { + where.status = { + in: statuses, + }; } if (actor?.role === "intern") { @@ -428,6 +430,9 @@ class ProjectRepository { in: userIds.map((id) => parseInt(id)), }, status: "active", + project: { + status: "active" + } }, include: { user: { diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js index f49bed2..1e21a5f 100644 --- a/src/modules/project/services/project.service.js +++ b/src/modules/project/services/project.service.js @@ -109,8 +109,13 @@ class ProjectService { async getAllProjects(query = {}, actor = {}) { try { + const statusFilter = this.parseProjectStatusFilter(query.status); + if (statusFilter.err) { + return statusFilter; + } + const projects = await projectRepository.findAll({ - status: query.status, + statuses: statusFilter.data, actor, }); @@ -293,6 +298,40 @@ class ProjectService { return new ForbiddenError("Access denied: invalid role"); } + parseProjectStatusFilter(rawStatus) { + if (!rawStatus) { + return data(null); + } + + const allowedStatuses = ["active", "completed", "archived"]; + + const statuses = Array.isArray(rawStatus) + ? rawStatus.flatMap((status) => String(status).split(",")) + : String(rawStatus).split(","); + + const cleanedStatuses = [ + ...new Set( + statuses + .map((status) => status.trim().toLowerCase()) + .filter(Boolean) + ), + ]; + + const invalidStatuses = cleanedStatuses.filter( + (status) => !allowedStatuses.includes(status) + ); + + if (invalidStatuses.length > 0) { + return error( + new BadRequestError( + `Invalid project status: ${invalidStatuses.join(", ")}` + ) + ); + } + + return data(cleanedStatuses); + } + normalizeEmails(emails = []) { return [ ...new Set( @@ -534,13 +573,16 @@ class ProjectService { const { id_project, id_user } = payload; if (!id_project || !id_user) { - return error(new BadRequestError("Project ID and User ID are required")); + return error(new BadRequestError("Project ID/slug and User ID are required")); } - const project = await projectRepository.findById(id_project); + const project = await projectRepository.findByIdOrSlug(id_project); if (!project) { return error(new NotFoundError("Project not found")); } + if (project.status !== "active") { + return error(new ConflictError("Cannot assign member to a non-active project")); + } const user = await projectRepository.findUserByIdAndActive(id_user); if (!user) { @@ -556,7 +598,7 @@ class ProjectService { const activeMemberships = await projectRepository.findActiveMembershipsByUserIds([id_user]); if (activeMemberships.length > 0) { const activeProj = activeMemberships[0].project; - if (activeMemberships[0].id_project === parseInt(id_project)) { + if (activeMemberships[0].id_project === project.id) { return error(new ConflictError("User is already an active member of this project")); } else { return error( @@ -567,13 +609,13 @@ class ProjectService { } } - const existingMembership = await projectRepository.findMembership(id_project, id_user); + const existingMembership = await projectRepository.findMembership(project.id, id_user); let membership; if (existingMembership) { membership = await projectRepository.updateMembershipStatus(existingMembership.id, 'active'); } else { - membership = await projectRepository.assignMember(id_project, id_user); + membership = await projectRepository.assignMember(project.id, id_user); } await notificationService.createNotificationForIntern( @@ -603,10 +645,10 @@ class ProjectService { const { id_project, id_user } = payload; if (!id_project || !id_user) { - return error(new BadRequestError("Project ID and User ID are required")); + return error(new BadRequestError("Project ID/slug and User ID are required")); } - const project = await projectRepository.findById(id_project); + const project = await projectRepository.findByIdOrSlug(id_project); if (!project) { return error(new NotFoundError("Project not found")); } @@ -616,7 +658,13 @@ class ProjectService { return error(new NotFoundError("User not found or inactive")); } - const existingMembership = await projectRepository.findMembership(id_project, id_user); + if (user.role !== "intern") { + return error( + new ForbiddenError("Access denied: only interns can be removed from projects") + ); + } + + const existingMembership = await projectRepository.findMembership(project.id, id_user); if (!existingMembership || existingMembership.status !== 'active') { return error(new BadRequestError("User is not an active member of this project")); diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index 4146f0a..f5ac657 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -125,7 +125,7 @@ const router = express.Router(); * type: string * nullable: true * example: Senior Program Manager at Internify. - * ProjectListItem: + * ProjectBaseItem: * type: object * properties: * id: @@ -136,6 +136,7 @@ const router = express.Router(); * example: internify-platform-dev * project_icon: * type: string + * enum: [code, chart, cloud, mobile, gear, users, clipboard, speedometer, lightbulb, shield] * example: code * background_color: * type: string @@ -146,6 +147,10 @@ const router = express.Router(); * description: * type: string * example: Upgrading the core Internify platform backend and web client. + * certificate_template: + * type: string + * nullable: true + * example: /uploads/certificate-template.png * start_date: * type: string * format: date-time @@ -156,6 +161,7 @@ const router = express.Router(); * example: 2026-10-15T00:00:00.000Z * status: * type: string + * enum: [active, completed, archived] * example: active * admin: * $ref: '#/components/schemas/ProjectAdminSummary' @@ -165,10 +171,6 @@ const router = express.Router(); * total_tasks: * type: integer * example: 3 - * task_done: - * type: integer - * example: 2 - * description: Number of completed tasks. For interns, this counts tasks submitted by the logged-in intern. For admin/mentor, this counts tasks submitted by all active project members. * created_at: * type: string * format: date-time @@ -177,6 +179,15 @@ const router = express.Router(); * type: string * format: date-time * example: 2026-06-10T10:00:00.000Z + * ProjectListItem: + * allOf: + * - $ref: '#/components/schemas/ProjectBaseItem' + * - type: object + * properties: + * task_done: + * type: integer + * example: 2 + * description: Number of completed tasks. For interns, this counts tasks submitted by the logged-in intern. For admin/mentor, this counts tasks submitted by all active project members. * ProjectMemberUser: * type: object * properties: @@ -256,7 +267,7 @@ const router = express.Router(); * example: 2026-06-10T10:00:00.000Z * ProjectDetail: * allOf: - * - $ref: '#/components/schemas/ProjectListItem' + * - $ref: '#/components/schemas/ProjectBaseItem' * - type: object * properties: * members: @@ -523,10 +534,10 @@ router.post('/add', verifyJWT, isMentorOrAdmin, projectController.createProject) * name: status * schema: * type: string - * enum: [active, completed, archived] + * example: 'active,completed' * required: false - * description: Filter projects by status - * example: active + * description: Filter projects by one or more statuses. Use comma-separated values, for example active,completed. Values are **active, completed, archived** + * example: active,completed * responses: * 200: * description: Projects retrieved successfully @@ -534,6 +545,8 @@ router.post('/add', verifyJWT, isMentorOrAdmin, projectController.createProject) * application/json: * schema: * $ref: '#/components/schemas/ProjectListResponse' + * 400: + * description: Invalid project status * 401: * description: Unauthorized * 417: @@ -825,8 +838,15 @@ router.get('/interns', verifyJWT, isMentorOrAdmin, projectController.getInterns) * - id_user * properties: * id_project: - * type: integer - * example: 1 + * oneOf: + * - type: integer + * example: 1 + * description: Project ID + * - type: string + * example: internify-lms + * description: Project slug + * description: Project ID or project slug + * example: internify-platform-dev * id_user: * type: integer * example: 2 @@ -846,7 +866,7 @@ router.get('/interns', verifyJWT, isMentorOrAdmin, projectController.getInterns) * 404: * description: Project or User not found * 409: - * description: Conflict (User is already an active member of this project) + * description: Conflict (User is already an active member of this project or target project is not active) * 500: * description: Internal server error */ @@ -872,8 +892,15 @@ router.post('/assign-member', verifyJWT, isMentorOrAdmin, projectController.assi * - id_user * properties: * id_project: - * type: integer - * example: 1 + * oneOf: + * - type: integer + * example: 1 + * description: Project ID + * - type: string + * example: internify-lms + * description: Project slug + * description: Project ID or slug + * example: internify-lms * id_user: * type: integer * example: 2