Merge branch 'feature/project-list-grouping' into upstream/feature/project-groupby

This commit is contained in:
Chamika J
2025-06-13 13:02:17 +05:30
committed by GitHub
162 changed files with 11294 additions and 3349 deletions

View File

@@ -6,7 +6,7 @@ import logger from "morgan";
import helmet from "helmet";
import compression from "compression";
import passport from "passport";
import csurf from "csurf";
import { csrfSync } from "csrf-sync";
import rateLimit from "express-rate-limit";
import cors from "cors";
import flash from "connect-flash";
@@ -112,17 +112,13 @@ function isLoggedIn(req: Request, _res: Response, next: NextFunction) {
return req.user ? next() : next(createError(401));
}
// CSRF configuration
const csrfProtection = csurf({
cookie: {
key: "XSRF-TOKEN",
path: "/",
httpOnly: false,
secure: isProduction(), // Only secure in production
sameSite: isProduction() ? "none" : "lax", // Different settings for dev vs prod
domain: isProduction() ? ".worklenz.com" : undefined // Only set domain in production
},
ignoreMethods: ["HEAD", "OPTIONS"]
// CSRF configuration using csrf-sync for session-based authentication
const {
invalidCsrfTokenError,
generateToken,
csrfSynchronisedProtection,
} = csrfSync({
getTokenFromRequest: (req: Request) => req.headers["x-csrf-token"] as string || (req.body && req.body["_csrf"])
});
// Apply CSRF selectively (exclude webhooks and public routes)
@@ -135,38 +131,25 @@ app.use((req, res, next) => {
) {
next();
} else {
csrfProtection(req, res, next);
csrfSynchronisedProtection(req, res, next);
}
});
// Set CSRF token cookie
// Set CSRF token method on request object for compatibility
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.csrfToken) {
const token = req.csrfToken();
res.cookie("XSRF-TOKEN", token, {
httpOnly: false,
secure: isProduction(),
sameSite: isProduction() ? "none" : "lax",
domain: isProduction() ? ".worklenz.com" : undefined,
path: "/"
});
// Add csrfToken method to request object for compatibility
if (!req.csrfToken && generateToken) {
req.csrfToken = (overwrite?: boolean) => generateToken(req, overwrite);
}
next();
});
// CSRF token refresh endpoint
app.get("/csrf-token", (req: Request, res: Response) => {
if (req.csrfToken) {
const token = req.csrfToken();
res.cookie("XSRF-TOKEN", token, {
httpOnly: false,
secure: isProduction(),
sameSite: isProduction() ? "none" : "lax",
domain: isProduction() ? ".worklenz.com" : undefined,
path: "/"
});
res.status(200).json({ done: true, message: "CSRF token refreshed" });
} else {
try {
const token = generateToken(req);
res.status(200).json({ done: true, message: "CSRF token refreshed", token });
} catch (error) {
res.status(500).json({ done: false, message: "Failed to generate CSRF token" });
}
});
@@ -219,7 +202,7 @@ if (isInternalServer()) {
// CSRF error handler
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
if (err.code === "EBADCSRFTOKEN") {
if (err === invalidCsrfTokenError) {
return res.status(403).json({
done: false,
message: "Invalid CSRF token",

View File

@@ -5,7 +5,7 @@ import db from "../config/db";
import {ServerResponse} from "../models/server-response";
import WorklenzControllerBase from "./worklenz-controller-base";
import HandleExceptions from "../decorators/handle-exceptions";
import {calculateMonthDays, getColor, megabytesToBytes} from "../shared/utils";
import {calculateMonthDays, getColor, log_error, megabytesToBytes} from "../shared/utils";
import moment from "moment";
import {calculateStorage} from "../shared/s3";
import {checkTeamSubscriptionStatus, getActiveTeamMemberCount, getCurrentProjectsCount, getFreePlanSettings, getOwnerIdByTeam, getTeamMemberCount, getUsedStorage} from "../shared/paddle-utils";
@@ -232,7 +232,11 @@ export default class AdminCenterController extends WorklenzControllerBase {
FROM team_member_info_view
WHERE team_member_info_view.team_member_id = tm.id),
role_id,
r.name AS role_name
r.name AS role_name,
EXISTS(SELECT email
FROM email_invitations
WHERE team_member_id = tm.id
AND email_invitations.team_id = tm.team_id) AS pending_invitation
FROM team_members tm
LEFT JOIN users u on tm.user_id = u.id
LEFT JOIN roles r on tm.role_id = r.id
@@ -255,22 +259,33 @@ export default class AdminCenterController extends WorklenzControllerBase {
const {id} = req.params;
const {name, teamMembers} = req.body;
const updateNameQuery = `UPDATE teams
SET name = $1
WHERE id = $2;`;
await db.query(updateNameQuery, [name, id]);
try {
// Update team name
const updateNameQuery = `UPDATE teams SET name = $1 WHERE id = $2 RETURNING id;`;
const nameResult = await db.query(updateNameQuery, [name, id]);
if (!nameResult.rows.length) {
return res.status(404).send(new ServerResponse(false, null, "Team not found"));
}
if (teamMembers.length) {
teamMembers.forEach(async (element: { role_name: string; user_id: string; }) => {
const q = `UPDATE team_members
SET role_id = (SELECT id FROM roles WHERE roles.team_id = $1 AND name = $2)
WHERE user_id = $3
AND team_id = $1;`;
await db.query(q, [id, element.role_name, element.user_id]);
});
// Update team member roles if provided
if (teamMembers?.length) {
// Use Promise.all to handle all role updates concurrently
await Promise.all(teamMembers.map(async (member: { role_name: string; user_id: string; }) => {
const roleQuery = `
UPDATE team_members
SET role_id = (SELECT id FROM roles WHERE roles.team_id = $1 AND name = $2)
WHERE user_id = $3 AND team_id = $1
RETURNING id;`;
await db.query(roleQuery, [id, member.role_name, member.user_id]);
}));
}
return res.status(200).send(new ServerResponse(true, null, "Team updated successfully"));
} catch (error) {
log_error("Error updating team:", error);
return res.status(500).send(new ServerResponse(false, null, "Failed to update team"));
}
return res.status(200).send(new ServerResponse(true, [], "Team updated successfully"));
}
@HandleExceptions()

View File

@@ -35,8 +35,18 @@ export default class AuthController extends WorklenzControllerBase {
const auth_error = errors.length > 0 ? errors[0] : null;
const message = messages.length > 0 ? messages[0] : null;
const midTitle = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
const title = req.query.strategy ? midTitle : null;
// Determine title based on authentication status and strategy
let title = null;
if (req.query.strategy) {
if (auth_error) {
// Show failure title only when there's an actual error
title = req.query.strategy === "login" ? "Login Failed!" : "Signup Failed!";
} else if (req.isAuthenticated() && message) {
// Show success title when authenticated and there's a success message
title = req.query.strategy === "login" ? "Login Successful!" : "Signup Successful!";
}
// If no error and not authenticated, don't show any title (this might be a redirect without completion)
}
if (req.user)
req.user.build_v = FileConstants.getRelease();

View File

@@ -322,7 +322,7 @@ export default class ProjectInsightsController extends WorklenzControllerBase {
(SELECT get_task_assignees(tasks.id)) AS assignees
FROM tasks
JOIN work_log ON work_log.task_id = tasks.id
WHERE project_id = $1
WHERE project_id = $1 AND total_minutes <> 0 AND (total_minutes * 60) <> work_log.total_time_spent
AND CASE
WHEN ($2 IS TRUE) THEN project_id IS NOT NULL
ELSE archived IS FALSE END

View File

@@ -408,6 +408,9 @@ export default class ProjectsController extends WorklenzControllerBase {
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,

View File

@@ -408,6 +408,65 @@ export default class ReportingAllocationController extends ReportingControllerBa
const { duration, date_range } = req.body;
// Calculate the date range (start and end)
let startDate: moment.Moment;
let endDate: moment.Moment;
if (date_range && date_range.length === 2) {
startDate = moment(date_range[0]);
endDate = moment(date_range[1]);
} else if (duration === DATE_RANGES.ALL_TIME) {
// Fetch the earliest start_date (or created_at if null) from selected projects
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
const minDateResult = await db.query(minDateQuery, []);
const minDate = minDateResult.rows[0]?.min_date;
startDate = minDate ? moment(minDate) : moment('2000-01-01');
endDate = moment();
} else {
switch (duration) {
case DATE_RANGES.YESTERDAY:
startDate = moment().subtract(1, "day");
endDate = moment().subtract(1, "day");
break;
case DATE_RANGES.LAST_WEEK:
startDate = moment().subtract(1, "week").startOf("isoWeek");
endDate = moment().subtract(1, "week").endOf("isoWeek");
break;
case DATE_RANGES.LAST_MONTH:
startDate = moment().subtract(1, "month").startOf("month");
endDate = moment().subtract(1, "month").endOf("month");
break;
case DATE_RANGES.LAST_QUARTER:
startDate = moment().subtract(3, "months").startOf("quarter");
endDate = moment().subtract(1, "quarter").endOf("quarter");
break;
default:
startDate = moment().startOf("day");
endDate = moment().endOf("day");
}
}
// Count only weekdays (Mon-Fri) in the period
let workingDays = 0;
let current = startDate.clone();
while (current.isSameOrBefore(endDate, 'day')) {
const day = current.isoWeekday();
if (day >= 1 && day <= 5) workingDays++;
current.add(1, 'day');
}
// Get hours_per_day for all selected projects
const projectHoursQuery = `SELECT id, hours_per_day FROM projects WHERE id IN (${projectIds})`;
const projectHoursResult = await db.query(projectHoursQuery, []);
const projectHoursMap: Record<string, number> = {};
for (const row of projectHoursResult.rows) {
projectHoursMap[row.id] = row.hours_per_day || 8;
}
// Sum total working hours for all selected projects
let totalWorkingHours = 0;
for (const pid of Object.keys(projectHoursMap)) {
totalWorkingHours += workingDays * projectHoursMap[pid];
}
const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range);
const archivedClause = archived
? ""
@@ -430,6 +489,12 @@ export default class ReportingAllocationController extends ReportingControllerBa
for (const member of result.rows) {
member.value = member.logged_time ? parseFloat(moment.duration(member.logged_time, "seconds").asHours().toFixed(2)) : 0;
member.color_code = getColor(member.name);
member.total_working_hours = totalWorkingHours;
member.utilization_percent = (totalWorkingHours > 0 && member.logged_time) ? ((parseFloat(member.logged_time) / (totalWorkingHours * 3600)) * 100).toFixed(2) : '0.00';
member.utilized_hours = member.logged_time ? (parseFloat(member.logged_time) / 3600).toFixed(2) : '0.00';
// Over/under utilized hours: utilized_hours - total_working_hours
const overUnder = member.utilized_hours && member.total_working_hours ? (parseFloat(member.utilized_hours) - member.total_working_hours) : 0;
member.over_under_utilized_hours = overUnder.toFixed(2);
}
return res.status(200).send(new ServerResponse(true, result.rows));

View File

@@ -1,6 +1,6 @@
import WorklenzControllerBase from "./worklenz-controller-base";
import {getColor} from "../shared/utils";
import {PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA} from "../shared/constants";
import { getColor } from "../shared/utils";
import { PriorityColorCodes, TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA } from "../shared/constants";
import moment from "moment/moment";
export const GroupBy = {
@@ -32,10 +32,41 @@ export default class TasksControllerBase extends WorklenzControllerBase {
}
public static updateTaskViewModel(task: any) {
task.progress = ~~(task.total_minutes_spent / task.total_minutes * 100);
// For parent tasks (with subtasks), always use calculated progress from subtasks
if (task.sub_tasks_count > 0) {
// Ensure progress matches complete_ratio for consistency
task.progress = task.complete_ratio || 0;
// Important: Parent tasks should not have manual progress
// If they somehow do, reset it
if (task.manual_progress) {
task.manual_progress = false;
task.progress_value = null;
}
}
// For tasks without subtasks, respect manual progress if set
else if (task.manual_progress === true && task.progress_value !== null && task.progress_value !== undefined) {
// For manually set progress, use that value directly
task.progress = parseInt(task.progress_value);
task.complete_ratio = parseInt(task.progress_value);
}
// For tasks with no subtasks and no manual progress, calculate based on time
else {
task.progress = task.total_minutes_spent && task.total_minutes
? ~~(task.total_minutes_spent / task.total_minutes * 100)
: 0;
// Set complete_ratio to match progress
task.complete_ratio = task.progress;
}
// Ensure numeric values
task.progress = parseInt(task.progress) || 0;
task.complete_ratio = parseInt(task.complete_ratio) || 0;
task.overdue = task.total_minutes < task.total_minutes_spent;
task.time_spent = {hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60};
task.time_spent = { hours: ~~(task.total_minutes_spent / 60), minutes: task.total_minutes_spent % 60 };
task.comments_count = Number(task.comments_count) ? +task.comments_count : 0;
task.attachments_count = Number(task.attachments_count) ? +task.attachments_count : 0;
@@ -73,9 +104,9 @@ export default class TasksControllerBase extends WorklenzControllerBase {
if (task.timer_start_time)
task.timer_start_time = moment(task.timer_start_time).valueOf();
// Set completed_count and total_tasks_count regardless of progress calculation method
const totalCompleted = (+task.completed_sub_tasks + +task.parent_task_completed) || 0;
const totalTasks = +task.sub_tasks_count || 0; // if needed add +1 for parent
task.complete_ratio = TasksControllerBase.calculateTaskCompleteRatio(totalCompleted, totalTasks);
const totalTasks = +task.sub_tasks_count || 0;
task.completed_count = totalCompleted;
task.total_tasks_count = totalTasks;

View File

@@ -97,9 +97,13 @@ export default class TasksControllerV2 extends TasksControllerBase {
try {
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
const [data] = result.rows;
data.info.ratio = +data.info.ratio.toFixed();
return data.info;
if (data && data.info && data.info.ratio !== undefined) {
data.info.ratio = +((data.info.ratio || 0).toFixed());
return data.info;
}
return null;
} catch (error) {
log_error(`Error in getTaskCompleteRatio: ${error}`);
return null;
}
}
@@ -192,6 +196,13 @@ export default class TasksControllerV2 extends TasksControllerBase {
t.archived,
t.description,
t.sort_order,
t.progress_value,
t.manual_progress,
t.weight,
(SELECT use_manual_progress FROM projects WHERE id = t.project_id) AS project_use_manual_progress,
(SELECT use_weighted_progress FROM projects WHERE id = t.project_id) AS project_use_weighted_progress,
(SELECT use_time_progress FROM projects WHERE id = t.project_id) AS project_use_time_progress,
(SELECT get_task_complete_ratio(t.id)->>'ratio') AS complete_ratio,
(SELECT phase_id FROM task_phase WHERE task_id = t.id) AS phase_id,
(SELECT name
@@ -315,6 +326,11 @@ export default class TasksControllerV2 extends TasksControllerBase {
@HandleExceptions()
public static async getList(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
// Before doing anything else, refresh task progress values for this project
if (req.params.id) {
await this.refreshProjectTaskProgressValues(req.params.id);
}
const isSubTasks = !!req.query.parent_task;
const groupBy = (req.query.group || GroupBy.STATUS) as string;
@@ -334,7 +350,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
return g;
}, {});
this.updateMapByGroup(tasks, groupBy, map);
await this.updateMapByGroup(tasks, groupBy, map);
const updatedGroups = Object.keys(map).map(key => {
const group = map[key];
@@ -353,12 +369,28 @@ export default class TasksControllerV2 extends TasksControllerBase {
return res.status(200).send(new ServerResponse(true, updatedGroups));
}
public static updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) {
public static async updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: ITaskGroup }) {
let index = 0;
const unmapped = [];
// First, ensure we have the latest progress values for all tasks
for (const task of tasks) {
// For any task with subtasks, ensure we have the latest progress values
if (task.sub_tasks_count > 0) {
const info = await this.getTaskCompleteRatio(task.id);
if (info) {
task.complete_ratio = info.ratio;
task.progress_value = info.ratio; // Ensure progress_value reflects the calculated ratio
console.log(`Updated task ${task.name} (${task.id}): complete_ratio=${task.complete_ratio}`);
}
}
}
// Now group the tasks with their updated progress values
for (const task of tasks) {
task.index = index++;
TasksControllerV2.updateTaskViewModel(task);
if (groupBy === GroupBy.STATUS) {
map[task.status]?.tasks.push(task);
} else if (groupBy === GroupBy.PRIORITY) {
@@ -394,8 +426,13 @@ export default class TasksControllerV2 extends TasksControllerBase {
@HandleExceptions()
public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
// Before doing anything else, refresh task progress values for this project
if (req.params.id) {
await this.refreshProjectTaskProgressValues(req.params.id);
}
const isSubTasks = !!req.query.parent_task;
// Add customColumns flag to query params
req.query.customColumns = "true";
@@ -410,7 +447,24 @@ export default class TasksControllerV2 extends TasksControllerBase {
[data] = result.rows;
} else { // else we return a flat list of tasks
data = [...result.rows];
for (const task of data) {
// For tasks with subtasks, get the complete ratio from the database function
if (task.sub_tasks_count > 0) {
try {
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [task.id]);
const [ratioData] = result.rows;
if (ratioData && ratioData.info) {
task.complete_ratio = +(ratioData.info.ratio || 0).toFixed();
task.completed_count = ratioData.info.total_completed;
task.total_tasks_count = ratioData.info.total_tasks;
console.log(`Updated task ${task.id} (${task.name}) from DB: complete_ratio=${task.complete_ratio}`);
}
} catch (error) {
// Proceed with default calculation if database call fails
}
}
TasksControllerV2.updateTaskViewModel(task);
}
}
@@ -443,6 +497,53 @@ export default class TasksControllerV2 extends TasksControllerBase {
return res.status(200).send(new ServerResponse(true, task));
}
@HandleExceptions()
public static async resetParentTaskManualProgress(parentTaskId: string): Promise<void> {
try {
// Check if this task has subtasks
const subTasksResult = await db.query(
"SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1 AND archived IS FALSE",
[parentTaskId]
);
const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || "0");
// If it has subtasks, reset the manual_progress flag to false
if (subtaskCount > 0) {
await db.query(
"UPDATE tasks SET manual_progress = false WHERE id = $1",
[parentTaskId]
);
console.log(`Reset manual progress for parent task ${parentTaskId} with ${subtaskCount} subtasks`);
// Get the project settings to determine which calculation method to use
const projectResult = await db.query(
"SELECT project_id FROM tasks WHERE id = $1",
[parentTaskId]
);
const projectId = projectResult.rows[0]?.project_id;
if (projectId) {
// Recalculate the parent task's progress based on its subtasks
const progressResult = await db.query(
"SELECT get_task_complete_ratio($1) AS ratio",
[parentTaskId]
);
const progressRatio = progressResult.rows[0]?.ratio?.ratio || 0;
// Emit the updated progress value to all clients
// Note: We don't have socket context here, so we can't directly emit
// This will be picked up on the next client refresh
console.log(`Recalculated progress for parent task ${parentTaskId}: ${progressRatio}%`);
}
}
} catch (error) {
log_error(`Error resetting parent task manual progress: ${error}`);
}
}
@HandleExceptions()
public static async convertToSubtask(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
@@ -482,6 +583,11 @@ export default class TasksControllerV2 extends TasksControllerBase {
? [req.body.id, req.body.to_group_id]
: [req.body.id, req.body.project_id, req.body.parent_task_id, req.body.to_group_id];
await db.query(q, params);
// Reset the parent task's manual progress when converting a task to a subtask
if (req.body.parent_task_id) {
await this.resetParentTaskManualProgress(req.body.parent_task_id);
}
const result = await db.query("SELECT get_single_task($1) AS task;", [req.body.id]);
const [data] = result.rows;
@@ -724,4 +830,126 @@ export default class TasksControllerV2 extends TasksControllerBase {
value
}));
}
public static async refreshProjectTaskProgressValues(projectId: string): Promise<void> {
try {
// Run the recalculate_all_task_progress function only for tasks in this project
const query = `
DO $$
BEGIN
-- First, reset manual_progress flag for all tasks that have subtasks within this project
UPDATE tasks AS t
SET manual_progress = FALSE
WHERE project_id = '${projectId}'
AND EXISTS (
SELECT 1
FROM tasks
WHERE parent_task_id = t.id
AND archived IS FALSE
);
-- Start recalculation from leaf tasks (no subtasks) and propagate upward
-- This ensures calculations are done in the right order
WITH RECURSIVE task_hierarchy AS (
-- Base case: Start with all leaf tasks (no subtasks) in this project
SELECT
id,
parent_task_id,
0 AS level
FROM tasks
WHERE project_id = '${projectId}'
AND NOT EXISTS (
SELECT 1 FROM tasks AS sub
WHERE sub.parent_task_id = tasks.id
AND sub.archived IS FALSE
)
AND archived IS FALSE
UNION ALL
-- Recursive case: Move up to parent tasks, but only after processing all their children
SELECT
t.id,
t.parent_task_id,
th.level + 1
FROM tasks t
JOIN task_hierarchy th ON t.id = th.parent_task_id
WHERE t.archived IS FALSE
)
-- Sort by level to ensure we calculate in the right order (leaves first, then parents)
UPDATE tasks
SET progress_value = (SELECT (get_task_complete_ratio(tasks.id)->>'ratio')::FLOAT)
FROM (
SELECT id, level
FROM task_hierarchy
ORDER BY level
) AS ordered_tasks
WHERE tasks.id = ordered_tasks.id
AND tasks.project_id = '${projectId}'
AND (manual_progress IS FALSE OR manual_progress IS NULL);
END $$;
`;
await db.query(query);
console.log(`Finished refreshing progress values for project ${projectId}`);
} catch (error) {
log_error("Error refreshing project task progress values", error);
}
}
public static async updateTaskProgress(taskId: string): Promise<void> {
try {
// Calculate the task's progress using get_task_complete_ratio
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
const [data] = result.rows;
if (data && data.info && data.info.ratio !== undefined) {
const progressValue = +((data.info.ratio || 0).toFixed());
// Update the task's progress_value in the database
await db.query(
"UPDATE tasks SET progress_value = $1 WHERE id = $2",
[progressValue, taskId]
);
console.log(`Updated progress for task ${taskId} to ${progressValue}%`);
// If this task has a parent, update the parent's progress as well
const parentResult = await db.query(
"SELECT parent_task_id FROM tasks WHERE id = $1",
[taskId]
);
if (parentResult.rows.length > 0 && parentResult.rows[0].parent_task_id) {
await this.updateTaskProgress(parentResult.rows[0].parent_task_id);
}
}
} catch (error) {
log_error(`Error updating task progress: ${error}`);
}
}
// Add this method to update progress when a task's weight is changed
public static async updateTaskWeight(taskId: string, weight: number): Promise<void> {
try {
// Update the task's weight
await db.query(
"UPDATE tasks SET weight = $1 WHERE id = $2",
[weight, taskId]
);
// Get the parent task ID
const parentResult = await db.query(
"SELECT parent_task_id FROM tasks WHERE id = $1",
[taskId]
);
// If this task has a parent, update the parent's progress
if (parentResult.rows.length > 0 && parentResult.rows[0].parent_task_id) {
await this.updateTaskProgress(parentResult.rows[0].parent_task_id);
}
} catch (error) {
log_error(`Error updating task weight: ${error}`);
}
}
}

View File

@@ -1,11 +1,11 @@
import {startDailyDigestJob} from "./daily-digest-job";
import {startNotificationsJob} from "./notifications-job";
import {startProjectDigestJob} from "./project-digest-job";
import { startRecurringTasksJob } from "./recurring-tasks";
import {startRecurringTasksJob} from "./recurring-tasks";
export function startCronJobs() {
startNotificationsJob();
startDailyDigestJob();
startProjectDigestJob();
// startRecurringTasksJob();
if (process.env.ENABLE_RECURRING_JOBS === "true") startRecurringTasksJob();
}

View File

@@ -7,12 +7,90 @@ import TasksController from "../controllers/tasks-controller";
// At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday.
// const TIME = "0 11 */1 * 1-5";
const TIME = "*/2 * * * *";
const TIME = process.env.RECURRING_JOBS_INTERVAL || "0 11 */1 * 1-5";
const TIME_FORMAT = "YYYY-MM-DD";
// const TIME = "0 0 * * *"; // Runs at midnight every day
const log = (value: any) => console.log("recurring-task-cron-job:", value);
// Define future limits for different schedule types
// More conservative limits to prevent task list clutter
const FUTURE_LIMITS = {
daily: moment.duration(3, "days"),
weekly: moment.duration(1, "week"),
monthly: moment.duration(1, "month"),
every_x_days: (interval: number) => moment.duration(interval, "days"),
every_x_weeks: (interval: number) => moment.duration(interval, "weeks"),
every_x_months: (interval: number) => moment.duration(interval, "months")
};
// Helper function to get the future limit based on schedule type
function getFutureLimit(scheduleType: string, interval?: number): moment.Duration {
switch (scheduleType) {
case "daily":
return FUTURE_LIMITS.daily;
case "weekly":
return FUTURE_LIMITS.weekly;
case "monthly":
return FUTURE_LIMITS.monthly;
case "every_x_days":
return FUTURE_LIMITS.every_x_days(interval || 1);
case "every_x_weeks":
return FUTURE_LIMITS.every_x_weeks(interval || 1);
case "every_x_months":
return FUTURE_LIMITS.every_x_months(interval || 1);
default:
return moment.duration(3, "days"); // Default to 3 days
}
}
// Helper function to batch create tasks
async function createBatchTasks(template: ITaskTemplate & IRecurringSchedule, endDates: moment.Moment[]) {
const createdTasks = [];
for (const nextEndDate of endDates) {
const existingTaskQuery = `
SELECT id FROM tasks
WHERE schedule_id = $1 AND end_date::DATE = $2::DATE;
`;
const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]);
if (existingTaskResult.rows.length === 0) {
const createTaskQuery = `SELECT create_quick_task($1::json) as task;`;
const taskData = {
name: template.name,
priority_id: template.priority_id,
project_id: template.project_id,
reporter_id: template.reporter_id,
status_id: template.status_id || null,
end_date: nextEndDate.format(TIME_FORMAT),
schedule_id: template.schedule_id
};
const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]);
const createdTask = createTaskResult.rows[0].task;
if (createdTask) {
createdTasks.push(createdTask);
for (const assignee of template.assignees) {
await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by);
}
for (const label of template.labels) {
const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`;
await db.query(q, [createdTask.id, label.label_id]);
}
console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`);
}
} else {
console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`);
}
}
return createdTasks;
}
async function onRecurringTaskJobTick() {
try {
log("(cron) Recurring tasks job started.");
@@ -33,65 +111,44 @@ async function onRecurringTaskJobTick() {
? moment(template.last_task_end_date)
: moment(template.created_at);
const futureLimit = moment(template.last_checked_at || template.created_at).add(1, "week");
// Calculate future limit based on schedule type
const futureLimit = moment(template.last_checked_at || template.created_at)
.add(getFutureLimit(
template.schedule_type,
template.interval_days || template.interval_weeks || template.interval_months || 1
));
let nextEndDate = calculateNextEndDate(template, lastTaskEndDate);
const endDatesToCreate: moment.Moment[] = [];
// Find the next future occurrence
while (nextEndDate.isSameOrBefore(now)) {
// Find all future occurrences within the limit
while (nextEndDate.isSameOrBefore(futureLimit)) {
if (nextEndDate.isAfter(now)) {
endDatesToCreate.push(moment(nextEndDate));
}
nextEndDate = calculateNextEndDate(template, nextEndDate);
}
// Only create a task if it's within the future limit
if (nextEndDate.isSameOrBefore(futureLimit)) {
const existingTaskQuery = `
SELECT id FROM tasks
WHERE schedule_id = $1 AND end_date::DATE = $2::DATE;
// Batch create tasks for all future dates
if (endDatesToCreate.length > 0) {
const createdTasks = await createBatchTasks(template, endDatesToCreate);
createdTaskCount += createdTasks.length;
// Update the last_checked_at in the schedule
const updateScheduleQuery = `
UPDATE task_recurring_schedules
SET last_checked_at = $1::DATE,
last_created_task_end_date = $2
WHERE id = $3;
`;
const existingTaskResult = await db.query(existingTaskQuery, [template.schedule_id, nextEndDate.format(TIME_FORMAT)]);
if (existingTaskResult.rows.length === 0) {
const createTaskQuery = `SELECT create_quick_task($1::json) as task;`;
const taskData = {
name: template.name,
priority_id: template.priority_id,
project_id: template.project_id,
reporter_id: template.reporter_id,
status_id: template.status_id || null,
end_date: nextEndDate.format(TIME_FORMAT),
schedule_id: template.schedule_id
};
const createTaskResult = await db.query(createTaskQuery, [JSON.stringify(taskData)]);
const createdTask = createTaskResult.rows[0].task;
if (createdTask) {
createdTaskCount++;
for (const assignee of template.assignees) {
await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by);
}
for (const label of template.labels) {
const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`;
await db.query(q, [createdTask.id, label.label_id]);
}
console.log(`Created task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)}`);
}
} else {
console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`);
}
await db.query(updateScheduleQuery, [
moment().format(TIME_FORMAT),
endDatesToCreate[endDatesToCreate.length - 1].format(TIME_FORMAT),
template.schedule_id
]);
} else {
console.log(`No task created for template ${template.name} - next occurrence is beyond the future limit`);
console.log(`No tasks created for template ${template.name} - next occurrence is beyond the future limit`);
}
// Update the last_checked_at in the schedule
const updateScheduleQuery = `
UPDATE task_recurring_schedules
SET last_checked_at = $1::DATE, last_created_task_end_date = $2
WHERE id = $3;
`;
await db.query(updateScheduleQuery, [moment(template.last_checked_at || template.created_at).add(1, "day").format(TIME_FORMAT), nextEndDate.format(TIME_FORMAT), template.schedule_id]);
}
log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`);

View File

@@ -6,11 +6,11 @@ import { isProduction } from "../shared/utils";
const pgSession = require("connect-pg-simple")(session);
export default session({
name: process.env.SESSION_NAME,
name: process.env.SESSION_NAME || "worklenz.sid",
secret: process.env.SESSION_SECRET || "development-secret-key",
proxy: false,
proxy: true,
resave: false,
saveUninitialized: true,
saveUninitialized: false,
rolling: true,
store: new pgSession({
pool: db.pool,
@@ -18,10 +18,9 @@ export default session({
}),
cookie: {
path: "/",
// secure: isProduction(),
// httpOnly: isProduction(),
// sameSite: "none",
// domain: isProduction() ? ".worklenz.com" : undefined,
secure: isProduction(), // Use secure cookies in production
httpOnly: true,
sameSite: "lax", // Standard setting for same-origin requests
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
}
});

View File

@@ -3,13 +3,16 @@ import { Strategy as LocalStrategy } from "passport-local";
import { log_error } from "../../shared/utils";
import db from "../../config/db";
import { Request } from "express";
import { ERROR_KEY, SUCCESS_KEY } from "./passport-constants";
async function handleLogin(req: Request, email: string, password: string, done: any) {
console.log("Login attempt for:", email);
// Clear any existing flash messages
(req.session as any).flash = {};
if (!email || !password) {
console.log("Missing credentials");
return done(null, false, { message: "Please enter both email and password" });
const errorMsg = "Please enter both email and password";
req.flash(ERROR_KEY, errorMsg);
return done(null, false);
}
try {
@@ -19,23 +22,27 @@ async function handleLogin(req: Request, email: string, password: string, done:
AND google_id IS NULL
AND is_deleted IS FALSE;`;
const result = await db.query(q, [email]);
console.log("User query result count:", result.rowCount);
const [data] = result.rows;
if (!data?.password) {
console.log("No account found");
return done(null, false, { message: "No account found with this email" });
const errorMsg = "No account found with this email";
req.flash(ERROR_KEY, errorMsg);
return done(null, false);
}
const passwordMatch = bcrypt.compareSync(password, data.password);
console.log("Password match:", passwordMatch);
if (passwordMatch && email === data.email) {
delete data.password;
return done(null, data, {message: "User successfully logged in"});
const successMsg = "User successfully logged in";
req.flash(SUCCESS_KEY, successMsg);
return done(null, data);
}
return done(null, false, { message: "Incorrect email or password" });
const errorMsg = "Incorrect email or password";
req.flash(ERROR_KEY, errorMsg);
return done(null, false);
} catch (error) {
console.error("Login error:", error);
log_error(error, req.body);

View File

@@ -204,3 +204,29 @@ export async function logPhaseChange(activityLog: IActivityLog) {
insertToActivityLogs(activityLog);
}
}
export async function logProgressChange(activityLog: IActivityLog) {
const { task_id, new_value, old_value } = activityLog;
if (!task_id || !activityLog.socket) return;
if (old_value !== new_value) {
activityLog.user_id = getLoggedInUserIdFromSocket(activityLog.socket);
activityLog.attribute_type = IActivityLogAttributeTypes.PROGRESS;
activityLog.log_type = IActivityLogChangeType.UPDATE;
insertToActivityLogs(activityLog);
}
}
export async function logWeightChange(activityLog: IActivityLog) {
const { task_id, new_value, old_value } = activityLog;
if (!task_id || !activityLog.socket) return;
if (old_value !== new_value) {
activityLog.user_id = getLoggedInUserIdFromSocket(activityLog.socket);
activityLog.attribute_type = IActivityLogAttributeTypes.WEIGHT;
activityLog.log_type = IActivityLogChangeType.UPDATE;
insertToActivityLogs(activityLog);
}
}

View File

@@ -29,6 +29,8 @@ export enum IActivityLogAttributeTypes {
COMMENT = "comment",
ARCHIVE = "archive",
PHASE = "phase",
PROGRESS = "progress",
WEIGHT = "weight",
}
export enum IActivityLogChangeType {

View File

@@ -117,11 +117,11 @@ export const TASK_DUE_NO_DUE_COLOR = "#a9a9a9";
export const DEFAULT_PAGE_SIZE = 20;
// S3 Credentials
export const REGION = process.env.AWS_REGION || "us-east-1";
export const BUCKET = process.env.AWS_BUCKET || "your-bucket-name";
export const REGION = process.env.S3_REGION || "us-east-1";
export const BUCKET = process.env.S3_BUCKET || "your-bucket-name";
export const S3_URL = process.env.S3_URL || "https://your-s3-url";
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "";
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "";
export const S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID || "";
export const S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY || "";
// Azure Blob Storage Credentials
export const STORAGE_PROVIDER = process.env.STORAGE_PROVIDER || "s3";

View File

@@ -0,0 +1,49 @@
import { Socket } from "socket.io";
import db from "../../config/db";
import { log_error } from "../util";
// Define a type for the callback function
type DoneStatusesCallback = (statuses: Array<{
id: string;
name: string;
sort_order: number;
color_code: string;
}>) => void;
/**
* Socket handler to get task statuses in the "done" category for a project
* Used when prompting users to mark a task as done when progress reaches 100%
*/
export async function on_get_done_statuses(
io: any,
socket: Socket,
projectId: string,
callback: DoneStatusesCallback
) {
try {
if (!projectId) {
return callback([]);
}
// Query to get all statuses in the "done" category for the project
const result = await db.query(`
SELECT ts.id, ts.name, ts.sort_order, stsc.color_code
FROM task_statuses ts
INNER JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE ts.project_id = $1
AND stsc.is_done = TRUE
ORDER BY ts.sort_order ASC
`, [projectId]);
const doneStatuses = result.rows;
console.log(`Found ${doneStatuses.length} "done" statuses for project ${projectId}`);
// Use callback to return the result
callback(doneStatuses);
} catch (error) {
log_error(`Error getting "done" statuses for project ${projectId}: ${error}`);
callback([]);
}
}

View File

@@ -5,6 +5,8 @@ import TasksControllerV2 from "../../controllers/tasks-controller-v2";
export async function on_get_task_progress(_io: Server, socket: Socket, taskId?: string) {
try {
console.log(`GET_TASK_PROGRESS requested for task: ${taskId}`);
const task: any = {};
task.id = taskId;
@@ -13,6 +15,8 @@ export async function on_get_task_progress(_io: Server, socket: Socket, taskId?:
task.complete_ratio = info.ratio;
task.completed_count = info.total_completed;
task.total_tasks_count = info.total_tasks;
console.log(`Sending task progress for task ${taskId}: complete_ratio=${task.complete_ratio}`);
}
return socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task);

View File

@@ -0,0 +1,89 @@
import { Socket } from "socket.io";
import db from "../../config/db";
import { SocketEvents } from "../events";
import { log_error } from "../util";
/**
* Socket handler to retrieve the number of subtasks for a given task
* Used to validate on the client side whether a task should show progress inputs
*/
export async function on_get_task_subtasks_count(io: any, socket: Socket, taskId: string) {
try {
if (!taskId) {
return;
}
// Get the count of subtasks for this task
const result = await db.query(
"SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1 AND archived IS FALSE",
[taskId]
);
const subtaskCount = parseInt(result.rows[0]?.subtask_count || "0");
// Emit the subtask count back to the client
socket.emit(
"TASK_SUBTASKS_COUNT",
{
task_id: taskId,
subtask_count: subtaskCount,
has_subtasks: subtaskCount > 0
}
);
console.log(`Emitted subtask count for task ${taskId}: ${subtaskCount}`);
// If there are subtasks, also get their progress information
if (subtaskCount > 0) {
// Get all subtasks for this parent task with their progress information
const subtasksResult = await db.query(`
SELECT
t.id,
t.progress_value,
t.manual_progress,
t.weight,
CASE
WHEN t.manual_progress = TRUE THEN t.progress_value
ELSE COALESCE(
(SELECT (CASE WHEN tl.total_minutes > 0 THEN
(tl.total_minutes_spent / tl.total_minutes * 100)
ELSE 0 END)
FROM (
SELECT
t2.id,
t2.total_minutes,
COALESCE(SUM(twl.time_spent), 0) as total_minutes_spent
FROM tasks t2
LEFT JOIN task_work_log twl ON t2.id = twl.task_id
WHERE t2.id = t.id
GROUP BY t2.id, t2.total_minutes
) tl
), 0)
END as calculated_progress
FROM tasks t
WHERE t.parent_task_id = $1 AND t.archived IS FALSE
`, [taskId]);
// Emit progress updates for each subtask
for (const subtask of subtasksResult.rows) {
const progressValue = subtask.manual_progress ?
subtask.progress_value :
Math.floor(subtask.calculated_progress);
socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id: subtask.id,
progress_value: progressValue,
weight: subtask.weight
}
);
}
console.log(`Emitted progress updates for ${subtasksResult.rows.length} subtasks of task ${taskId}`);
}
} catch (error) {
log_error(`Error getting subtask count for task ${taskId}: ${error}`);
}
}

View File

@@ -4,10 +4,11 @@ import db from "../../config/db";
import {NotificationsService} from "../../services/notifications/notifications.service";
import {TASK_STATUS_COLOR_ALPHA} from "../../shared/constants";
import {SocketEvents} from "../events";
import {getLoggedInUserIdFromSocket, log_error, notifyProjectUpdates} from "../util";
import {getLoggedInUserIdFromSocket, log, log_error, notifyProjectUpdates} from "../util";
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
import {getTaskDetails, logStatusChange} from "../../services/activity-logs/activity-logs.service";
import {getTaskDetails, logProgressChange, logStatusChange} from "../../services/activity-logs/activity-logs.service";
import { assignMemberIfNot } from "./on-quick-assign-or-remove";
import logger from "../../utils/logger";
export async function on_task_status_change(_io: Server, socket: Socket, data?: string) {
try {
@@ -49,6 +50,46 @@ export async function on_task_status_change(_io: Server, socket: Socket, data?:
});
}
// Check if the new status is in a "done" category
if (changeResponse.status_category?.is_done) {
// Get current progress value
const progressResult = await db.query(`
SELECT progress_value, manual_progress
FROM tasks
WHERE id = $1
`, [body.task_id]);
const currentProgress = progressResult.rows[0]?.progress_value;
const isManualProgress = progressResult.rows[0]?.manual_progress;
// Only update if not already 100%
if (currentProgress !== 100) {
// Update progress to 100%
await db.query(`
UPDATE tasks
SET progress_value = 100, manual_progress = TRUE
WHERE id = $1
`, [body.task_id]);
log(`Task ${body.task_id} moved to done status - progress automatically set to 100%`, null);
// Log the progress change to activity logs
await logProgressChange({
task_id: body.task_id,
old_value: currentProgress !== null ? currentProgress.toString() : "0",
new_value: "100",
socket
});
// If this is a subtask, update parent task progress
if (body.parent_task) {
setTimeout(() => {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), body.parent_task);
}, 100);
}
}
}
const info = await TasksControllerV2.getTaskCompleteRatio(body.parent_task || body.task_id);
socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), {

View File

@@ -6,10 +6,76 @@ import { SocketEvents } from "../events";
import { log_error, notifyProjectUpdates } from "../util";
import { getTaskDetails, logTotalMinutes } from "../../services/activity-logs/activity-logs.service";
export async function on_time_estimation_change(_io: Server, socket: Socket, data?: string) {
/**
* Recursively updates all ancestor tasks' progress when a subtask changes
* @param io Socket.io instance
* @param socket Socket instance for emitting events
* @param projectId Project ID for room broadcasting
* @param taskId The task ID to update (starts with the parent task)
*/
async function updateTaskAncestors(io: any, socket: Socket, projectId: string, taskId: string | null) {
if (!taskId) return;
try {
// Get the current task's progress ratio
const progressRatio = await db.query(
"SELECT get_task_complete_ratio($1) as ratio",
[taskId]
);
const ratio = progressRatio?.rows[0]?.ratio?.ratio || 0;
console.log(`Updated task ${taskId} progress after time estimation change: ${ratio}`);
// Check if this task needs a "done" status prompt
let shouldPromptForDone = false;
if (ratio >= 100) {
// Get the task's current status
const taskStatusResult = await db.query(`
SELECT ts.id, stsc.is_done
FROM tasks t
JOIN task_statuses ts ON t.status_id = ts.id
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE t.id = $1
`, [taskId]);
// If the task isn't already in a "done" category, we should prompt the user
if (taskStatusResult.rows.length > 0 && !taskStatusResult.rows[0].is_done) {
shouldPromptForDone = true;
}
}
// Emit the updated progress
socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id: taskId,
progress_value: ratio,
should_prompt_for_done: shouldPromptForDone
}
);
// Find this task's parent to continue the recursive update
const parentResult = await db.query(
"SELECT parent_task_id FROM tasks WHERE id = $1",
[taskId]
);
const parentTaskId = parentResult.rows[0]?.parent_task_id;
// If there's a parent, recursively update it
if (parentTaskId) {
await updateTaskAncestors(io, socket, projectId, parentTaskId);
}
} catch (error) {
log_error(`Error updating ancestor task ${taskId}: ${error}`);
}
}
export async function on_time_estimation_change(io: Server, socket: Socket, data?: string) {
try {
// (SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent,
const q = `UPDATE tasks SET total_minutes = $2 WHERE id = $1 RETURNING total_minutes;`;
const q = `UPDATE tasks SET total_minutes = $2 WHERE id = $1 RETURNING total_minutes, project_id, parent_task_id;`;
const body = JSON.parse(data as string);
const hours = body.total_hours || 0;
@@ -19,7 +85,10 @@ export async function on_time_estimation_change(_io: Server, socket: Socket, dat
const task_data = await getTaskDetails(body.task_id, "total_minutes");
const result0 = await db.query(q, [body.task_id, totalMinutes]);
const [data0] = result0.rows;
const [taskData] = result0.rows;
const projectId = taskData.project_id;
const parentTaskId = taskData.parent_task_id;
const result = await db.query("SELECT SUM(time_spent) AS total_minutes_spent FROM task_work_log WHERE task_id = $1;", [body.task_id]);
const [dd] = result.rows;
@@ -31,6 +100,22 @@ export async function on_time_estimation_change(_io: Server, socket: Socket, dat
total_minutes_spent: dd.total_minutes_spent || 0
};
socket.emit(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), TasksController.updateTaskViewModel(d));
// If this is a subtask in time-based mode, update parent task progress
if (parentTaskId) {
const projectSettingsResult = await db.query(
"SELECT use_time_progress FROM projects WHERE id = $1",
[projectId]
);
const useTimeProgress = projectSettingsResult.rows[0]?.use_time_progress;
if (useTimeProgress) {
// Recalculate parent task progress when subtask time estimation changes
await updateTaskAncestors(io, socket, projectId, parentTaskId);
}
}
notifyProjectUpdates(socket, d.id);
logTotalMinutes({

View File

@@ -0,0 +1,177 @@
import { Socket } from "socket.io";
import db from "../../config/db";
import { SocketEvents } from "../events";
import { log, log_error, notifyProjectUpdates } from "../util";
import { logProgressChange } from "../../services/activity-logs/activity-logs.service";
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
interface UpdateTaskProgressData {
task_id: string;
progress_value: number;
parent_task_id: string | null;
}
/**
* Recursively updates all ancestor tasks' progress when a subtask changes
* @param io Socket.io instance
* @param socket Socket instance for emitting events
* @param projectId Project ID for room broadcasting
* @param taskId The task ID to update (starts with the parent task)
*/
async function updateTaskAncestors(io: any, socket: Socket, projectId: string, taskId: string | null) {
if (!taskId) return;
try {
// Use the new controller method to update the task progress
await TasksControllerV2.updateTaskProgress(taskId);
// Get the current task's progress ratio
const progressRatio = await db.query(
"SELECT get_task_complete_ratio($1) as ratio",
[taskId]
);
const ratio = progressRatio?.rows[0]?.ratio?.ratio || 0;
console.log(`Updated task ${taskId} progress: ${ratio}`);
// Check if this task needs a "done" status prompt
let shouldPromptForDone = false;
if (ratio >= 100) {
// Get the task's current status
const taskStatusResult = await db.query(`
SELECT ts.id, stsc.is_done
FROM tasks t
JOIN task_statuses ts ON t.status_id = ts.id
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE t.id = $1
`, [taskId]);
// If the task isn't already in a "done" category, we should prompt the user
if (taskStatusResult.rows.length > 0 && !taskStatusResult.rows[0].is_done) {
shouldPromptForDone = true;
}
}
// Emit the updated progress
socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id: taskId,
progress_value: ratio,
should_prompt_for_done: shouldPromptForDone
}
);
// Find this task's parent to continue the recursive update
const parentResult = await db.query(
"SELECT parent_task_id FROM tasks WHERE id = $1",
[taskId]
);
const parentTaskId = parentResult.rows[0]?.parent_task_id;
// If there's a parent, recursively update it
if (parentTaskId) {
await updateTaskAncestors(io, socket, projectId, parentTaskId);
}
} catch (error) {
log_error(`Error updating ancestor task ${taskId}: ${error}`);
}
}
export async function on_update_task_progress(io: any, socket: Socket, data: string) {
try {
const parsedData = JSON.parse(data) as UpdateTaskProgressData;
const { task_id, progress_value, parent_task_id } = parsedData;
if (!task_id || progress_value === undefined) {
return;
}
// Check if this is a parent task (has subtasks)
const subTasksResult = await db.query(
"SELECT COUNT(*) as subtask_count FROM tasks WHERE parent_task_id = $1",
[task_id]
);
const subtaskCount = parseInt(subTasksResult.rows[0]?.subtask_count || "0");
// If this is a parent task, we shouldn't set manual progress
if (subtaskCount > 0) {
log_error(`Cannot set manual progress on parent task ${task_id} with ${subtaskCount} subtasks`);
return;
}
// Get the current progress value to log the change
const currentProgressResult = await db.query(
"SELECT progress_value, project_id, status_id FROM tasks WHERE id = $1",
[task_id]
);
const currentProgress = currentProgressResult.rows[0]?.progress_value;
const projectId = currentProgressResult.rows[0]?.project_id;
const statusId = currentProgressResult.rows[0]?.status_id;
// Update the task progress in the database
await db.query(
`UPDATE tasks
SET progress_value = $1, manual_progress = true, updated_at = NOW()
WHERE id = $2`,
[progress_value, task_id]
);
// Log the progress change using the activity logs service
await logProgressChange({
task_id,
old_value: currentProgress !== null ? currentProgress.toString() : "0",
new_value: progress_value.toString(),
socket
});
if (projectId) {
// Check if progress is 100% and the task isn't already in a "done" status category
let shouldPromptForDone = false;
if (progress_value >= 100) {
// Check if the task's current status is in a "done" category
const statusCategoryResult = await db.query(`
SELECT stsc.is_done
FROM task_statuses ts
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE ts.id = $1
`, [statusId]);
// If the task isn't already in a "done" category, we should prompt the user
if (statusCategoryResult.rows.length > 0 && !statusCategoryResult.rows[0].is_done) {
shouldPromptForDone = true;
}
}
// Emit the update to all clients in the project room
socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id,
progress_value,
should_prompt_for_done: shouldPromptForDone
}
);
log(`Emitted progress update for task ${task_id} to project room ${projectId}`, null);
// If this task has a parent, use our controller to update all ancestors
if (parent_task_id) {
// Use the controller method to update the parent task's progress
await TasksControllerV2.updateTaskProgress(parent_task_id);
// Also use the existing method for socket notifications
await updateTaskAncestors(io, socket, projectId, parent_task_id);
}
// Notify that project updates are available
notifyProjectUpdates(socket, task_id);
}
} catch (error) {
log_error(error);
}
}

View File

@@ -0,0 +1,107 @@
import { Socket } from "socket.io";
import db from "../../config/db";
import { SocketEvents } from "../events";
import { log, log_error, notifyProjectUpdates } from "../util";
import { logWeightChange } from "../../services/activity-logs/activity-logs.service";
import TasksControllerV2 from "../../controllers/tasks-controller-v2";
interface UpdateTaskWeightData {
task_id: string;
weight: number;
parent_task_id: string | null;
}
export async function on_update_task_weight(io: any, socket: Socket, data: string) {
try {
const parsedData = JSON.parse(data) as UpdateTaskWeightData;
const { task_id, weight, parent_task_id } = parsedData;
if (!task_id || weight === undefined) {
return;
}
// Get the current weight value to log the change
const currentWeightResult = await db.query(
"SELECT weight, project_id FROM tasks WHERE id = $1",
[task_id]
);
const currentWeight = currentWeightResult.rows[0]?.weight;
const projectId = currentWeightResult.rows[0]?.project_id;
// Update the task weight using our controller method
await TasksControllerV2.updateTaskWeight(task_id, weight);
// Log the weight change using the activity logs service
await logWeightChange({
task_id,
old_value: currentWeight !== null ? currentWeight.toString() : "100",
new_value: weight.toString(),
socket
});
if (projectId) {
// Emit the update to all clients in the project room
socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id,
weight
}
);
// If this is a subtask, update the parent task's progress
if (parent_task_id) {
// Use the controller to update the parent task progress
await TasksControllerV2.updateTaskProgress(parent_task_id);
// Get the updated progress to emit to clients
const progressRatio = await db.query(
"SELECT get_task_complete_ratio($1) as ratio",
[parent_task_id]
);
// Emit the parent task's updated progress
socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id: parent_task_id,
progress_value: progressRatio?.rows[0]?.ratio?.ratio || 0
}
);
// We also need to update any grandparent tasks
const grandparentResult = await db.query(
"SELECT parent_task_id FROM tasks WHERE id = $1",
[parent_task_id]
);
const grandparentId = grandparentResult.rows[0]?.parent_task_id;
if (grandparentId) {
await TasksControllerV2.updateTaskProgress(grandparentId);
// Emit the grandparent's updated progress
const grandparentProgressRatio = await db.query(
"SELECT get_task_complete_ratio($1) as ratio",
[grandparentId]
);
socket.emit(
SocketEvents.TASK_PROGRESS_UPDATED.toString(),
{
task_id: grandparentId,
progress_value: grandparentProgressRatio?.rows[0]?.ratio?.ratio || 0
}
);
}
}
// Notify that project updates are available
notifyProjectUpdates(socket, task_id);
}
} catch (error) {
log_error(error);
}
}

View File

@@ -57,4 +57,17 @@ export enum SocketEvents {
TASK_ASSIGNEES_CHANGE,
TASK_CUSTOM_COLUMN_UPDATE,
CUSTOM_COLUMN_PINNED_CHANGE,
TEAM_MEMBER_ROLE_CHANGE,
// Task progress events
UPDATE_TASK_PROGRESS,
UPDATE_TASK_WEIGHT,
TASK_PROGRESS_UPDATED,
// Task subtasks count events
GET_TASK_SUBTASKS_COUNT,
TASK_SUBTASKS_COUNT,
// Task completion events
GET_DONE_STATUSES,
}

View File

@@ -52,6 +52,10 @@ import { on_task_recurring_change } from "./commands/on-task-recurring-change";
import { on_task_assignees_change } from "./commands/on-task-assignees-change";
import { on_task_custom_column_update } from "./commands/on_custom_column_update";
import { on_custom_column_pinned_change } from "./commands/on_custom_column_pinned_change";
import { on_update_task_progress } from "./commands/on-update-task-progress";
import { on_update_task_weight } from "./commands/on-update-task-weight";
import { on_get_task_subtasks_count } from "./commands/on-get-task-subtasks-count";
import { on_get_done_statuses } from "./commands/on-get-done-statuses";
export function register(io: any, socket: Socket) {
log(socket.id, "client registered");
@@ -69,7 +73,6 @@ export function register(io: any, socket: Socket) {
socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), data => on_time_estimation_change(io, socket, data));
socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), data => on_task_description_change(io, socket, data));
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), data => on_get_task_progress(io, socket, data));
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), data => on_get_task_progress(io, socket, data));
socket.on(SocketEvents.TASK_TIMER_START.toString(), data => on_task_timer_start(io, socket, data));
socket.on(SocketEvents.TASK_TIMER_STOP.toString(), data => on_task_timer_stop(io, socket, data));
socket.on(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), data => on_task_sort_order_change(io, socket, data));
@@ -106,6 +109,10 @@ export function register(io: any, socket: Socket) {
socket.on(SocketEvents.TASK_ASSIGNEES_CHANGE.toString(), data => on_task_assignees_change(io, socket, data));
socket.on(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), data => on_task_custom_column_update(io, socket, data));
socket.on(SocketEvents.CUSTOM_COLUMN_PINNED_CHANGE.toString(), data => on_custom_column_pinned_change(io, socket, data));
socket.on(SocketEvents.UPDATE_TASK_PROGRESS.toString(), data => on_update_task_progress(io, socket, data));
socket.on(SocketEvents.UPDATE_TASK_WEIGHT.toString(), data => on_update_task_weight(io, socket, data));
socket.on(SocketEvents.GET_TASK_SUBTASKS_COUNT.toString(), (taskId) => on_get_task_subtasks_count(io, socket, taskId));
socket.on(SocketEvents.GET_DONE_STATUSES.toString(), (projectId, callback) => on_get_done_statuses(io, socket, projectId, callback));
// socket.io built-in event
socket.on("disconnect", (reason) => on_disconnect(io, socket, reason));