Merge branch 'main' into feat/certificate-api

This commit is contained in:
Rafi Athallah
2026-06-28 22:22:35 +07:00
4 changed files with 133 additions and 30 deletions
+27 -4
View File
@@ -88,10 +88,33 @@ const updateProjectModel = joi.object().keys({
}); });
const getAllProjectsModel = joi.object().keys({ const getAllProjectsModel = joi.object().keys({
status: joi.string().valid(...PROJECT_STATUS_VALUES).optional().messages({ status: joi
'string.base': 'Project status must be a string.', .alternatives()
'any.only': `Project status must be one of: ${PROJECT_STATUS_VALUES.join(', ')}.`, .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 = { module.exports = {
@@ -58,11 +58,13 @@ class ProjectRepository {
}); });
} }
async findAll({ status, actor }) { async findAll({ statuses, actor }) {
const where = {}; const where = {};
if (status) { if (statuses && statuses.length > 0) {
where.status = status; where.status = {
in: statuses,
};
} }
if (actor?.role === "intern") { if (actor?.role === "intern") {
@@ -428,6 +430,9 @@ class ProjectRepository {
in: userIds.map((id) => parseInt(id)), in: userIds.map((id) => parseInt(id)),
}, },
status: "active", status: "active",
project: {
status: "active"
}
}, },
include: { include: {
user: { user: {
@@ -109,8 +109,13 @@ class ProjectService {
async getAllProjects(query = {}, actor = {}) { async getAllProjects(query = {}, actor = {}) {
try { try {
const statusFilter = this.parseProjectStatusFilter(query.status);
if (statusFilter.err) {
return statusFilter;
}
const projects = await projectRepository.findAll({ const projects = await projectRepository.findAll({
status: query.status, statuses: statusFilter.data,
actor, actor,
}); });
@@ -293,6 +298,40 @@ class ProjectService {
return new ForbiddenError("Access denied: invalid role"); 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 = []) { normalizeEmails(emails = []) {
return [ return [
...new Set( ...new Set(
@@ -534,13 +573,16 @@ class ProjectService {
const { id_project, id_user } = payload; const { id_project, id_user } = payload;
if (!id_project || !id_user) { 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) { if (!project) {
return error(new NotFoundError("Project not found")); 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); const user = await projectRepository.findUserByIdAndActive(id_user);
if (!user) { if (!user) {
@@ -556,7 +598,7 @@ class ProjectService {
const activeMemberships = await projectRepository.findActiveMembershipsByUserIds([id_user]); const activeMemberships = await projectRepository.findActiveMembershipsByUserIds([id_user]);
if (activeMemberships.length > 0) { if (activeMemberships.length > 0) {
const activeProj = activeMemberships[0].project; 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")); return error(new ConflictError("User is already an active member of this project"));
} else { } else {
return error( 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; let membership;
if (existingMembership) { if (existingMembership) {
membership = await projectRepository.updateMembershipStatus(existingMembership.id, 'active'); membership = await projectRepository.updateMembershipStatus(existingMembership.id, 'active');
} else { } else {
membership = await projectRepository.assignMember(id_project, id_user); membership = await projectRepository.assignMember(project.id, id_user);
} }
await notificationService.createNotificationForIntern( await notificationService.createNotificationForIntern(
@@ -603,10 +645,10 @@ class ProjectService {
const { id_project, id_user } = payload; const { id_project, id_user } = payload;
if (!id_project || !id_user) { 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) { if (!project) {
return error(new NotFoundError("Project not found")); return error(new NotFoundError("Project not found"));
} }
@@ -616,7 +658,13 @@ class ProjectService {
return error(new NotFoundError("User not found or inactive")); 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') { if (!existingMembership || existingMembership.status !== 'active') {
return error(new BadRequestError("User is not an active member of this project")); return error(new BadRequestError("User is not an active member of this project"));
+41 -14
View File
@@ -125,7 +125,7 @@ const router = express.Router();
* type: string * type: string
* nullable: true * nullable: true
* example: Senior Program Manager at Internify. * example: Senior Program Manager at Internify.
* ProjectListItem: * ProjectBaseItem:
* type: object * type: object
* properties: * properties:
* id: * id:
@@ -136,6 +136,7 @@ const router = express.Router();
* example: internify-platform-dev * example: internify-platform-dev
* project_icon: * project_icon:
* type: string * type: string
* enum: [code, chart, cloud, mobile, gear, users, clipboard, speedometer, lightbulb, shield]
* example: code * example: code
* background_color: * background_color:
* type: string * type: string
@@ -146,6 +147,10 @@ const router = express.Router();
* description: * description:
* type: string * type: string
* example: Upgrading the core Internify platform backend and web client. * example: Upgrading the core Internify platform backend and web client.
* certificate_template:
* type: string
* nullable: true
* example: /uploads/certificate-template.png
* start_date: * start_date:
* type: string * type: string
* format: date-time * format: date-time
@@ -156,6 +161,7 @@ const router = express.Router();
* example: 2026-10-15T00:00:00.000Z * example: 2026-10-15T00:00:00.000Z
* status: * status:
* type: string * type: string
* enum: [active, completed, archived]
* example: active * example: active
* admin: * admin:
* $ref: '#/components/schemas/ProjectAdminSummary' * $ref: '#/components/schemas/ProjectAdminSummary'
@@ -165,10 +171,6 @@ const router = express.Router();
* total_tasks: * total_tasks:
* type: integer * type: integer
* example: 3 * 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: * created_at:
* type: string * type: string
* format: date-time * format: date-time
@@ -177,6 +179,15 @@ const router = express.Router();
* type: string * type: string
* format: date-time * format: date-time
* example: 2026-06-10T10:00:00.000Z * 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: * ProjectMemberUser:
* type: object * type: object
* properties: * properties:
@@ -256,7 +267,7 @@ const router = express.Router();
* example: 2026-06-10T10:00:00.000Z * example: 2026-06-10T10:00:00.000Z
* ProjectDetail: * ProjectDetail:
* allOf: * allOf:
* - $ref: '#/components/schemas/ProjectListItem' * - $ref: '#/components/schemas/ProjectBaseItem'
* - type: object * - type: object
* properties: * properties:
* members: * members:
@@ -523,10 +534,10 @@ router.post('/add', verifyJWT, isMentorOrAdmin, projectController.createProject)
* name: status * name: status
* schema: * schema:
* type: string * type: string
* enum: [active, completed, archived] * example: 'active,completed'
* required: false * required: false
* description: Filter projects by status * description: Filter projects by one or more statuses. Use comma-separated values, for example active,completed. Values are **active, completed, archived**
* example: active * example: active,completed
* responses: * responses:
* 200: * 200:
* description: Projects retrieved successfully * description: Projects retrieved successfully
@@ -534,6 +545,8 @@ router.post('/add', verifyJWT, isMentorOrAdmin, projectController.createProject)
* application/json: * application/json:
* schema: * schema:
* $ref: '#/components/schemas/ProjectListResponse' * $ref: '#/components/schemas/ProjectListResponse'
* 400:
* description: Invalid project status
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 417: * 417:
@@ -825,8 +838,15 @@ router.get('/interns', verifyJWT, isMentorOrAdmin, projectController.getInterns)
* - id_user * - id_user
* properties: * properties:
* id_project: * id_project:
* type: integer * oneOf:
* example: 1 * - 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: * id_user:
* type: integer * type: integer
* example: 2 * example: 2
@@ -846,7 +866,7 @@ router.get('/interns', verifyJWT, isMentorOrAdmin, projectController.getInterns)
* 404: * 404:
* description: Project or User not found * description: Project or User not found
* 409: * 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: * 500:
* description: Internal server error * description: Internal server error
*/ */
@@ -872,8 +892,15 @@ router.post('/assign-member', verifyJWT, isMentorOrAdmin, projectController.assi
* - id_user * - id_user
* properties: * properties:
* id_project: * id_project:
* type: integer * oneOf:
* example: 1 * - 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: * id_user:
* type: integer * type: integer
* example: 2 * example: 2