Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -230,13 +230,13 @@ export default class PtTasksController extends PtTasksControllerBase {

@HandleExceptions()
public static async bulkDelete(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
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));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +226 to 227
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Read the specific lines around 226 to confirm the issue
sed -n '220,230p' worklenz-backend/src/controllers/project-workload/workload-gannt-controller.ts | cat -n

Repository: Worklenz/worklenz

Length of output: 518


🏁 Script executed:

# Search for all dateRange property accesses to confirm naming convention
rg 'dateRange\.' worklenz-backend/src/controllers/project-workload/workload-gannt-controller.ts -A 0 -B 0

Repository: Worklenz/worklenz

Length of output: 1356


🏁 Script executed:

# Check if dateRange object has endDate property defined anywhere
rg 'endDate' worklenz-backend/src/controllers/project-workload/workload-gannt-controller.ts

Repository: Worklenz/worklenz

Length of output: 2047


🌐 Web query:

moment.js isAfter(undefined) behavior current version

💡 Result:

In the current Moment.js latest release (2.30.1), moment#isAfter(undefined) behaves the same as calling isAfter() with no argument: the undefined gets parsed into a Moment representing “now”, and the comparison is done against that current time. [1][2]

Concretely:

  • someMoment.isAfter(undefined) ⇒ compares someMoment to now (at the time isAfter runs). [1][2]
  • moment().isAfter(undefined)typically false, because it compares “now” (created slightly earlier) to a new “now” created inside isAfter. [2]

This happens because isAfter internally turns non-Moment inputs into a Moment via createLocal(input) (and createLocal(undefined) becomes “now”). [2]

Sources:
[1] Moment.js site showing current version 2.30.1
[2] Moment.js docs + isAfter implementation (parsing input via createLocal(input); moment(undefined) treated as moment())


dateRange.endDate is a non-existent property — should be dateRange.end_date.

Line 226 uses dateRange.endDate (camelCase), which is undefined at runtime. Every other access to this object uses the snake_case property end_date (lines 214–215, 227, and throughout the file). As a result, moment(logsRange.max_date).isAfter(undefined) never evaluates as intended: in moment.js, isAfter(undefined) treats undefined as the current time, so the condition becomes "is logsRange.max_date after now?" — which is false for typical past log dates. The log-range max date is silently never applied to dateRange.end_date.

🐛 Proposed fix
-    if (moment(logsRange.max_date).isAfter(dateRange.endDate))
+    if (moment(logsRange.max_date).isAfter(dateRange.end_date))
       dateRange.end_date = logsRange.max_date;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (moment(logsRange.max_date).isAfter(dateRange.endDate))
