import moment from "moment"; import db from "../config/db"; import HandleExceptions from "../decorators/handle-exceptions"; import {IWorkLenzRequest} from "../interfaces/worklenz-request"; import {IWorkLenzResponse} from "../interfaces/worklenz-response"; import {ServerResponse} from "../models/server-response"; import {LOG_DESCRIPTIONS} from "../shared/constants"; import {getColor} from "../shared/utils"; import {generateProjectKey} from "../utils/generate-project-key"; import WorklenzControllerBase from "./worklenz-controller-base"; import { NotificationsService } from "../services/notifications/notifications.service"; import { IPassportSession } from "../interfaces/passport-session"; import { SocketEvents } from "../socket.io/events"; import { IO } from "../shared/io"; import { getCurrentProjectsCount, getFreePlanSettings } from "../shared/paddle-utils"; export default class ProjectsController extends WorklenzControllerBase { private static async getAllKeysByTeamId(teamId?: string) { if (!teamId) return []; try { const result = await db.query("SELECT key FROM projects WHERE team_id = $1;", [teamId]); return result.rows.map((project: any) => project.key).filter((key: any) => !!key); } catch (error) { return []; } } private static async notifyProjecManagertUpdates(projectId: string, user: IPassportSession, projectManagerTeamMemberId: string | null) { if (projectManagerTeamMemberId) { const q = `SELECT (SELECT user_id FROM team_member_info_view WHERE team_member_id = $2) AS user_id, (SELECT socket_id FROM users WHERE id = (SELECT user_id FROM team_member_info_view WHERE team_member_id = $2)) AS socket_id, (SELECT name FROM projects WHERE id = $1) AS project_name FROM project_members pm WHERE project_id = $1 AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER')`; const result = await db.query(q, [projectId, projectManagerTeamMemberId]); const [data] = result.rows; if (projectManagerTeamMemberId !== user.team_member_id) { void NotificationsService.createNotification({ userId: data.user_id, teamId: user?.team_id as string, socketId: data.socket_id, message: `You're assigned as the Project Manager of the ${data.project_name} .`, taskId: null, projectId: projectId as string }); } } IO.getSocketById(user.socket_id as string) ?.to(projectId) .emit(SocketEvents.PROJECT_DATA_CHANGE.toString(), {user_id: user.id}); } @HandleExceptions({ raisedExceptions: { "PROJECT_EXISTS_ERROR": `A project with the name "{0}" already exists. Please choose a different name.` } }) public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { if (req.user?.subscription_status === "free" && req.user?.owner_id) { const limits = await getFreePlanSettings(); const projectsCount = await getCurrentProjectsCount(req.user.owner_id); const projectsLimit = parseInt(limits.projects_limit); if (parseInt(projectsCount) >= projectsLimit) { return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`)); } } const q = `SELECT create_project($1) AS project`; req.body.team_id = req.user?.team_id || null; req.body.user_id = req.user?.id || null; req.body.folder_id = req.body.folder_id || null; req.body.category_id = req.body.category_id?.trim() || null; req.body.client_name = req.body.client_name?.trim() || null; req.body.project_created_log = LOG_DESCRIPTIONS.PROJECT_CREATED; req.body.project_member_added_log = LOG_DESCRIPTIONS.PROJECT_MEMBER_ADDED; req.body.project_manager_id = req.body.project_manager ? req.body.project_manager.id : null; const keys = await this.getAllKeysByTeamId(req.user?.team_id as string); req.body.key = generateProjectKey(req.body.name, keys) || null; const result = await db.query(q, [JSON.stringify(req.body)]); const [data] = result.rows; return res.status(200).send(new ServerResponse(true, data.project || {})); } @HandleExceptions() public static async updatePinnedView(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const projectId = req.body.project_id; const teamMemberId = req.user?.team_member_id; const defaultView = req.body.default_view; const q = `UPDATE project_members SET default_view = $1 WHERE project_id = $2 AND team_member_id = $3`; const result = await db.query(q, [defaultView, projectId, teamMemberId]); const [data] = result.rows; return res.status(200).send(new ServerResponse(true, data)); } @HandleExceptions() public static async getMyProjectsToTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const q = `SELECT id, name, color_code FROM projects WHERE team_id = $1 AND is_member_of_project(projects.id, $2, $1)`; const result = await db.query(q, [req.user?.team_id, req.user?.id || null]); return res.status(200).send(new ServerResponse(true, result.rows)); } @HandleExceptions() public static async getMyProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const {searchQuery, size, offset} = this.toPaginationOptions(req.query, "name"); const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : ""; const isArchived = req.query.filter === "2" ? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`; const q = ` SELECT ROW_TO_JSON(rec) AS projects FROM (SELECT COUNT(*) AS total, (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) FROM (SELECT id, name, EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id) AS favorite, EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id) AS archived, color_code, (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = projects.id) AS all_tasks_count, (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = projects.id AND status_id IN (SELECT id FROM task_statuses WHERE project_id = projects.id AND category_id IN (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count, (SELECT COUNT(*) FROM project_members WHERE project_id = projects.id) AS members_count, (SELECT get_project_members(projects.id)) AS names, (SELECT CASE WHEN ((SELECT MAX(updated_at) FROM tasks WHERE archived IS FALSE AND project_id = projects.id) > updated_at) THEN (SELECT MAX(updated_at) FROM tasks WHERE archived IS FALSE AND project_id = projects.id) ELSE updated_at END) AS updated_at FROM projects WHERE team_id = $1 ${isArchived} ${isFavorites} ${searchQuery} AND is_member_of_project(projects.id , '${req.user?.id}' , $1) ORDER BY updated_at DESC LIMIT $2 OFFSET $3) t) AS data FROM projects WHERE team_id = $1 ${isArchived} ${isFavorites} ${searchQuery} AND is_member_of_project(projects.id , '${req.user?.id}' , $1)) rec; `; const result = await db.query(q, [req.user?.team_id || null, size, offset]); const [data] = result.rows; const projects = Array.isArray(data?.projects.data) ? data?.projects.data : []; for (const project of projects) { project.progress = project.all_tasks_count > 0 ? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0; } return res.status(200).send(new ServerResponse(true, data?.projects || this.paginatedDatasetDefaultStruct)); } private static flatString(text: string) { return (text || "").split(" ").map(s => `'${s}'`).join(","); } private static getFilterByCategoryWhereClosure(text: string) { return text ? `AND category_id IN (${this.flatString(text)})` : ""; } private static getFilterByStatusWhereClosure(text: string) { return text ? `AND status_id IN (${this.flatString(text)})` : ""; } @HandleExceptions() public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name"); const filterByMember = !req.user?.owner && !req.user?.is_admin ? ` AND is_member_of_project(projects.id, '${req.user?.id}', $1) ` : ""; const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : ""; const isArchived = req.query.filter === "2" ? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`; const categories = this.getFilterByCategoryWhereClosure(req.query.categories as string); const statuses = this.getFilterByStatusWhereClosure(req.query.statuses as string); const q = ` SELECT ROW_TO_JSON(rec) AS projects FROM (SELECT COUNT(*) AS total, (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) FROM (SELECT id, name, (SELECT name FROM sys_project_statuses WHERE id = status_id) AS status, (SELECT color_code FROM sys_project_statuses WHERE id = status_id) AS status_color, (SELECT icon FROM sys_project_statuses WHERE id = status_id) AS status_icon, EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id) AS favorite, EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id) AS archived, color_code, start_date, end_date, category_id, (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = projects.id) AS all_tasks_count, (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = projects.id AND status_id IN (SELECT id FROM task_statuses WHERE project_id = projects.id AND category_id IN (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count, (SELECT COUNT(*) FROM project_members WHERE project_id = projects.id) AS members_count, (SELECT get_project_members(projects.id)) AS names, (SELECT name FROM clients WHERE id = projects.client_id) AS client_name, (SELECT name FROM users WHERE id = projects.owner_id) AS project_owner, (SELECT name FROM project_categories WHERE id = projects.category_id) AS category_name, (SELECT color_code FROM project_categories WHERE id = projects.category_id) AS category_color, ((SELECT team_member_id as team_member_id FROM project_members WHERE project_id = projects.id AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER'))) AS project_manager_team_member_id, (SELECT default_view FROM project_members prm WHERE prm.project_id = projects.id AND team_member_id = '${req.user?.team_member_id}') AS team_member_default_view, (SELECT CASE WHEN ((SELECT MAX(updated_at) FROM tasks WHERE archived IS FALSE AND project_id = projects.id) > updated_at) THEN (SELECT MAX(updated_at) FROM tasks WHERE archived IS FALSE AND project_id = projects.id) ELSE updated_at END) AS updated_at FROM projects WHERE team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery} ORDER BY ${sortField} ${sortOrder} LIMIT $2 OFFSET $3) t) AS data FROM projects WHERE team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}) rec; `; const result = await db.query(q, [req.user?.team_id || null, size, offset]); const [data] = result.rows; for (const project of data?.projects.data || []) { project.progress = project.all_tasks_count > 0 ? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0; project.updated_at_string = moment(project.updated_at).fromNow(); project.names = this.createTagList(project?.names); project.names.map((a: any) => a.color_code = getColor(a.name)); if (project.project_manager_team_member_id) { project.project_manager = { id : project.project_manager_team_member_id }; } } return res.status(200).send(new ServerResponse(true, data?.projects || this.paginatedDatasetDefaultStruct)); } @HandleExceptions() public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name"); const search = (req.query.search || "").toString().trim(); let searchFilter = ""; const params = [req.params.id, req.user?.team_id ?? null, size, offset]; if (search) { searchFilter = ` AND ( (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%' OR (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%' ) `; params.push(search); } const q = ` WITH filtered_members AS ( SELECT project_members.id, team_member_id, (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS name, (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS email, u.avatar_url, (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count, (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id) AND status_id IN (SELECT id FROM task_statuses WHERE category_id = (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count, EXISTS(SELECT email FROM email_invitations WHERE team_member_id = project_members.team_member_id AND email_invitations.team_id = $2) AS pending_invitation, (SELECT project_access_levels.name FROM project_access_levels WHERE project_access_levels.id = project_members.project_access_level_id) AS access, (SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title FROM project_members INNER JOIN team_members tm ON project_members.team_member_id = tm.id LEFT JOIN users u ON tm.user_id = u.id WHERE project_id = $1 ${search ? searchFilter : ""} ) SELECT (SELECT COUNT(*) FROM filtered_members) AS total, (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) FROM ( SELECT * FROM filtered_members ORDER BY ${sortField} ${sortOrder} LIMIT $3 OFFSET $4 ) t ) AS data `; const result = await db.query(q, params); const [data] = result.rows; for (const member of data?.data || []) { member.progress = member.all_tasks_count > 0 ? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0; } return res.status(200).send(new ServerResponse(true, data || this.paginatedDatasetDefaultStruct)); } @HandleExceptions() public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const q = ` SELECT projects.id, projects.name, projects.color_code, projects.notes, projects.key, projects.start_date, projects.end_date, projects.status_id, projects.health_id, projects.created_at, projects.updated_at, projects.folder_id, projects.phase_label, projects.category_id, (projects.estimated_man_days) AS man_days, (projects.estimated_working_days) AS working_days, (projects.hours_per_day) AS hours_per_day, (SELECT name FROM project_categories WHERE id = projects.category_id) AS category_name, (SELECT color_code FROM project_categories WHERE id = projects.category_id) AS category_color, (EXISTS(SELECT 1 FROM project_subscribers WHERE project_id = $1 AND user_id = $3)) AS subscribed, (SELECT name FROM users WHERE id = projects.owner_id) AS project_owner, sps.name AS status, sps.color_code AS status_color, sps.icon AS status_icon, (SELECT name FROM clients WHERE id = projects.client_id) AS client_name, projects.use_manual_progress, projects.use_weighted_progress, projects.use_time_progress, (SELECT COALESCE(ROW_TO_JSON(pm), '{}'::JSON) FROM (SELECT team_member_id AS id, (SELECT COALESCE(ROW_TO_JSON(pmi), '{}'::JSON) FROM (SELECT name, email, avatar_url FROM team_member_info_view tmiv WHERE tmiv.team_member_id = pm.team_member_id AND tmiv.team_id = (SELECT team_id FROM projects WHERE id = $1)) pmi) AS project_manager_info, EXISTS(SELECT email FROM email_invitations WHERE team_member_id = pm.team_member_id AND email_invitations.team_id = (SELECT team_id FROM team_member_info_view WHERE team_member_id = pm.team_member_id)) AS pending_invitation, (SELECT active FROM team_members WHERE id = pm.team_member_id) FROM project_members pm WHERE project_id = $1 AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER')) pm) AS project_manager FROM projects LEFT JOIN sys_project_statuses sps ON projects.status_id = sps.id WHERE projects.id = $1 AND team_id = $2; `; const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, req.user?.id ?? null]); const [data] = result.rows; if (data && data.project_manager) { data.project_manager.name = data.project_manager.project_manager_info.name; data.project_manager.email = data.project_manager.project_manager_info.email; data.project_manager.avatar_url = data.project_manager.project_manager_info.avatar_url; data.project_manager.color_code = getColor(data.project_manager.name); } return res.status(200).send(new ServerResponse(true, data)); } @HandleExceptions({ raisedExceptions: { "PROJECT_EXISTS_ERROR": `Project with "{0}" name already exists. Please choose a different project name.` } }) public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const q = `SELECT update_project($1) AS project;`; const key = req.body.key?.toString().trim().toUpperCase(); if (!key) return res.status(200).send(new ServerResponse(false, null, "The project key cannot be empty.")); if (key.length > 5) return res.status(200).send(new ServerResponse(false, null, "The project key length cannot exceed 5 characters.")); req.body.id = req.params.id; req.body.team_id = req.user?.team_id || null; req.body.user_id = req.user?.id || null; req.body.folder_id = req.body.folder_id || null; req.body.category_id = req.body.category_id || null; req.body.client_name = req.body.client_name?.trim() || null; req.body.project_created_log = LOG_DESCRIPTIONS.PROJECT_UPDATED; req.body.project_member_added_log = LOG_DESCRIPTIONS.PROJECT_MEMBER_ADDED; req.body.project_member_removed_log = LOG_DESCRIPTIONS.PROJECT_MEMBER_REMOVED; req.body.team_member_id = req.body.project_manager ? req.body.project_manager.id : null; const result = await db.query(q, [JSON.stringify(req.body)]); const [data] = result.rows; this.notifyProjecManagertUpdates(req.params.id, req.user as IPassportSession, req.body.project_manager ? req.body.project_manager.id : null); return res.status(200).send(new ServerResponse(true, data.project)); } @HandleExceptions() public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const q = `DELETE FROM projects WHERE id = $1 AND team_id = $2`; const result = await db.query(q, [req.params.id, req.user?.team_id || null]); return res.status(200).send(new ServerResponse(true, result.rows)); } @HandleExceptions() public static async getOverview(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const q = ` SELECT (SELECT COUNT(id) FROM tasks WHERE archived IS FALSE AND project_id = $1 AND status_id IN (SELECT id FROM task_statuses WHERE category_id = (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS done_task_count, (SELECT COUNT(id) FROM tasks WHERE archived IS FALSE AND project_id = $1 AND status_id IN (SELECT id FROM task_statuses WHERE category_id IN (SELECT id FROM sys_task_status_categories WHERE is_doing IS TRUE OR is_todo IS TRUE))) AS pending_task_count FROM projects WHERE id = $1 AND team_id = $2; `; const result = await db.query(q, [req.params.id, req.user?.team_id || null]); const [data] = result.rows; return res.status(200).send(new ServerResponse(true, data)); } @HandleExceptions() public static async getOverviewMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const {archived} = req.query; const q = ` SELECT team_member_id AS id, FALSE AS active, (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = $1 AND CASE WHEN ($2 IS TRUE) THEN project_id IS NOT NULL ELSE archived IS FALSE END) AS project_task_count, (SELECT COUNT(*) FROM tasks_assignees INNER JOIN tasks t ON tasks_assignees.task_id = t.id WHERE CASE WHEN ($2 IS TRUE) THEN t.project_id IS NOT NULL ELSE archived IS FALSE END AND project_member_id = project_members.id) AS task_count, (SELECT COUNT(*) FROM tasks_assignees INNER JOIN tasks t ON tasks_assignees.task_id = t.id INNER JOIN task_statuses ts ON t.status_id = ts.id WHERE CASE WHEN ($2 IS TRUE) THEN t.project_id IS NOT NULL ELSE archived IS FALSE END AND project_member_id = project_members.id AND ts.category_id IN (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE)) AS done_task_count, (SELECT COUNT(*) FROM tasks_assignees INNER JOIN tasks t ON tasks_assignees.task_id = t.id INNER JOIN task_statuses ts ON t.status_id = ts.id WHERE CASE WHEN ($2 IS TRUE) THEN t.project_id IS NOT NULL ELSE archived IS FALSE END AND project_member_id = project_members.id AND end_date::DATE < CURRENT_DATE::DATE AND t.status_id NOT IN (SELECT id FROM task_statuses WHERE category_id NOT IN (SELECT id FROM sys_task_status_categories WHERE is_done IS FALSE))) AS overdue_task_count, (SELECT COUNT(*) FROM tasks_assignees INNER JOIN tasks t ON tasks_assignees.task_id = t.id INNER JOIN task_statuses ts ON t.status_id = ts.id WHERE CASE WHEN ($2 IS TRUE) THEN t.project_id IS NOT NULL ELSE archived IS FALSE END AND project_member_id = project_members.id AND ts.category_id IN (SELECT id FROM sys_task_status_categories WHERE is_doing IS TRUE OR is_todo IS TRUE)) AS pending_task_count, (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id), u.avatar_url, (SELECT team_member_info_view.email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id), (SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title FROM project_members INNER JOIN team_members tm ON project_members.team_member_id = tm.id LEFT JOIN users u ON tm.user_id = u.id WHERE project_id = $1; `; const result = await db.query(q, [req.params.id, archived === "true"]); for (const item of result.rows) { item.progress = item.task_count > 0 ? ((item.done_task_count / item.task_count) * 100).toFixed(0) : 0; item.contribution = item.project_task_count > 0 ? ((item.task_count / item.project_task_count) * 100).toFixed(0) : 0; item.tasks = []; } return res.status(200).send(new ServerResponse(true, result.rows)); } @HandleExceptions() public static async getAllTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const {searchQuery, size, offset} = this.toPaginationOptions(req.query, ["tasks.name"]); const filterByMember = !req.user?.owner && !req.user?.is_admin ? ` AND is_member_of_project(p.id, '${req.user?.id}', $1) ` : ""; const isDueSoon = req.query.filter == "1"; const dueSoon = isDueSoon ? "AND tasks.end_date IS NOT NULL" : ""; const orderBy = isDueSoon ? "tasks.end_date DESC" : "p.name"; const assignedToMe = req.query.filter == "2" ? ` AND tasks.id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id = (SELECT id FROM team_members WHERE user_id = '${req.user?.id}' AND team_id = $1)) ` : ""; const q = ` SELECT ROW_TO_JSON(rec) AS projects FROM (SELECT COUNT(*) AS total, (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) FROM (SELECT tasks.id, tasks.name, p.team_id, p.name AS project_name, tasks.start_date, tasks.end_date, p.id AS project_id, p.color_code AS project_color, (SELECT name FROM task_statuses WHERE id = tasks.status_id) AS status, (SELECT color_code FROM sys_task_status_categories WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color, (SELECT get_task_assignees(tasks.id)) AS names FROM tasks INNER JOIN projects p ON tasks.project_id = p.id WHERE tasks.archived IS FALSE AND p.team_id = $1 ${filterByMember} ${dueSoon} ${searchQuery} ${assignedToMe} ORDER BY ${orderBy} LIMIT $2 OFFSET $3) t) AS data FROM tasks INNER JOIN projects p ON tasks.project_id = p.id WHERE tasks.archived IS FALSE AND p.team_id = $1 ${filterByMember} ${dueSoon} ${searchQuery} ${assignedToMe}) rec; `; const result = await db.query(q, [req.user?.team_id || null, size, offset]); const [data] = result.rows; for (const project of data?.projects.data || []) { project.progress = project.all_tasks_count > 0 ? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0; project.names = this.createTagList(project?.names); project.names.map((a: any) => a.color_code = getColor(a.name)); } return res.status(200).send(new ServerResponse(true, data.projects || this.paginatedDatasetDefaultStruct)); } @HandleExceptions() public static async getAllProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const q = `SELECT id AS value, name AS text FROM projects WHERE team_id = $1 ORDER BY name;`; const result = await db.query(q, [req.user?.team_id || null]); return res.status(200).send(new ServerResponse(true, result.rows || [])); } @HandleExceptions() public static async toggleFavorite(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const q = `SELECT toggle_favorite_project($1, $2);`; const result = await db.query(q, [req.user?.id, req.params.id]); return res.status(200).send(new ServerResponse(true, result.rows || [])); } @HandleExceptions() public static async toggleArchive(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const q = `SELECT toggle_archive_project($1, $2);`; const result = await db.query(q, [req.user?.id, req.params.id]); return res.status(200).send(new ServerResponse(true, result.rows || [])); } @HandleExceptions() public static async toggleArchiveAll(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const q = `SELECT toggle_archive_all_projects($1);`; const result = await db.query(q, [req.params.id]); const [data] = result.rows; return res.status(200).send(new ServerResponse(true, data.toggle_archive_all_projects || [])); } public static async getProjectManager(projectId: string) { const q = `SELECT team_member_id FROM project_members WHERE project_id = $1 AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER')`; const result = await db.query(q, [projectId]); return result.rows || []; } public static async updateExistPhaseColors() { const q = `SELECT id, name FROM project_phases`; const phases = await db.query(q); phases.rows.forEach((phase) => { phase.color_code = getColor(phase.name); }); const body = { phases: phases.rows }; const q2 = `SELECT update_existing_phase_colors($1)`; await db.query(q2, [JSON.stringify(body)]); } public static async updateExistSortOrder() { const q = `SELECT id, project_id FROM project_phases ORDER BY name`; const phases = await db.query(q); const sortNumbers: any = {}; phases.rows.forEach(phase => { const projectId = phase.project_id; if (!sortNumbers[projectId]) { sortNumbers[projectId] = 0; } phase.sort_number = sortNumbers[projectId]++; }); const body = { phases: phases.rows }; const q2 = `SELECT update_existing_phase_sort_order($1)`; await db.query(q2, [JSON.stringify(body)]); // return phases; } @HandleExceptions() public static async getGrouped(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { // Use qualified field name for projects to avoid ambiguity const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, ["projects.name"]); const groupBy = req.query.groupBy as string || "category"; const filterByMember = !req.user?.owner && !req.user?.is_admin ? ` AND is_member_of_project(projects.id, '${req.user?.id}', $1) ` : ""; const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : ""; const isArchived = req.query.filter === "2" ? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`; const categories = this.getFilterByCategoryWhereClosure(req.query.categories as string); const statuses = this.getFilterByStatusWhereClosure(req.query.statuses as string); // Determine grouping field and join based on groupBy parameter let groupField = ""; let groupName = ""; let groupColor = ""; let groupJoin = ""; let groupByFields = ""; let groupOrderBy = ""; switch (groupBy) { case "client": groupField = "COALESCE(projects.client_id::text, 'no-client')"; groupName = "COALESCE(clients.name, 'No Client')"; groupColor = "'#688'"; groupJoin = "LEFT JOIN clients ON projects.client_id = clients.id"; groupByFields = "projects.client_id, clients.name"; groupOrderBy = "COALESCE(clients.name, 'No Client')"; break; case "status": groupField = "COALESCE(projects.status_id::text, 'no-status')"; groupName = "COALESCE(sys_project_statuses.name, 'No Status')"; groupColor = "COALESCE(sys_project_statuses.color_code, '#888')"; groupJoin = "LEFT JOIN sys_project_statuses ON projects.status_id = sys_project_statuses.id"; groupByFields = "projects.status_id, sys_project_statuses.name, sys_project_statuses.color_code"; groupOrderBy = "COALESCE(sys_project_statuses.name, 'No Status')"; break; case "category": default: groupField = "COALESCE(projects.category_id::text, 'uncategorized')"; groupName = "COALESCE(project_categories.name, 'Uncategorized')"; groupColor = "COALESCE(project_categories.color_code, '#888')"; groupJoin = "LEFT JOIN project_categories ON projects.category_id = project_categories.id"; groupByFields = "projects.category_id, project_categories.name, project_categories.color_code"; groupOrderBy = "COALESCE(project_categories.name, 'Uncategorized')"; } // Ensure sortField is properly qualified for the inner project query let qualifiedSortField = sortField; if (Array.isArray(sortField)) { qualifiedSortField = sortField[0]; // Take the first field if it's an array } // Replace "projects." with "p2." for the inner query const innerSortField = qualifiedSortField.replace("projects.", "p2."); const q = ` SELECT ROW_TO_JSON(rec) AS groups FROM ( SELECT COUNT(DISTINCT ${groupField}) AS total_groups, (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(group_data))), '[]'::JSON) FROM ( SELECT ${groupField} AS group_key, ${groupName} AS group_name, ${groupColor} AS group_color, COUNT(*) AS project_count, (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(project_data))), '[]'::JSON) FROM ( SELECT p2.id, p2.name, (SELECT sys_project_statuses.name FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status, (SELECT sys_project_statuses.color_code FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_color, (SELECT sys_project_statuses.icon FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_icon, EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = p2.id) AS favorite, EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = p2.id) AS archived, p2.color_code, p2.start_date, p2.end_date, p2.category_id, (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = p2.id) AS all_tasks_count, (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = p2.id AND status_id IN (SELECT task_statuses.id FROM task_statuses WHERE task_statuses.project_id = p2.id AND task_statuses.category_id IN (SELECT sys_task_status_categories.id FROM sys_task_status_categories WHERE sys_task_status_categories.is_done IS TRUE))) AS completed_tasks_count, (SELECT COUNT(*) FROM project_members WHERE project_members.project_id = p2.id) AS members_count, (SELECT get_project_members(p2.id)) AS names, (SELECT clients.name FROM clients WHERE clients.id = p2.client_id) AS client_name, (SELECT users.name FROM users WHERE users.id = p2.owner_id) AS project_owner, (SELECT project_categories.name FROM project_categories WHERE project_categories.id = p2.category_id) AS category_name, (SELECT project_categories.color_code FROM project_categories WHERE project_categories.id = p2.category_id) AS category_color, ((SELECT project_members.team_member_id as team_member_id FROM project_members WHERE project_members.project_id = p2.id AND project_members.project_access_level_id = (SELECT project_access_levels.id FROM project_access_levels WHERE project_access_levels.key = 'PROJECT_MANAGER'))) AS project_manager_team_member_id, (SELECT project_members.default_view FROM project_members WHERE project_members.project_id = p2.id AND project_members.team_member_id = '${req.user?.team_member_id}') AS team_member_default_view, (SELECT CASE WHEN ((SELECT MAX(tasks.updated_at) FROM tasks WHERE tasks.archived IS FALSE AND tasks.project_id = p2.id) > p2.updated_at) THEN (SELECT MAX(tasks.updated_at) FROM tasks WHERE tasks.archived IS FALSE AND tasks.project_id = p2.id) ELSE p2.updated_at END) AS updated_at FROM projects p2 ${groupJoin.replace("projects.", "p2.")} WHERE p2.team_id = $1 AND ${groupField.replace("projects.", "p2.")} = ${groupField} ${categories.replace("projects.", "p2.")} ${statuses.replace("projects.", "p2.")} ${isArchived.replace("projects.", "p2.")} ${isFavorites.replace("projects.", "p2.")} ${filterByMember.replace("projects.", "p2.")} ${searchQuery.replace("projects.", "p2.")} ORDER BY ${innerSortField} ${sortOrder} ) project_data ) AS projects FROM projects ${groupJoin} WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery} GROUP BY ${groupByFields} ORDER BY ${groupOrderBy} LIMIT $2 OFFSET $3 ) group_data ) AS data FROM projects ${groupJoin} WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery} ) rec; `; const result = await db.query(q, [req.user?.team_id || null, size, offset]); const [data] = result.rows; // Process the grouped data for (const group of data?.groups.data || []) { for (const project of group.projects || []) { project.progress = project.all_tasks_count > 0 ? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0; project.updated_at_string = moment(project.updated_at).fromNow(); project.names = this.createTagList(project?.names); project.names.map((a: any) => a.color_code = getColor(a.name)); if (project.project_manager_team_member_id) { project.project_manager = { id: project.project_manager_team_member_id }; } } } return res.status(200).send(new ServerResponse(true, data?.groups || { total_groups: 0, data: [] })); } }