Merge branch 'main' into feat/certificate-api
This commit is contained in:
@@ -88,8 +88,31 @@ 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()
|
||||||
|
.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(', ')}.`,
|
'any.only': `Project status must be one of: ${PROJECT_STATUS_VALUES.join(', ')}.`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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:
|
||||||
|
* - type: integer
|
||||||
* example: 1
|
* 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:
|
||||||
|
* - type: integer
|
||||||
* example: 1
|
* 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
|
||||||
|
|||||||
Reference in New Issue
Block a user