dateRange.end_date = logsRange.max_date;
if (moment(logsRange.max_date).isAfter(dateRange.end_date))
dateRange.end_date = logsRange.max_date;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@worklenz-backend/src/controllers/project-workload/workload-gannt-controller.ts`
around lines 226 - 227, The condition uses the wrong property name: replace the
camelCase dateRange.endDate with the snake_case dateRange.end_date so the moment
check compares logsRange.max_date against the actual end date; update the
expression inside the if that calls moment(logsRange.max_date).isAfter(...) to
use dateRange.end_date and ensure the subsequent assignment sets
dateRange.end_date = logsRange.max_date (referencing the dateRange and logsRange
variables and the end_date field).


return dateRange;
Expand Down Expand Up @@ -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 || {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find toPaginationOptions definition
find . -type f -name "*.ts" | xargs grep -l "toPaginationOptions" | head -20

Repository: Worklenz/worklenz

Length of output: 1389


🏁 Script executed:

#!/bin/bash
# Locate and inspect toPaginationOptions implementation
rg -n --type=ts "toPaginationOptions" -A 15 worklenz-backend/src/controllers/

Repository: Worklenz/worklenz

Length of output: 50373


🏁 Script executed:

#!/bin/bash
# Get the full toPaginationOptions function from worklenz-controller-base.ts
wc -l worklenz-backend/src/controllers/worklenz-controller-base.ts

Repository: Worklenz/worklenz

Length of output: 124


🏁 Script executed:

#!/bin/bash
# Read the toPaginationOptions function implementation
sed -n '30,100p' worklenz-backend/src/controllers/worklenz-controller-base.ts

Repository: Worklenz/worklenz

Length of output: 2264


🏁 Script executed:

#!/bin/bash
# Find the specific line 93 in reporting-overview-controller.ts and surrounding context
sed -n '80,100p' worklenz-backend/src/controllers/reporting/overview/reporting-overview-controller.ts

Repository: Worklenz/worklenz

Length of output: 1158


🏁 Script executed:

#!/bin/bash
# Find getProjectsByTeam implementation and see how it uses sortField
rg -n "getProjectsByTeam" worklenz-backend/src/controllers/ -A 5 | head -40

Repository: Worklenz/worklenz

Length of output: 4508


🏁 Script executed:

#!/bin/bash
# Check the ReportingControllerBase class
find . -type f -name "*reporting*controller*base*" -o -name "*base*controller*" | grep reporting

Repository: Worklenz/worklenz

Length of output: 222


🏁 Script executed:

#!/bin/bash
# Read the getProjectsByTeam function in reporting-controller-base.ts
sed -n '230,280p' worklenz-backend/src/controllers/reporting/reporting-controller-base.ts

Repository: Worklenz/worklenz

Length of output: 2140


🏁 Script executed:

#!/bin/bash
# Read more of the getProjectsByTeam function to find the ORDER BY clause
sed -n '230,330p' worklenz-backend/src/controllers/reporting/reporting-controller-base.ts

Repository: Worklenz/worklenz

Length of output: 5743


🏁 Script executed:

#!/bin/bash
# Search for ORDER BY in the getProjectsByTeam function
sed -n '230,400p' worklenz-backend/src/controllers/reporting/reporting-controller-base.ts | grep -n "ORDER BY"

Repository: Worklenz/worklenz

Length of output: 245


🏁 Script executed:

#!/bin/bash
# Get the complete file size and read the end portion
wc -l worklenz-backend/src/controllers/reporting/reporting-controller-base.ts
tail -200 worklenz-backend/src/controllers/reporting/reporting-controller-base.ts | head -100

Repository: Worklenz/worklenz

Length of output: 6041


🏁 Script executed:

#!/bin/bash
# Read lines around 370-380 where ORDER BY ${sortField} is used
sed -n '360,390p' worklenz-backend/src/controllers/reporting/reporting-controller-base.ts

Repository: Worklenz/worklenz

Length of output: 2383


🏁 Script executed:

#!/bin/bash
# Check the return type annotation of toPaginationOptions
sed -n '30,78p' worklenz-backend/src/controllers/worklenz-controller-base.ts | tail -5

Repository: Worklenz/worklenz

Length of output: 176


🏁 Script executed:

#!/bin/bash
# Look for where sortField is used in other controllers after toPaginationOptions
rg "toPaginationOptions.*sortField" worklenz-backend/src/controllers/ -A 3 | head -50

Repository: Worklenz/worklenz

Length of output: 43


Verify that toPaginationOptions always normalizes sortField to a guaranteed string.

The toPaginationOptions function can return sortField as either a string or string[] depending on the searchField parameter passed to it. At line 93, toPaginationOptions is called with ["p.name"] (an array), and when no explicit field query parameter is provided, sortField defaults to this array. The subsequent as string cast is a compile-time assertion that does not enforce a runtime check—if searchField is an array and no query field is provided, sortField will be passed to getProjectsByTeam as an array despite the cast, leading to a type mismatch in the SQL template literal ORDER BY ${sortField} (which would rely on JavaScript's implicit toString() conversion).

Ensure toPaginationOptions normalizes sortField to always be a single string value before returning, or validate/map the array to a safe default string field.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@worklenz-backend/src/controllers/reporting/overview/reporting-overview-controller.ts`
at line 93, The sortField coming from toPaginationOptions can be a string or
string[] which causes a runtime type mismatch when passed into
ReportingControllerBase.getProjectsByTeam and used in ORDER BY; update the code
so sortField is always a string: either modify toPaginationOptions to normalize
its sortField return value (if sortField is an array, return the selected field
or the first element as a string) or, if you prefer the minimal change, add a
defensive normalization before the call at reporting-overview-controller.ts
(e.g., if Array.isArray(sortField) pick the first element or a safe default like
"p.name"); ensure the normalized string is what you pass into getProjectsByTeam
and used in SQL templating so ORDER BY always receives a string.



