diff --git a/worklenz-backend/src/controllers/project-templates/pt-tasks-controller.ts b/worklenz-backend/src/controllers/project-templates/pt-tasks-controller.ts index e9ee68c26..6f8172769 100644 --- a/worklenz-backend/src/controllers/project-templates/pt-tasks-controller.ts +++ b/worklenz-backend/src/controllers/project-templates/pt-tasks-controller.ts @@ -39,7 +39,7 @@ export default class PtTasksController extends PtTasksControllerBase { const searchField = options.search ? "cptt.name" : "sort_order"; const { searchQuery, sortField } = PtTasksController.toPaginationOptions(options, searchField); - const sortFields = sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || "sort_order"; + const sortFields = (sortField as string).replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || "sort_order"; const isSubTasks = !!options.parent_task; @@ -230,13 +230,13 @@ export default class PtTasksController extends PtTasksControllerBase { @HandleExceptions() public static async bulkDelete(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const deletedTasks = req.body.tasks.map((t: any) => t.id); + const deletedTasks = req.body.tasks.map((t: any) => t.id); - const result: any = {deleted_tasks: deletedTasks}; + const result: any = { deleted_tasks: deletedTasks }; - const q = `SELECT bulk_delete_pt_tasks($1) AS task;`; - await db.query(q, [JSON.stringify(req.body)]); - return res.status(200).send(new ServerResponse(true, result)); + const q = `SELECT bulk_delete_pt_tasks($1) AS task;`; + await db.query(q, [JSON.stringify(req.body)]); + return res.status(200).send(new ServerResponse(true, result)); } } diff --git a/worklenz-backend/src/controllers/project-workload/workload-gannt-controller.ts b/worklenz-backend/src/controllers/project-workload/workload-gannt-controller.ts index 1e76a7c3a..02282fdb4 100644 --- a/worklenz-backend/src/controllers/project-workload/workload-gannt-controller.ts +++ b/worklenz-backend/src/controllers/project-workload/workload-gannt-controller.ts @@ -220,10 +220,10 @@ export default class WorkloadGanntController extends WLTasksControllerBase { if (logsRange.max_date) logsRange.max_date = momentTime.tz(logsRange.max_date, `${timeZone}`).format("YYYY-MM-DD"); - if (moment(logsRange.min_date ).isBefore(dateRange.start_date)) + if (moment(logsRange.min_date).isBefore(dateRange.start_date)) dateRange.start_date = logsRange.min_date; - if (moment(logsRange.max_date ).isAfter(dateRange.endDate)) + if (moment(logsRange.max_date).isAfter(dateRange.endDate)) dateRange.end_date = logsRange.max_date; return dateRange; @@ -331,7 +331,7 @@ export default class WorkloadGanntController extends WLTasksControllerBase { for (const member of result.rows) { member.color_code = getColor(member.name); - + // Set default working settings if organization data is not available member.org_working_hours = member.org_working_hours || 8; member.org_working_days = member.org_working_days || { @@ -492,7 +492,7 @@ export default class WorkloadGanntController extends WLTasksControllerBase { private static getFilterByDatesWhereClosure(dateChecker?: string): string { if (!dateChecker) return ""; - + switch (dateChecker) { case this.TASKS_START_DATE_NULL_FILTER: return "start_date IS NULL"; @@ -540,7 +540,7 @@ export default class WorkloadGanntController extends WLTasksControllerBase { const queryParams: any[] = []; let paramOffset = 1; - const sortFields = sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || "sort_order"; + const sortFields = (sortField as string).replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || "sort_order"; // Filter tasks by its members const membersResult = WorkloadGanntController.getFilterByMembersWhereClosure(options.members as string, paramOffset); if (membersResult.params.length > 0) { diff --git a/worklenz-backend/src/controllers/reporting/overview/reporting-overview-controller.ts b/worklenz-backend/src/controllers/reporting/overview/reporting-overview-controller.ts index c67361258..4424d0d01 100644 --- a/worklenz-backend/src/controllers/reporting/overview/reporting-overview-controller.ts +++ b/worklenz-backend/src/controllers/reporting/overview/reporting-overview-controller.ts @@ -90,7 +90,7 @@ export default class ReportingOverviewController extends ReportingOverviewBase { const teamFilterClause = `p.team_id = $1`; - const result = await ReportingControllerBase.getProjectsByTeam(teamId, size, offset, searchQuery, sortField, sortOrder, "", "", "", archivedClause, teamFilterClause, ""); + const result = await ReportingControllerBase.getProjectsByTeam(teamId, size, offset, searchQuery, sortField as string, sortOrder, "", "", "", archivedClause, teamFilterClause, ""); for (const project of result.projects) { diff --git a/worklenz-backend/src/controllers/reporting/overview/reporting-overview-export-controller.ts b/worklenz-backend/src/controllers/reporting/overview/reporting-overview-export-controller.ts index fbc56961c..57a99c2f4 100644 --- a/worklenz-backend/src/controllers/reporting/overview/reporting-overview-export-controller.ts +++ b/worklenz-backend/src/controllers/reporting/overview/reporting-overview-export-controller.ts @@ -24,7 +24,7 @@ export default class ReportingOverviewExportController extends ReportingOverview const teamFilterClause = `p.team_id = $1`; - const result = await ReportingControllerBase.getProjectsByTeam(teamId, size, offset, searchQuery, sortField, sortOrder, "", "", "", archivedClause, teamFilterClause, ""); + const result = await ReportingControllerBase.getProjectsByTeam(teamId, size, offset, searchQuery, sortField as string, sortOrder, "", "", "", archivedClause, teamFilterClause, ""); for (const project of result.projects) { project.team_color = getColor(project.team_name) + TASK_PRIORITY_COLOR_ALPHA; @@ -416,7 +416,7 @@ export default class ReportingOverviewExportController extends ReportingOverview const teamMemberName = (req.query.team_member_name as string)?.trim() || null; const teamName = (req.query.team_name as string)?.trim() || ""; - const { duration, date_range, only_single_member, archived} = req.query; + const { duration, date_range, only_single_member, archived } = req.query; const includeArchived = req.query.archived === "true"; @@ -506,7 +506,7 @@ export default class ReportingOverviewExportController extends ReportingOverview const includeArchived = req.query.archived === "true"; - const results = await ReportingExportModel.getMemberTasks(teamMemberId as string, projectId, "false", "", [], includeArchived, req.user?.id as string); + const results = await ReportingExportModel.getMemberTasks(teamMemberId as string, projectId, "false", "", [], includeArchived, req.user?.id as string); // excel file const exportDate = moment().format("MMM-DD-YYYY"); diff --git a/worklenz-backend/src/controllers/reporting/projects/reporting-projects-controller.ts b/worklenz-backend/src/controllers/reporting/projects/reporting-projects-controller.ts index d6381f792..7e3e9878b 100644 --- a/worklenz-backend/src/controllers/reporting/projects/reporting-projects-controller.ts +++ b/worklenz-backend/src/controllers/reporting/projects/reporting-projects-controller.ts @@ -80,7 +80,7 @@ export default class ReportingProjectsController extends ReportingProjectsBase { const projectFilterClause = await this.buildProjectFilterForTeamLead(req); const teamFilterClause = `in_organization(p.team_id, $1) ${projectFilterClause} ${teamsClause}`; - const result = await ReportingControllerBase.getProjectsByTeam(teamId as string, size, offset, searchQuery, sortField, sortOrder, statusesClause, healthsClause, categoriesClause, archivedClause, teamFilterClause, projectManagersClause, filterParams); + const result = await ReportingControllerBase.getProjectsByTeam(teamId as string, size, offset, searchQuery, sortField as string, sortOrder, statusesClause, healthsClause, categoriesClause, archivedClause, teamFilterClause, projectManagersClause, filterParams); for (const project of result.projects) { project.team_color = getColor(project.team_name) + TASK_PRIORITY_COLOR_ALPHA; diff --git a/worklenz-backend/src/controllers/reporting/reporting-controller-base.ts b/worklenz-backend/src/controllers/reporting/reporting-controller-base.ts index be6832ebb..295655646 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-controller-base.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-controller-base.ts @@ -111,7 +111,7 @@ export default abstract class ReportingControllerBase extends WorklenzController protected static buildBillableQuery(selectedStatuses: { billable: boolean; nonBillable: boolean }): string { const { billable, nonBillable } = selectedStatuses; - + if (billable && nonBillable) { // Both are enabled, no need to filter return ""; @@ -121,7 +121,7 @@ export default abstract class ReportingControllerBase extends WorklenzController } else if (nonBillable) { // Only non-billable is enabled return " AND tasks.billable IS FALSE"; - } + } return ""; } @@ -166,6 +166,67 @@ export default abstract class ReportingControllerBase extends WorklenzController } + /** + * Build project filter clause for Team Leads + * Team Leads can only see projects they are assigned to as project managers + */ + public static async buildProjectFilterForTeamLead(req: IWorkLenzRequest): Promise { + // Check if user is a Team Lead (not Admin or Owner) + const userId = req.user?.id; + const teamId = req.user?.team_id; + + if (!userId || !teamId) return ""; + + // Check user's role + const roleQuery = ` + SELECT r.key + FROM roles r + JOIN team_members tm ON tm.role_id = r.id + WHERE tm.user_id = $1 AND tm.team_id = $2 + `; + const roleResult = await db.query(roleQuery, [userId, teamId]); + + if (roleResult.rows.length === 0) return ""; + + const roleKey = roleResult.rows[0].key; + + // Only apply filter for Team Leads + if (roleKey === 'TEAM_LEAD') { + // Team Leads can only see projects they manage + return `AND p.id IN ( + SELECT pm.project_id + FROM project_members pm + WHERE pm.team_member_id IN ( + SELECT id FROM team_members WHERE user_id = '${userId}' + ) + AND pm.project_access_level_id = ( + SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER' + ) + )`; + } + + // Admins and Owners can see all projects + return ""; + } + + /** + * Get project IDs that a Team Lead is assigned to + */ + public static async getTeamLeadProjects(userId: string, teamId: string): Promise { + const q = ` + SELECT DISTINCT pm.project_id + FROM project_members pm + JOIN team_members tm ON pm.team_member_id = tm.id + WHERE tm.user_id = $1 + AND tm.team_id = $2 + AND pm.project_access_level_id = ( + SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER' + ) + `; + const result = await db.query(q, [userId, teamId]); + return result.rows.map(row => row.project_id); + } + public static async getProjectsByTeam( teamId: string, size: string | number | null, @@ -178,7 +239,8 @@ export default abstract class ReportingControllerBase extends WorklenzController categoryClause: string, archivedClause = "", teamFilterClause: string, - projectManagersClause: string) { + projectManagersClause: string, + filterParams: any[] = []) { const q = `SELECT COUNT(*) AS total, (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) @@ -315,7 +377,9 @@ export default abstract class ReportingControllerBase extends WorklenzController LEFT JOIN project_categories pc ON pc.id = p.category_id LEFT JOIN sys_project_statuses ps ON p.status_id = ps.id WHERE ${teamFilterClause} ${searchQuery} ${healthClause} ${statusClause} ${categoryClause} ${projectManagersClause} ${archivedClause};`; - const result = await db.query(q, [teamId, size, offset]); + // Combine all parameters: teamId, size, offset, then filter params + const queryParams = [teamId, size, offset, ...filterParams]; + const result = await db.query(q, queryParams); const [data] = result.rows; for (const project of data.projects) { diff --git a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts index f10cd1c7c..93cd7a817 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts @@ -107,22 +107,22 @@ export default class ReportingMembersController extends ReportingControllerBaseW const assignClause = assignClauseResult.clause; const assignParams = assignClauseResult.params; paramOffset += assignParams.length; - + const completedDurationResult = this.completedDurationFilter(key, dateRange, paramOffset); const completedDurationClasue = completedDurationResult.clause; const completedParams = completedDurationResult.params; paramOffset += completedParams.length; - + const overdueActivityLogsResult = this.getActivityLogsOverdue(key, dateRange, paramOffset); const overdueActivityLogsClause = overdueActivityLogsResult.clause; const overdueParams = overdueActivityLogsResult.params; paramOffset += overdueParams.length; - + const activityLogCreationResult = this.getActivityLogsCreationClause(key, dateRange, paramOffset); const activityLogCreationFilter = activityLogCreationResult.clause; const activityLogParams = activityLogCreationResult.params; paramOffset += activityLogParams.length; - + const timeLogDateRangeResult = this.getTimeLogDateRangeClause(key, dateRange, paramOffset); const timeLogDateRangeClause = timeLogDateRangeResult.clause; const timeLogParams = timeLogDateRangeResult.params; @@ -703,14 +703,12 @@ export default class ReportingMembersController extends ReportingControllerBaseW // Get user timezone for proper date filtering const userTimezone = await this.getUserTimezone(req.user?.id as string); // $1 => team_id, $2 => team_member_id, so date params start at $3 - const durationClauseResult = this.getDateRangeClauseWithTimezoneParams( + const durationClause = this.getDateRangeClauseWithTimezone( (duration as string) || DATE_RANGES.LAST_WEEK, dateRange, - userTimezone, - 3 + userTimezone ); - const durationClause = durationClauseResult.clause; - const durationParams = durationClauseResult.params; + const durationParams: any[] = []; const minMaxDateClauseResult = this.getMinMaxDates( (duration as string) || DATE_RANGES.LAST_WEEK, @@ -833,7 +831,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW ); const durationClause = durationClauseResult.clause; const durationParams = durationClauseResult.params; - + const minMaxDateClauseResult = this.getMinMaxDates( duration as string || DATE_RANGES.LAST_WEEK, dateRange, @@ -842,7 +840,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW ); const minMaxDateClause = minMaxDateClauseResult.clause; const minMaxParams = minMaxDateClauseResult.params; - + const memberName = (req.query.member_name as string)?.trim() || null; // Combine all parameters for the query @@ -1078,7 +1076,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW ); const durationClause = durationClauseResult.clause; const durationParams = durationClauseResult.params; - + const minMaxDateClauseResult = this.getMinMaxDates( duration || DATE_RANGES.LAST_WEEK, date_range, @@ -1179,8 +1177,8 @@ export default class ReportingMembersController extends ReportingControllerBaseW // Parameters: $1 = team_id, $2 = team_member_id, $3+ = params, then userId const userIdParamIndex = 3 + params.length; const archivedClause = includeArchived - ? "" - : `AND project_id NOT IN (SELECT project_id FROM archived_projects WHERE archived_projects.user_id = $${userIdParamIndex})`; + ? "" + : `AND project_id NOT IN (SELECT project_id FROM archived_projects WHERE archived_projects.user_id = $${userIdParamIndex})`; const q = ` SELECT user_id, @@ -1224,7 +1222,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW return logGroups; } - private static async memberActivityLogsData(durationClause: string, minMaxDateClause: string, team_id: string, team_member_id: string, includeArchived:boolean, userId: string, params: any[]) { + private static async memberActivityLogsData(durationClause: string, minMaxDateClause: string, team_id: string, team_member_id: string, includeArchived: boolean, userId: string, params: any[]) { let archivedClause = ""; let archivedParams: any[] = []; @@ -1341,7 +1339,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW protected static buildBillableQuery(selectedStatuses: { billable: boolean; nonBillable: boolean }, tableAlias = "tasks"): string { const { billable, nonBillable } = selectedStatuses; - + if (billable && nonBillable) { // Both are enabled, no need to filter return ""; @@ -1351,7 +1349,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW } else if (nonBillable) { // Only non-billable is enabled return ` AND ${tableAlias}.billable IS FALSE`; - } + } return ""; } @@ -1363,14 +1361,12 @@ export default class ReportingMembersController extends ReportingControllerBaseW // Get user timezone for proper date filtering const userTimezone = await this.getUserTimezone(req.user?.id as string); // $1 => team_id, $2 => team_member_id, so date params start at $3 - const durationClauseResult = this.getDateRangeClauseWithTimezoneParams( + const durationClause = this.getDateRangeClauseWithTimezone( duration || DATE_RANGES.LAST_WEEK, date_range, - userTimezone, - 3 + userTimezone ); - const durationClause = durationClauseResult.clause; - const durationParams = durationClauseResult.params; + const durationParams: any[] = []; const minMaxDateClauseResult = this.getMinMaxDates( duration || DATE_RANGES.LAST_WEEK, @@ -1410,8 +1406,8 @@ export default class ReportingMembersController extends ReportingControllerBaseW } const archivedClause = includeArchived - ? "" - : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${req.user?.id}')`; + ? "" + : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${req.user?.id}')`; // Use parameterized queries @@ -1420,19 +1416,19 @@ export default class ReportingMembersController extends ReportingControllerBaseW const assignClause = assignClauseResult.clause; const assignParams = assignClauseResult.params; let paramOffset = 2 + assignParams.length; - + const completedDurationResult = this.completedDurationFilter(duration as string, dateRange, paramOffset); const completedDurationClasue = completedDurationResult.clause; const completedParams = completedDurationResult.params; paramOffset += completedParams.length; - + const overdueClauseResult = this.getActivityLogsOverdue(duration as string, dateRange, paramOffset); const overdueClauseByDate = overdueClauseResult.clause; const overdueParams = overdueClauseResult.params; paramOffset += overdueParams.length; - + const taskSelectorClause = this.getTaskSelectorClause(); - + const durationFilterResult = this.memberTasksDurationFilter(duration as string, dateRange, paramOffset); const durationFilter = durationFilterResult.clause; const durationParams = durationFilterResult.params; @@ -1531,11 +1527,11 @@ export default class ReportingMembersController extends ReportingControllerBaseW // Get user timezone and date clauses const userTimezone = await this.getUserTimezone(req.user?.id as string); - + // Build params array with timezone first, then date range values const params: any[] = [userTimezone]; let paramIndex = 2; - + // Add date range parameters and build duration clause let durationClause = ''; if (date_range && date_range.length === 2) { @@ -1710,11 +1706,11 @@ export default class ReportingMembersController extends ReportingControllerBaseW // Get user timezone const userTimezone = await this.getUserTimezone(req.user?.id as string); - + // Build params array with timezone first, then date range values const params: any[] = [userTimezone]; let paramIndex = 2; - + // Add date range parameters and build duration clause let durationClause = ''; if (dateRange && dateRange.length === 2) { @@ -1858,24 +1854,24 @@ export default class ReportingMembersController extends ReportingControllerBaseW private static updateTaskProperties(tasks: any[]) { for (const task of tasks) { - task.project_color = getColor(task.project_name); - task.estimated_string = formatDuration(moment.duration(~~(task.total_minutes), "seconds")); - task.time_spent_string = formatDuration(moment.duration(~~(task.time_logged), "seconds")); - task.overlogged_time_string = formatDuration(moment.duration(~~(task.overlogged_time), "seconds")); - task.overdue_days = task.days_overdue ? task.days_overdue : null; + task.project_color = getColor(task.project_name); + task.estimated_string = formatDuration(moment.duration(~~(task.total_minutes), "seconds")); + task.time_spent_string = formatDuration(moment.duration(~~(task.time_logged), "seconds")); + task.overlogged_time_string = formatDuration(moment.duration(~~(task.overlogged_time), "seconds")); + task.overdue_days = task.days_overdue ? task.days_overdue : null; } -} + } -@HandleExceptions() -public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLenzResponse) { - const { team_member_id } = req.query; - const includeArchived = req.query.archived === "true"; + @HandleExceptions() + public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLenzResponse) { + const { team_member_id } = req.query; + const includeArchived = req.query.archived === "true"; - const archivedClause = includeArchived - ? "" - : `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND archived_projects.user_id = '${req.user?.id}')`; + const archivedClause = includeArchived + ? "" + : `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND archived_projects.user_id = '${req.user?.id}')`; - const q = `SELECT id, + const q = `SELECT id, name, color_code, start_date, @@ -1926,36 +1922,36 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen FROM projects WHERE projects.id IN (SELECT project_id FROM project_members WHERE team_member_id = $1) ${archivedClause};`; - const result = await db.query(q, [team_member_id]); - const data = result.rows; - - for (const row of data) { - row.estimated_time = int(row.estimated_time); - row.actual_time = int(row.actual_time); - row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time)); - row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time)); - row.days_left = this.getDaysLeft(row.end_date); - row.is_overdue = this.isOverdue(row.end_date); - if (row.days_left && row.is_overdue) { - row.days_left = row.days_left.toString().replace(/-/g, ""); - } - row.is_today = this.isToday(row.end_date); - if (row.project_manager) { - row.project_manager.name = row.project_manager.project_manager_info.name; - row.project_manager.avatar_url = row.project_manager.project_manager_info.avatar_url; - row.project_manager.color_code = getColor(row.project_manager.name); + const result = await db.query(q, [team_member_id]); + const data = result.rows; + + for (const row of data) { + row.estimated_time = int(row.estimated_time); + row.actual_time = int(row.actual_time); + row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time)); + row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time)); + row.days_left = this.getDaysLeft(row.end_date); + row.is_overdue = this.isOverdue(row.end_date); + if (row.days_left && row.is_overdue) { + row.days_left = row.days_left.toString().replace(/-/g, ""); + } + row.is_today = this.isToday(row.end_date); + if (row.project_manager) { + row.project_manager.name = row.project_manager.project_manager_info.name; + row.project_manager.avatar_url = row.project_manager.project_manager_info.avatar_url; + row.project_manager.color_code = getColor(row.project_manager.name); + } + row.project_health = row.health_name ? row.health_name : null; } - row.project_health = row.health_name ? row.health_name : null; - } - const body = { - team_member_name: data[0].team_member_name, - projects: data - }; + const body = { + team_member_name: data[0].team_member_name, + projects: data + }; - return res.status(200).send(new ServerResponse(true, body)); + return res.status(200).send(new ServerResponse(true, body)); -} + } @HandleExceptions() public static async exportMemberProjects(req: IWorkLenzRequest, res: IWorkLenzResponse) { diff --git a/worklenz-backend/src/controllers/schedule/schedule-controller.ts b/worklenz-backend/src/controllers/schedule/schedule-controller.ts index 1496502c1..7f585194b 100644 --- a/worklenz-backend/src/controllers/schedule/schedule-controller.ts +++ b/worklenz-backend/src/controllers/schedule/schedule-controller.ts @@ -752,16 +752,16 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; @HandleExceptions() public static async deleteMemberAllocations(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { // Use parameterized queries for DELETE statement - const ids = Array.isArray(req.body.ids) - ? req.body.ids - : typeof req.body.ids === 'string' + const ids = Array.isArray(req.body.ids) + ? req.body.ids + : typeof req.body.ids === 'string' ? req.body.ids.split(",").filter((id: string) => id.trim()) : []; - + if (ids.length === 0) { return res.status(400).send(new ServerResponse(false, null, "No IDs provided")); } - + const { clause, params } = SqlHelper.buildInClause(ids, 1); const q = `DELETE FROM project_member_allocations WHERE id IN (${clause})`; await db.query(q, params); @@ -831,7 +831,7 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; paramOffset++; } - const sortFields = sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || "sort_order"; + const sortFields = (sortField as string).replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || "sort_order"; const membersResult = ScheduleControllerV2.getFilterByMembersWhereClosure(options.members as string, paramOffset); if (membersResult.params.length > 0) { queryParams.push(...membersResult.params); @@ -873,7 +873,7 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; // Build member-specific time spent query let timeSpentQuery = "(SELECT ROUND(SUM(time_spent) / 60.0, 2) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent"; - + // If specific members are selected, filter time logs by those members if (options.members && typeof options.members === 'string') { const memberIds = options.members.split(" ").filter(id => id.trim()); @@ -885,7 +885,7 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; INNER JOIN team_members tm ON twl.user_id = tm.user_id WHERE twl.task_id = t.id AND tm.id IN (${memberPlaceholders})) AS total_minutes_spent`; - + // Add member IDs to query params queryParams.push(...memberIds); paramOffset += memberIds.length; @@ -1010,7 +1010,7 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; const groupBy = (req.query.group || GroupBy.STATUS) as string; const { query: q, params } = ScheduleControllerV2.getQuery(req.user?.id as string, req.params.id, req.query); - + const result = await db.query(q, params); const tasks = [...result.rows]; @@ -1030,9 +1030,9 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; // Initialize groups from database data groups.forEach((group) => { if (!group.id) return; - + const groupKey = group.id; - + groupedResponse[groupKey] = { id: group.id, name: group.name, diff --git a/worklenz-backend/src/controllers/team-members-controller.ts b/worklenz-backend/src/controllers/team-members-controller.ts index a338cc73a..4f67bc05e 100644 --- a/worklenz-backend/src/controllers/team-members-controller.ts +++ b/worklenz-backend/src/controllers/team-members-controller.ts @@ -146,7 +146,7 @@ export default class TeamMembersController extends WorklenzControllerBase { */ if (subscriptionData.subscription_status === "trialing") { const currentTrialMembers = parseInt(subscriptionData.current_count) || 0; - + if (currentTrialMembers + incrementBy > TRIAL_MEMBER_LIMIT) { return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`)); } @@ -200,22 +200,22 @@ export default class TeamMembersController extends WorklenzControllerBase { // Helper function to check for encoded components function containsEncodedComponents(x: string) { - return decodeURI(x) !== decodeURIComponent(x); + return decodeURI(x) !== decodeURIComponent(x); } // Decode search parameter if it contains encoded components if (req.query.search && typeof req.query.search === 'string') { - if (containsEncodedComponents(req.query.search)) { - req.query.search = decodeURIComponent(req.query.search); - } + if (containsEncodedComponents(req.query.search)) { + req.query.search = decodeURIComponent(req.query.search); + } } const { - searchQuery, - sortField, - sortOrder, - size, - offset + searchQuery, + sortField, + sortOrder, + size, + offset } = this.toPaginationOptions(req.query, ["u.name", "u.email"], true); const paginate = req.query.all === "false" ? `LIMIT ${size} OFFSET ${offset}` : ""; @@ -839,7 +839,7 @@ export default class TeamMembersController extends WorklenzControllerBase { } = this.toPaginationOptions(req.query, ["tmiv.name", "tmiv.email", "u.name"]); const { start, end, project, status, teamId } = req.query; - const teamMembers = await this.getTeamMemberInsightData(teamId as string, start, end, project, status, searchQuery, sortField, sortOrder, size, offset, req.query.all); + const teamMembers = await this.getTeamMemberInsightData(teamId as string, start, end, project, status, searchQuery, sortField as string, sortOrder, size, offset, req.query.all as string); teamMembers.data.map((a: any) => { a.color_code = getColor(a.name); @@ -908,7 +908,7 @@ export default class TeamMembersController extends WorklenzControllerBase { } = this.toPaginationOptions(req.query, ["tmiv.name", "tmiv.email", "u.name"]); const { start, end, project, status } = req.query; - const teamMembers = await this.getTeamMemberInsightData(req.user?.team_id, start || null, end, project, status, searchQuery, sortField, sortOrder, size, offset, req.query.all); + const teamMembers = await this.getTeamMemberInsightData(req.user?.team_id, start || null, end, project, status, searchQuery, sortField as string, sortOrder, size, offset, req.query.all as string); const exportDate = moment().format("MMM-DD-YYYY"); const fileName = `Worklenz - Team Members Export - ${exportDate}`; @@ -1098,7 +1098,7 @@ export default class TeamMembersController extends WorklenzControllerBase { if (subscriptionData.subscription_status === "trialing") { const currentTrialMembers = parseInt(subscriptionData.current_count) || 0; const emailsToAdd = req.body.emails?.length || 1; - + if (currentTrialMembers + emailsToAdd > TRIAL_MEMBER_LIMIT) { return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`)); } diff --git a/worklenz-backend/src/controllers/worklenz-controller-base.ts b/worklenz-backend/src/controllers/worklenz-controller-base.ts index 0c3ef0cc2..29d7f7867 100644 --- a/worklenz-backend/src/controllers/worklenz-controller-base.ts +++ b/worklenz-backend/src/controllers/worklenz-controller-base.ts @@ -1,11 +1,11 @@ import { forEach } from "lodash"; -import {DEFAULT_PAGE_SIZE} from "../shared/constants"; -import {toTsQuery} from "../shared/utils"; +import { DEFAULT_PAGE_SIZE } from "../shared/constants"; +import { toTsQuery } from "../shared/utils"; export default abstract class WorklenzControllerBase { protected static get paginatedDatasetDefaultStruct() { - return {total: 0, data: []}; + return { total: 0, data: [] }; } protected static isValidHost(hostname: string) { @@ -21,13 +21,13 @@ export default abstract class WorklenzControllerBase { const remaining = list.slice(max); const names = remaining.map(i => i.name); data = data.slice(0, max); - data.push({name: `+${remaining.length}`, end: true, names: names as string[]}); + data.push({ name: `+${remaining.length}`, end: true, names: names as string[] }); } return data; } - protected static toPaginationOptions(queryParams: any, searchField: string | string[], isMemberFilter = false) { + protected static toPaginationOptions(queryParams: any, searchField: string | string[], isMemberFilter = false, paramOffset?: number) { // Pagination const size = +(queryParams.size || DEFAULT_PAGE_SIZE); const index = +(queryParams.index || 1); @@ -37,11 +37,30 @@ export default abstract class WorklenzControllerBase { const search = (queryParams.search as string || "").trim(); let searchQuery = ""; + const searchParams: any[] = []; - if (search) { - // Properly escape single quotes to prevent SQL syntax errors + if (search && paramOffset !== undefined) { + // Use parameterized queries when paramOffset is provided const escapedSearch = search.replace(/'/g, "''"); - + + let s = ""; + if (typeof searchField === "string") { + s = ` ${searchField} ILIKE $${paramOffset}`; + searchParams.push(`%${escapedSearch}%`); + } else if (Array.isArray(searchField)) { + s = searchField.map((field, idx) => { + searchParams.push(`%${escapedSearch}%`); + return ` ${field} ILIKE $${paramOffset + idx}`; + }).join(" OR "); + } + + if (s) { + searchQuery = isMemberFilter ? ` (${s}) AND ` : ` AND (${s}) `; + } + } else if (search) { + // Fallback to inline search for backward compatibility + const escapedSearch = search.replace(/'/g, "''"); + let s = ""; if (typeof searchField === "string") { s = ` ${searchField} ILIKE '%${escapedSearch}%'`; @@ -65,7 +84,7 @@ export default abstract class WorklenzControllerBase { const sortOrder = queryParams.order === "descend" ? "desc" : "asc"; - return {searchQuery, sortField, sortOrder, size, offset, paging}; + return { searchQuery, searchParams, sortField, sortOrder, size, offset, paging }; } } diff --git a/worklenz-backend/src/services/activity-logging.service.ts b/worklenz-backend/src/services/activity-logging.service.ts new file mode 100644 index 000000000..abb99711b --- /dev/null +++ b/worklenz-backend/src/services/activity-logging.service.ts @@ -0,0 +1,106 @@ +import db from "../config/db"; +import { log_error } from "../shared/utils"; + +interface IProjectActivityParams { + teamId: string; + projectId: string; + userId: string; + i18nKey: string; + projectName?: string; +} + +export class ActivityLoggingService { + + /** + * Log that a project was created + */ + public static async logProjectCreated( + teamId: string, + projectId: string, + userId: string, + projectName: string + ): Promise { + try { + const q = `INSERT INTO project_logs (team_id, project_id, user_id, description, log_type) + VALUES ($1, $2, $3, $4, 'project_created') + ON CONFLICT DO NOTHING`; + await db.query(q, [teamId, projectId, userId, `Project "${projectName}" created`]); + } catch (e) { + log_error(e); + } + } + + /** + * Log that a project was updated + */ + public static async logProjectUpdated( + teamId: string, + projectId: string, + userId: string, + projectName: string + ): Promise { + try { + const q = `INSERT INTO project_logs (team_id, project_id, user_id, description, log_type) + VALUES ($1, $2, $3, $4, 'project_updated') + ON CONFLICT DO NOTHING`; + await db.query(q, [teamId, projectId, userId, `Project "${projectName}" updated`]); + } catch (e) { + log_error(e); + } + } + + /** + * Log that a project was deleted + */ + public static async logProjectDeleted( + teamId: string, + projectId: string, + userId: string, + projectName: string + ): Promise { + try { + const q = `INSERT INTO project_logs (team_id, project_id, user_id, description, log_type) + VALUES ($1, $2, $3, $4, 'project_deleted') + ON CONFLICT DO NOTHING`; + await db.query(q, [teamId, projectId, userId, `Project "${projectName}" deleted`]); + } catch (e) { + log_error(e); + } + } + + /** + * Log that a project was archived + */ + public static async logProjectArchived( + teamId: string, + projectId: string, + userId: string, + projectName: string + ): Promise { + try { + const q = `INSERT INTO project_logs (team_id, project_id, user_id, description, log_type) + VALUES ($1, $2, $3, $4, 'project_archived') + ON CONFLICT DO NOTHING`; + await db.query(q, [teamId, projectId, userId, `Project "${projectName}" archived`]); + } catch (e) { + log_error(e); + } + } + + /** + * Log a generic project activity using an i18n key + */ + public static async logProjectActivity(params: IProjectActivityParams): Promise { + try { + const q = `INSERT INTO project_logs (team_id, project_id, user_id, description, log_type) + VALUES ($1, $2, $3, $4, 'project_activity') + ON CONFLICT DO NOTHING`; + const description = params.projectName + ? `${params.i18nKey}: ${params.projectName}` + : params.i18nKey; + await db.query(q, [params.teamId, params.projectId, params.userId, description]); + } catch (e) { + log_error(e); + } + } +} diff --git a/worklenz-backend/src/shared/constants.ts b/worklenz-backend/src/shared/constants.ts index 1b99c907f..af2073b02 100644 --- a/worklenz-backend/src/shared/constants.ts +++ b/worklenz-backend/src/shared/constants.ts @@ -18,6 +18,13 @@ export const LOG_DESCRIPTIONS = { PROJECT_MEMBER_REMOVED: "was removed from the project by", }; +export const LOG_I18N_KEYS = { + PROJECT_MANAGER_ASSIGNED: "project_manager_assigned", + PROJECT_FAVORITED: "project_favorited", + PROJECT_UNFAVORITED: "project_unfavorited", + PROJECT_UNARCHIVED: "project_unarchived", +}; + export const WorklenzColorShades = { "#154c9b": ["#0D2A50", "#112E54", "#153258", "#19365C", "#1D3A60", "#213E64", "#254268", "#29466C", "#2D4A70", "#314E74"], "#3b7ad4": ["#224884", "#26528A", "#2A5C90", "#2E6696", "#32709C", "#367AA2", "#3A84A8", "#3E8EAE", "#4298B4", "#46A2BA"],