Files
worklenz/worklenz-backend/src/controllers/projects-controller.ts
chamikaJ 3cae2771de feat(projects): implement grouped project retrieval and UI enhancements
- Added a new endpoint for retrieving projects grouped by category, client, or status.
- Enhanced the ProjectsController with a method to handle grouped project queries.
- Updated the projects API router to include the new grouped endpoint.
- Improved the frontend to support displaying grouped projects with pagination and filtering options.
- Updated localization files for English, Spanish, and Portuguese to include new grouping options.
- Refactored project list components to accommodate the new grouped view and improved UI elements.
2025-06-13 15:46:03 +05:30

942 lines
48 KiB
TypeScript

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 <b> Project Manager </b> of the <b> ${data.project_name} </b>.`,
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
const q = `
SELECT ROW_TO_JSON(rec) AS members
FROM (SELECT COUNT(*) AS total,
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
FROM (SELECT project_members.id,
team_member_id,
(SELECT name
FROM team_member_info_view
WHERE team_member_info_view.team_member_id = tm.id),
(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
ORDER BY ${sortField} ${sortOrder}
LIMIT $3 OFFSET $4) t) AS data
FROM project_members
WHERE project_id = $1) rec;
`;
const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, size, offset]);
const [data] = result.rows;
for (const member of data?.members.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?.members || this.paginatedDatasetDefaultStruct));
}
@HandleExceptions()
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
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<IWorkLenzResponse> {
// 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: [] }));
}
}