for (const project of result.projects) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Comment on lines +419 to 421
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

archived is destructured but never used — dead variable.

archived is added to the destructuring on line 419, but includeArchived on line 421 re-reads req.query.archived directly. The destructured binding is never referenced.

Either remove archived from the destructuring, or use it to compute includeArchived:

🔧 Proposed fix
-    const { duration, date_range, only_single_member, archived } = req.query;
+    const { duration, date_range, only_single_member } = req.query;

     const includeArchived = req.query.archived === "true";

Or, alternatively, use the destructured variable and remove the redundant req.query access:

     const { duration, date_range, only_single_member, archived } = req.query;

-    const includeArchived = req.query.archived === "true";
+    const includeArchived = archived === "true";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { duration, date_range, only_single_member, archived } = req.query;
const includeArchived = req.query.archived === "true";
const { duration, date_range, only_single_member } = req.query;
const includeArchived = req.query.archived === "true";
Suggested change
const { duration, date_range, only_single_member, archived } = req.query;
const includeArchived = req.query.archived === "true";
const { duration, date_range, only_single_member, archived } = req.query;
const includeArchived = archived === "true";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@worklenz-backend/src/controllers/reporting/overview/reporting-overview-export-controller.ts`
around lines 419 - 421, Remove the dead variable by either deleting `archived`
from the destructuring or use it to compute `includeArchived`; specifically
update the block where `const { duration, date_range, only_single_member,
archived } = req.query;` and `const includeArchived = req.query.archived ===
"true";` are defined so that `includeArchived` is derived from the destructured
`archived` (e.g., `includeArchived` computed from `archived`) or remove
`archived` from the destructure and keep the current `includeArchived`
assignment; modify the code around the `includeArchived` computation in
reporting-overview-export-controller.ts accordingly.


Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
Expand All @@ -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 "";
}
Expand Down Expand Up @@ -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<string> {
// 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 "";
}
Comment on lines +169 to +210
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

SQL injection: userId is interpolated directly into the returned clause.

Line 200 embeds userId via string interpolation (user_id = '${userId}'), bypassing parameterized queries. Although userId originates from the authenticated session, this contradicts the parameterization effort in this PR and is a security anti-pattern. If this method is ever called with unsanitized input, it becomes exploitable.

Return both a clause with placeholders and the corresponding params, similar to SqlHelper.buildInClause:

Proposed fix
-  public static async buildProjectFilterForTeamLead(req: IWorkLenzRequest): Promise<string> {
+  public static async buildProjectFilterForTeamLead(req: IWorkLenzRequest, paramOffset: number): Promise<{ clause: string; params: any[] }> {
     const userId = req.user?.id;
     const teamId = req.user?.team_id;
 
-    if (!userId || !teamId) return "";
+    if (!userId || !teamId) return { clause: "", params: [] };
 
     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 "";
+    if (roleResult.rows.length === 0) return { clause: "", params: [] };
 
     const roleKey = roleResult.rows[0].key;
 
     if (roleKey === 'TEAM_LEAD') {
-      return `AND p.id IN (
+      return {
+        clause: `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}'
+          SELECT id FROM team_members WHERE user_id = $${paramOffset}
         ) 
         AND pm.project_access_level_id = (
           SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER'
         )
-      )`;
+      )`,
+        params: [userId]
+      };
     }
 
-    return "";
+    return { clause: "", params: [] };
   }

Callers would then spread the returned params into their filterParams array and advance paramOffset accordingly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@worklenz-backend/src/controllers/reporting/reporting-controller-base.ts`
around lines 169 - 210, buildProjectFilterForTeamLead currently injects userId
directly into the SQL fragment (user_id = '${userId}'), causing a SQL injection
risk; change the method (buildProjectFilterForTeamLead) to return a
parameterized clause plus its params (e.g. { clause: string, params: any[] })
instead of a raw string, replace the interpolated userId with a positional
placeholder ($n) and push userId into the returned params array, and ensure
callers spread those params into their filterParams and advance their
paramOffset (follow the pattern used by SqlHelper.buildInClause for parameter
numbering/offset handling).


/**
* Get project IDs that a Team Lead is assigned to
*/
public static async getTeamLeadProjects(userId: string, teamId: string): Promise<string[]> {
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,
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Loading