diff --git a/docker-compose.yaml b/docker-compose.yaml index fdd16fbb8..f9856f287 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,7 @@ services: # PostgreSQL Database postgres: - image: postgres:15-alpine + image: postgres:15.10-alpine container_name: worklenz-postgres restart: unless-stopped environment: @@ -27,7 +27,7 @@ services: # Database Backup Service db-backup: - image: postgres:15-alpine + image: postgres:15.10-alpine container_name: worklenz-db-backup restart: unless-stopped depends_on: @@ -80,7 +80,7 @@ services: # Redis Cache (Express mode - default) redis: - image: redis:7-alpine + image: redis:7.4-alpine container_name: worklenz-redis restart: unless-stopped command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-worklenz_redis_pass} @@ -152,7 +152,7 @@ services: # Backend API backend: - image: chamikajaycey/worklenz-backend:latest + image: chamikajaycey/worklenz-backend:${BACKEND_VERSION:-2.1.6} build: context: ./worklenz-backend dockerfile: Dockerfile @@ -240,7 +240,7 @@ services: # Frontend Application frontend: - image: chamikajaycey/worklenz-frontend:latest + image: chamikajaycey/worklenz-frontend:${FRONTEND_VERSION:-2.1.6} build: context: ./worklenz-frontend dockerfile: Dockerfile @@ -272,7 +272,7 @@ services: # Nginx Reverse Proxy nginx: - image: nginx:alpine + image: nginx:1.27-alpine container_name: worklenz-nginx restart: unless-stopped depends_on: @@ -303,7 +303,7 @@ services: # Certbot for Let's Encrypt SSL certbot: - image: certbot/certbot:latest + image: certbot/certbot:v3.1.0 container_name: worklenz-certbot restart: unless-stopped volumes: diff --git a/manage.sh b/manage.sh index bf24eb63a..a5634833b 100755 --- a/manage.sh +++ b/manage.sh @@ -797,21 +797,49 @@ build_images() { rm -f "$ENV_FILE.bak" fi - # Image version - always use latest - local image_version="latest" + # Get version numbers from environment or prompt + local backend_version="${BACKEND_VERSION:-}" + local frontend_version="${FRONTEND_VERSION:-}" + + echo "" + echo -e "${YELLOW}Version Configuration${NC}" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + + if [ -z "$backend_version" ]; then + read -p "Enter backend version (e.g., 2.1.5) [latest]: " backend_version + backend_version=${backend_version:-latest} + fi + + if [ -z "$frontend_version" ]; then + read -p "Enter frontend version (e.g., 2.1.5) [latest]: " frontend_version + frontend_version=${frontend_version:-latest} + fi echo "" print_info "Docker Hub Username: $docker_username" - print_info "Image Version: $image_version" + print_info "Backend Version: $backend_version" + print_info "Frontend Version: $frontend_version" echo "" # Build backend image print_info "Building backend image..." echo "" - docker build \ - -f "$SCRIPT_DIR/worklenz-backend/Dockerfile" \ - -t "${docker_username}/worklenz-backend:${image_version}" \ - "$SCRIPT_DIR/worklenz-backend" + + if [ "$backend_version" == "latest" ]; then + # Build only with latest tag + docker build \ + -f "$SCRIPT_DIR/worklenz-backend/Dockerfile" \ + -t "${docker_username}/worklenz-backend:latest" \ + "$SCRIPT_DIR/worklenz-backend" + else + # Build with both version tag and latest tag + docker build \ + -f "$SCRIPT_DIR/worklenz-backend/Dockerfile" \ + -t "${docker_username}/worklenz-backend:${backend_version}" \ + -t "${docker_username}/worklenz-backend:latest" \ + "$SCRIPT_DIR/worklenz-backend" + fi if [ $? -ne 0 ]; then print_error "Backend build failed!" @@ -819,15 +847,32 @@ build_images() { fi print_success "Backend image built successfully!" + if [ "$backend_version" != "latest" ]; then + print_info "Tagged as: ${docker_username}/worklenz-backend:${backend_version}" + print_info "Tagged as: ${docker_username}/worklenz-backend:latest" + else + print_info "Tagged as: ${docker_username}/worklenz-backend:latest" + fi echo "" # Build frontend image print_info "Building frontend image..." echo "" - docker build \ - -f "$SCRIPT_DIR/worklenz-frontend/Dockerfile" \ - -t "${docker_username}/worklenz-frontend:${image_version}" \ - "$SCRIPT_DIR/worklenz-frontend" + + if [ "$frontend_version" == "latest" ]; then + # Build only with latest tag + docker build \ + -f "$SCRIPT_DIR/worklenz-frontend/Dockerfile" \ + -t "${docker_username}/worklenz-frontend:latest" \ + "$SCRIPT_DIR/worklenz-frontend" + else + # Build with both version tag and latest tag + docker build \ + -f "$SCRIPT_DIR/worklenz-frontend/Dockerfile" \ + -t "${docker_username}/worklenz-frontend:${frontend_version}" \ + -t "${docker_username}/worklenz-frontend:latest" \ + "$SCRIPT_DIR/worklenz-frontend" + fi if [ $? -ne 0 ]; then print_error "Frontend build failed!" @@ -835,16 +880,22 @@ build_images() { fi print_success "Frontend image built successfully!" + if [ "$frontend_version" != "latest" ]; then + print_info "Tagged as: ${docker_username}/worklenz-frontend:${frontend_version}" + print_info "Tagged as: ${docker_username}/worklenz-frontend:latest" + else + print_info "Tagged as: ${docker_username}/worklenz-frontend:latest" + fi echo "" # Update docker-compose.yaml to use the new images print_info "Updating docker-compose.yaml..." - # Update backend image - sed -i.bak "s|image: worklenz-backend:.*|image: ${docker_username}/worklenz-backend:${image_version}|" "$DOCKER_COMPOSE_FILE" + # Update backend image in docker-compose.yaml to match built version + sed -i.bak "s|image: .*/worklenz-backend:\${BACKEND_VERSION:-.*}|image: ${docker_username}/worklenz-backend:\${BACKEND_VERSION:-${backend_version}}|" "$DOCKER_COMPOSE_FILE" - # Update frontend image - sed -i.bak "s|image: worklenz-frontend:.*|image: ${docker_username}/worklenz-frontend:${image_version}|" "$DOCKER_COMPOSE_FILE" + # Update frontend image in docker-compose.yaml to match built version + sed -i.bak "s|image: .*/worklenz-frontend:\${FRONTEND_VERSION:-.*}|image: ${docker_username}/worklenz-frontend:\${FRONTEND_VERSION:-${frontend_version}}|" "$DOCKER_COMPOSE_FILE" rm -f "$DOCKER_COMPOSE_FILE.bak" @@ -852,8 +903,17 @@ build_images() { print_success "Images built successfully!" echo "" echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - echo -e " ${GREEN}Backend Image:${NC} ${docker_username}/worklenz-backend:${image_version}" - echo -e " ${GREEN}Frontend Image:${NC} ${docker_username}/worklenz-frontend:${image_version}" + if [ "$backend_version" != "latest" ]; then + echo -e " ${GREEN}Backend Images:${NC}" + echo -e " - ${docker_username}/worklenz-backend:${backend_version}" + echo -e " - ${docker_username}/worklenz-backend:latest" + echo -e " ${GREEN}Frontend Images:${NC}" + echo -e " - ${docker_username}/worklenz-frontend:${frontend_version}" + echo -e " - ${docker_username}/worklenz-frontend:latest" + else + echo -e " ${GREEN}Backend Image:${NC} ${docker_username}/worklenz-backend:latest" + echo -e " ${GREEN}Frontend Image:${NC} ${docker_username}/worklenz-frontend:latest" + fi echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo "" @@ -875,34 +935,49 @@ build_images() { return 1 fi - # Push backend image - print_info "Pushing backend image..." - docker push "${docker_username}/worklenz-backend:${image_version}" - + # Push backend images + print_info "Pushing backend images..." + if [ "$backend_version" != "latest" ]; then + docker push "${docker_username}/worklenz-backend:${backend_version}" + if [ $? -ne 0 ]; then + print_error "Backend image push failed!" + return 1 + fi + fi + docker push "${docker_username}/worklenz-backend:latest" if [ $? -ne 0 ]; then - print_error "Backend image push failed!" + print_error "Backend latest image push failed!" return 1 fi - print_success "Backend image pushed successfully!" + print_success "Backend images pushed successfully!" echo "" - # Push frontend image - print_info "Pushing frontend image..." - docker push "${docker_username}/worklenz-frontend:${image_version}" - + # Push frontend images + print_info "Pushing frontend images..." + if [ "$frontend_version" != "latest" ]; then + docker push "${docker_username}/worklenz-frontend:${frontend_version}" + if [ $? -ne 0 ]; then + print_error "Frontend image push failed!" + return 1 + fi + fi + docker push "${docker_username}/worklenz-frontend:latest" if [ $? -ne 0 ]; then - print_error "Frontend image push failed!" + print_error "Frontend latest image push failed!" return 1 fi - print_success "Frontend image pushed successfully!" + print_success "Frontend images pushed successfully!" echo "" print_success "Images are now available on Docker Hub!" echo "" echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e " ${GREEN}Backend:${NC} https://hub.docker.com/r/${docker_username}/worklenz-backend" echo -e " ${GREEN}Frontend:${NC} https://hub.docker.com/r/${docker_username}/worklenz-frontend" + if [ "$backend_version" != "latest" ]; then + echo -e " ${GREEN}Tags:${NC} ${backend_version}, latest (backend) | ${frontend_version}, latest (frontend)" + fi echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" else print_info "Images built locally only (not pushed to Docker Hub)" @@ -948,11 +1023,29 @@ push_images() { return 1 fi - local image_version="latest" + # Get version numbers from environment or prompt + local backend_version="${BACKEND_VERSION:-}" + local frontend_version="${FRONTEND_VERSION:-}" + + echo "" + echo -e "${YELLOW}Version Configuration${NC}" + echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + + if [ -z "$backend_version" ]; then + read -p "Enter backend version to push (e.g., 2.1.5) [latest]: " backend_version + backend_version=${backend_version:-latest} + fi + + if [ -z "$frontend_version" ]; then + read -p "Enter frontend version to push (e.g., 2.1.5) [latest]: " frontend_version + frontend_version=${frontend_version:-latest} + fi echo "" print_info "Docker Hub Username: $docker_username" - print_info "Image Version: $image_version" + print_info "Backend Version: $backend_version" + print_info "Frontend Version: $frontend_version" echo "" # Check if images exist locally @@ -982,32 +1075,70 @@ push_images() { print_success "Docker Hub login successful!" echo "" - # Push backend image - print_info "Pushing backend image to Docker Hub..." - docker push "${docker_username}/worklenz-backend:${image_version}" - + # Push backend images + print_info "Pushing backend images to Docker Hub..." + if [ "$backend_version" != "latest" ]; then + # Verify version-tagged image exists locally + if ! docker image inspect "${docker_username}/worklenz-backend:${backend_version}" >/dev/null 2>&1; then + print_error "Image ${docker_username}/worklenz-backend:${backend_version} not found locally. Please build it first." + return 1 + fi + print_info "Pushing ${docker_username}/worklenz-backend:${backend_version}..." + docker push "${docker_username}/worklenz-backend:${backend_version}" + if [ $? -ne 0 ]; then + print_error "Backend version push failed!" + return 1 + fi + fi + # Verify latest image exists locally + if ! docker image inspect "${docker_username}/worklenz-backend:latest" >/dev/null 2>&1; then + print_error "Image ${docker_username}/worklenz-backend:latest not found locally. Please build it first." + return 1 + fi + print_info "Pushing ${docker_username}/worklenz-backend:latest..." + docker push "${docker_username}/worklenz-backend:latest" if [ $? -ne 0 ]; then - print_error "Backend push failed!" + print_error "Backend latest push failed!" return 1 fi - print_success "Backend image pushed successfully!" + print_success "Backend images pushed successfully!" echo "" - # Push frontend image - print_info "Pushing frontend image to Docker Hub..." - docker push "${docker_username}/worklenz-frontend:${image_version}" - + # Push frontend images + print_info "Pushing frontend images to Docker Hub..." + if [ "$frontend_version" != "latest" ]; then + # Verify version-tagged image exists locally + if ! docker image inspect "${docker_username}/worklenz-frontend:${frontend_version}" >/dev/null 2>&1; then + print_error "Image ${docker_username}/worklenz-frontend:${frontend_version} not found locally. Please build it first." + return 1 + fi + print_info "Pushing ${docker_username}/worklenz-frontend:${frontend_version}..." + docker push "${docker_username}/worklenz-frontend:${frontend_version}" + if [ $? -ne 0 ]; then + print_error "Frontend version push failed!" + return 1 + fi + fi + print_info "Pushing ${docker_username}/worklenz-frontend:latest..." + docker push "${docker_username}/worklenz-frontend:latest" if [ $? -ne 0 ]; then - print_error "Frontend push failed!" + print_error "Frontend latest push failed!" return 1 fi - print_success "Frontend image pushed successfully!" + print_success "Frontend images pushed successfully!" echo "" echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - echo -e " ${GREEN}Backend Image:${NC} ${docker_username}/worklenz-backend:${image_version}" - echo -e " ${GREEN}Frontend Image:${NC} ${docker_username}/worklenz-frontend:${image_version}" + echo -e " ${GREEN}Backend:${NC} https://hub.docker.com/r/${docker_username}/worklenz-backend" + echo -e " ${GREEN}Frontend:${NC} https://hub.docker.com/r/${docker_username}/worklenz-frontend" + if [ "$backend_version" != "latest" ]; then + echo -e " ${GREEN}Pushed Tags:${NC}" + echo -e " Backend: ${backend_version}, latest" + echo -e " Frontend: ${frontend_version}, latest" + else + echo -e " ${GREEN}Pushed Tags:${NC} latest" + fi echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" } @@ -1121,15 +1252,25 @@ install_worklenz() { fi fi - local image_version="latest" + # Get version numbers + local backend_version="${BACKEND_VERSION:-latest}" + local frontend_version="${FRONTEND_VERSION:-latest}" if [ -n "$docker_username" ]; then # Build backend image - print_info "Building backend image..." - docker build \ - -f "$SCRIPT_DIR/worklenz-backend/Dockerfile" \ - -t "${docker_username}/worklenz-backend:${image_version}" \ - "$SCRIPT_DIR/worklenz-backend" + print_info "Building backend image (version: ${backend_version})..." + if [ "$backend_version" == "latest" ]; then + docker build \ + -f "$SCRIPT_DIR/worklenz-backend/Dockerfile" \ + -t "${docker_username}/worklenz-backend:latest" \ + "$SCRIPT_DIR/worklenz-backend" + else + docker build \ + -f "$SCRIPT_DIR/worklenz-backend/Dockerfile" \ + -t "${docker_username}/worklenz-backend:${backend_version}" \ + -t "${docker_username}/worklenz-backend:latest" \ + "$SCRIPT_DIR/worklenz-backend" + fi if [ $? -ne 0 ]; then print_error "Backend build failed!" @@ -1140,11 +1281,19 @@ install_worklenz() { echo "" # Build frontend image - print_info "Building frontend image..." - docker build \ - -f "$SCRIPT_DIR/worklenz-frontend/Dockerfile" \ - -t "${docker_username}/worklenz-frontend:${image_version}" \ - "$SCRIPT_DIR/worklenz-frontend" + print_info "Building frontend image (version: ${frontend_version})..." + if [ "$frontend_version" == "latest" ]; then + docker build \ + -f "$SCRIPT_DIR/worklenz-frontend/Dockerfile" \ + -t "${docker_username}/worklenz-frontend:latest" \ + "$SCRIPT_DIR/worklenz-frontend" + else + docker build \ + -f "$SCRIPT_DIR/worklenz-frontend/Dockerfile" \ + -t "${docker_username}/worklenz-frontend:${frontend_version}" \ + -t "${docker_username}/worklenz-frontend:latest" \ + "$SCRIPT_DIR/worklenz-frontend" + fi if [ $? -ne 0 ]; then print_error "Frontend build failed!" @@ -1156,10 +1305,8 @@ install_worklenz() { # Update docker-compose.yaml print_info "Updating docker-compose.yaml..." - sed -i.bak "s|image: worklenz-backend:.*|image: ${docker_username}/worklenz-backend:${image_version}|" "$DOCKER_COMPOSE_FILE" - sed -i.bak "s|image: worklenz-frontend:.*|image: ${docker_username}/worklenz-frontend:${image_version}|" "$DOCKER_COMPOSE_FILE" - sed -i.bak "s|image: .*/worklenz-backend:.*|image: ${docker_username}/worklenz-backend:${image_version}|" "$DOCKER_COMPOSE_FILE" - sed -i.bak "s|image: .*/worklenz-frontend:.*|image: ${docker_username}/worklenz-frontend:${image_version}|" "$DOCKER_COMPOSE_FILE" + sed -i.bak "s|image: .*/worklenz-backend:\${BACKEND_VERSION:-.*}|image: ${docker_username}/worklenz-backend:\${BACKEND_VERSION:-${backend_version}}|" "$DOCKER_COMPOSE_FILE" + sed -i.bak "s|image: .*/worklenz-frontend:\${FRONTEND_VERSION:-.*}|image: ${docker_username}/worklenz-frontend:\${FRONTEND_VERSION:-${frontend_version}}|" "$DOCKER_COMPOSE_FILE" rm -f "$DOCKER_COMPOSE_FILE.bak" print_success "Custom images ready!" @@ -1172,11 +1319,35 @@ install_worklenz() { docker login if [ $? -eq 0 ]; then - print_info "Pushing backend image..." - docker push "${docker_username}/worklenz-backend:${image_version}" - - print_info "Pushing frontend image..." - docker push "${docker_username}/worklenz-frontend:${image_version}" + if [ "$backend_version" != "latest" ]; then + print_info "Pushing backend image (${backend_version})..." + docker push "${docker_username}/worklenz-backend:${backend_version}" + if [ $? -ne 0 ]; then + print_error "Failed to push backend image (${backend_version})" + return 1 + fi + fi + print_info "Pushing backend image (latest)..." + docker push "${docker_username}/worklenz-backend:latest" + if [ $? -ne 0 ]; then + print_error "Failed to push backend image (latest)" + return 1 + fi + + if [ "$frontend_version" != "latest" ]; then + print_info "Pushing frontend image (${frontend_version})..." + docker push "${docker_username}/worklenz-frontend:${frontend_version}" + if [ $? -ne 0 ]; then + print_error "Failed to push frontend image (${frontend_version})" + return 1 + fi + fi + print_info "Pushing frontend image (latest)..." + docker push "${docker_username}/worklenz-frontend:latest" + if [ $? -ne 0 ]; then + print_error "Failed to push frontend image (latest)" + return 1 + fi print_success "Images pushed to Docker Hub!" fi diff --git a/worklenz-backend/src/controllers/project-categories-controller.ts b/worklenz-backend/src/controllers/project-categories-controller.ts index 220355a5f..f60dc4dae 100644 --- a/worklenz-backend/src/controllers/project-categories-controller.ts +++ b/worklenz-backend/src/controllers/project-categories-controller.ts @@ -6,29 +6,72 @@ import { ServerResponse } from "../models/server-response"; import WorklenzControllerBase from "./worklenz-controller-base"; import HandleExceptions from "../decorators/handle-exceptions"; import { getColor } from "../shared/utils"; -import { WorklenzColorCodes } from "../shared/constants"; +import { WorklenzColorShades } from "../shared/constants"; +import { SqlHelper } from "../shared/sql-helpers"; export default class ProjectCategoriesController extends WorklenzControllerBase { - private static flatString(text: string) { - return (text || "").split(",").map(s => `'${s}'`).join(","); - } - @HandleExceptions() - public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async create( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + // Validate name + const name = + typeof req.body.name === "string" ? req.body.name.trim() : undefined; + if (!name || name.length === 0) { + return res + .status(400) + .send(new ServerResponse(false, "Category name is required.")); + } + const q = ` INSERT INTO project_categories (name, team_id, created_by, color_code) VALUES ($1, $2, $3, $4) RETURNING id, name, color_code; `; - const name = req.body.name.trim(); - const result = await db.query(q, [name, req.user?.team_id, req.user?.id, name ? getColor(name) : null]); + + // Validate and use provided color_code, or fall back to generated color + let colorCode: string | null = null; + if (req.body.color_code) { + // Validate color - accept both base colors and all shade variations + const validColors = [ + ...Object.keys(WorklenzColorShades), + ...Object.values(WorklenzColorShades).flat(), + ].map((c) => c.toLowerCase()); + + const providedColor = req.body.color_code.trim().toLowerCase(); + if (validColors.includes(providedColor)) { + // Find the original case color from the valid colors + const allColors = [ + ...Object.keys(WorklenzColorShades), + ...Object.values(WorklenzColorShades).flat(), + ]; + colorCode = allColors.find(c => c.toLowerCase() === providedColor) || providedColor; + } else { + // Invalid color provided, fall back to generated color + colorCode = name ? getColor(name) : null; + } + } else { + // No color provided, generate one + colorCode = name ? getColor(name) : null; + } + + const result = await db.query(q, [ + name, + req.user?.team_id, + req.user?.id, + colorCode, + ]); const [data] = result.rows; return res.status(200).send(new ServerResponse(true, data)); } @HandleExceptions() - public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async get( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const q = ` SELECT id, name, color_code, (SELECT COUNT(*) FROM projects WHERE category_id = project_categories.id) AS usage FROM project_categories @@ -39,7 +82,10 @@ export default class ProjectCategoriesController extends WorklenzControllerBase } @HandleExceptions() - public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async getById( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const q = ` SELECT id, name, color_code, (SELECT COUNT(*) FROM projects WHERE category_id = project_categories.id) AS usage FROM project_categories @@ -47,7 +93,7 @@ export default class ProjectCategoriesController extends WorklenzControllerBase const result = await db.query(q, [req.params.id]); return res.status(200).send(new ServerResponse(true, result.rows)); } - + private static async getTeamsByOrg(teamId: string) { const q = `SELECT id FROM teams WHERE in_organization(id, $1)`; const result = await db.query(q, [teamId]); @@ -55,36 +101,74 @@ export default class ProjectCategoriesController extends WorklenzControllerBase } @HandleExceptions() - public static async getByMultipleTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - + public static async getByMultipleTeams( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const teams = await this.getTeamsByOrg(req.user?.team_id as string); - const teamIds = teams.map(team => team.id).join(","); + const teamIds = teams.map((team) => team.id); - const q = `SELECT id, name, color_code FROM project_categories WHERE team_id IN (${this.flatString(teamIds)});`; + // Handle empty teams array - return empty result + if (teamIds.length === 0) { + return res.status(200).send(new ServerResponse(true, [])); + } - const result = await db.query(q); - return res.status(200).send(new ServerResponse(true, result.rows)); + const { clause, params } = SqlHelper.buildInClause(teamIds, 1); + + const q = `SELECT id, name, color_code FROM project_categories WHERE team_id IN (${clause})`; + const result = await db.query(q, params); + return res.status(200).send(new ServerResponse(true, result.rows)); } @HandleExceptions() - public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async update( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { + // Validate color type first + if (typeof req.body.color !== 'string' || !req.body.color) { + return res.status(400).send(new ServerResponse(false, "Invalid color")); + } + + // Validate color - accept both base colors and all shade variations + const validColors = [ + ...Object.keys(WorklenzColorShades), + ...Object.values(WorklenzColorShades).flat(), + ].map((c) => c.toLowerCase()); + if (!validColors.includes(req.body.color.toLowerCase())) { + return res.status(400).send(new ServerResponse(false, "Invalid color")); + } + + // Validate name + const name = + typeof req.body.name === "string" ? req.body.name.trim() : undefined; + if (!name || name.length === 0) { + return res + .status(400) + .send(new ServerResponse(false, "Category name is required.")); + } + const q = ` UPDATE project_categories - SET color_code = $2 + SET name = $2, color_code = $3 WHERE id = $1 - AND team_id = $3; + AND team_id = $4; `; - - if (!WorklenzColorCodes.includes(req.body.color)) - return res.status(400).send(new ServerResponse(false, null)); - - const result = await db.query(q, [req.params.id, req.body.color, req.user?.team_id]); + const result = await db.query(q, [ + req.params.id, + name, + req.body.color, + req.user?.team_id, + ]); return res.status(200).send(new ServerResponse(true, result.rows)); } @HandleExceptions() - public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + public static async deleteById( + req: IWorkLenzRequest, + res: IWorkLenzResponse + ): Promise { const q = ` DELETE FROM project_categories 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 e87542d5e..e9ee68c26 100644 --- a/worklenz-backend/src/controllers/project-templates/pt-tasks-controller.ts +++ b/worklenz-backend/src/controllers/project-templates/pt-tasks-controller.ts @@ -5,6 +5,7 @@ import HandleExceptions from "../../decorators/handle-exceptions"; import { IWorkLenzRequest } from "../../interfaces/worklenz-request"; import { IWorkLenzResponse } from "../../interfaces/worklenz-response"; import { ServerResponse } from "../../models/server-response"; +import { SqlHelper } from "../../shared/sql-helpers"; import { TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants"; import { getColor } from "../../shared/utils"; import PtTasksControllerBase, { GroupBy, ITaskGroup } from "./pt-tasks-controller-base"; @@ -33,14 +34,6 @@ export default class PtTasksController extends PtTasksControllerBase { return PtTasksController.isCountsOnly(query) || query.parent_task; } - private static flatString(text: string) { - return (text || "").split(" ").map(s => `'${s}'`).join(","); - } - - private static getFilterByTemplatsWhereClosure(text: string) { - return text ? `template_id IN (${this.flatString(text)})` : ""; - } - private static getQuery(userId: string, options: ParsedQs) { const searchField = options.search ? "cptt.name" : "sort_order"; 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 982ba0a9a..1e76a7c3a 100644 --- a/worklenz-backend/src/controllers/project-workload/workload-gannt-controller.ts +++ b/worklenz-backend/src/controllers/project-workload/workload-gannt-controller.ts @@ -6,6 +6,7 @@ import HandleExceptions from "../../decorators/handle-exceptions"; import { IWorkLenzRequest } from "../../interfaces/worklenz-request"; import { IWorkLenzResponse } from "../../interfaces/worklenz-response"; import { ServerResponse } from "../../models/server-response"; +import { SqlHelper } from "../../shared/sql-helpers"; import { TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants"; import { getColor } from "../../shared/utils"; import WLTasksControllerBase, { GroupBy, IWLTaskGroup } from "./workload-gannt-base"; @@ -137,18 +138,29 @@ export default class WorkloadGanntController extends WLTasksControllerBase { const today = new Date(); - let startDate = moment(today).clone().startOf("month"); - let endDate = moment(today).clone().endOf("month"); + // Use provided date parameters if available, otherwise use existing logic + let startDate: moment.Moment; + let endDate: moment.Moment; - this.setChartStartEnd(dateRange, logRange, req.query.timeZone as string); - - if (dateRange.start_date && dateRange.end_date) { - startDate = this.validateStartDate(moment(dateRange.start_date)) ? moment(dateRange.start_date).startOf("month") : moment(today).clone().startOf("month"); - endDate = this.validateEndDate(moment(dateRange.end_date)) ? moment(today).clone().endOf("month") : moment(dateRange.end_date).endOf("month"); - } else if (dateRange.start_date && !dateRange.end_date) { - startDate = this.validateStartDate(moment(dateRange.start_date)) ? moment(dateRange.start_date).startOf("month") : moment(today).clone().startOf("month"); - } else if (!dateRange.start_date && dateRange.end_date) { - endDate = this.validateEndDate(moment(dateRange.end_date)) ? moment(today).clone().endOf("month") : moment(dateRange.end_date).endOf("month"); + if (req.query.start_date && req.query.end_date) { + // Use provided date range directly + startDate = moment(req.query.start_date as string); + endDate = moment(req.query.end_date as string); + } else { + // Fall back to existing complex logic + startDate = moment(today).clone().startOf("month"); + endDate = moment(today).clone().endOf("month"); + + this.setChartStartEnd(dateRange, logRange, req.query.timeZone as string); + + if (dateRange.start_date && dateRange.end_date) { + startDate = this.validateStartDate(moment(dateRange.start_date)) ? moment(dateRange.start_date).startOf("month") : moment(today).clone().startOf("month"); + endDate = this.validateEndDate(moment(dateRange.end_date)) ? moment(today).clone().endOf("month") : moment(dateRange.end_date).endOf("month"); + } else if (dateRange.start_date && !dateRange.end_date) { + startDate = this.validateStartDate(moment(dateRange.start_date)) ? moment(dateRange.start_date).startOf("month") : moment(today).clone().startOf("month"); + } else if (!dateRange.start_date && dateRange.end_date) { + endDate = this.validateEndDate(moment(dateRange.end_date)) ? moment(today).clone().endOf("month") : moment(dateRange.end_date).endOf("month"); + } } const xMonthsBeforeStart = startDate.clone().subtract(1, "months"); @@ -220,7 +232,9 @@ export default class WorkloadGanntController extends WLTasksControllerBase { @HandleExceptions() public static async getMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const expandedMembers: string[] = req.body.expanded_members; + const expandedMembers: string[] = req.body?.expanded_members || req.query?.expanded_members || []; + const startDate: string | undefined = req.query?.start_date as string; + const endDate: string | undefined = req.query?.end_date as string; const q = `SELECT pm.id AS project_member_id, tmiv.team_member_id, @@ -228,6 +242,14 @@ export default class WorkloadGanntController extends WLTasksControllerBase { name AS name, avatar_url, TRUE AS project_member, + + -- Organization working settings + (SELECT working_hours FROM organizations WHERE id = (SELECT organization_id FROM teams WHERE id = (SELECT team_id FROM team_members WHERE id = tmiv.team_member_id))) AS org_working_hours, + (SELECT ROW_TO_JSON(wd) FROM ( + SELECT monday, tuesday, wednesday, thursday, friday, saturday, sunday + FROM organization_working_days + WHERE organization_id = (SELECT organization_id FROM teams WHERE id = (SELECT team_id FROM team_members WHERE id = tmiv.team_member_id)) + ) wd) AS org_working_days, (SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) FROM (SELECT MIN(LEAST(start_date, end_date)) AS min_date, @@ -236,25 +258,65 @@ export default class WorkloadGanntController extends WLTasksControllerBase { INNER JOIN tasks_assignees ta ON tasks.id = ta.task_id WHERE archived IS FALSE AND project_id = $1 - AND ta.team_member_id = tmiv.team_member_id) rec) AS duration, + AND ta.team_member_id = tmiv.team_member_id + ${WorkloadGanntController.getTaskDateRangeFilter(startDate, endDate)}) rec) AS duration, (SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) FROM (SELECT MIN(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS min_date, - MAX(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS max_date + MAX(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS max_date, + SUM(twl.time_spent) AS total_time_spent_seconds FROM task_work_log twl INNER JOIN tasks t ON twl.task_id = t.id AND t.archived IS FALSE WHERE t.project_id = $1 - AND twl.user_id = tmiv.user_id) rec) AS logs_date_union, + AND twl.user_id = tmiv.user_id + ${WorkloadGanntController.getLogDateRangeFilter(startDate, endDate)}) rec) AS logs_date_union, (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) - FROM (SELECT start_date, - end_date + FROM ( + -- Tasks with start/end dates + SELECT tasks.id AS task_id, + tasks.name AS task_name, + start_date, + end_date, + (SELECT name FROM task_statuses WHERE id = tasks.status_id) AS status_name, + (SELECT color_code FROM sys_task_status_categories WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color, + (SELECT name FROM task_priorities WHERE id = tasks.priority_id) AS priority_name, + (SELECT color_code FROM task_priorities WHERE id = tasks.priority_id) AS priority_color, + NULL::NUMERIC AS logged_hours, + 'task' AS entry_type FROM tasks INNER JOIN tasks_assignees ta ON tasks.id = ta.task_id WHERE archived IS FALSE AND project_id = pm.project_id AND ta.team_member_id = tmiv.team_member_id - ORDER BY start_date ASC) rec) AS tasks + ${WorkloadGanntController.getTaskDateRangeFilter(startDate, endDate)} + + UNION ALL + + -- Time logs as single-day entries + SELECT DISTINCT ON (twl.created_at::date, t.id) + t.id AS task_id, + t.name AS task_name, + twl.created_at::date AS start_date, + twl.created_at::date AS end_date, + (SELECT name FROM task_statuses WHERE id = t.status_id) AS status_name, + (SELECT color_code FROM sys_task_status_categories WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color, + (SELECT name FROM task_priorities WHERE id = t.priority_id) AS priority_name, + (SELECT color_code FROM task_priorities WHERE id = t.priority_id) AS priority_color, + SUM(twl.time_spent / 3600.0) OVER (PARTITION BY twl.created_at::date, t.id) AS logged_hours, + 'time_log' AS entry_type + FROM task_work_log twl + INNER JOIN tasks t ON twl.task_id = t.id + INNER JOIN tasks_assignees ta ON t.id = ta.task_id + WHERE t.archived IS FALSE + AND t.project_id = pm.project_id + AND ta.team_member_id = tmiv.team_member_id + AND twl.user_id = tmiv.user_id + ${startDate ? `AND twl.created_at::date >= '${startDate}'` : ''} + ${endDate ? `AND twl.created_at::date <= '${endDate}'` : ''} + + ORDER BY start_date ASC + ) rec) AS tasks FROM project_members pm INNER JOIN team_member_info_view tmiv ON pm.team_member_id = tmiv.team_member_id WHERE project_id = $1 @@ -268,7 +330,19 @@ export default class WorkloadGanntController extends WLTasksControllerBase { const result = await db.query(q, [req.params.id]); for (const member of result.rows) { - member.color_code = getColor(member.TaskName); + 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 || { + monday: true, + tuesday: true, + wednesday: true, + thursday: true, + friday: true, + saturday: false, + sunday: false + }; this.setMaxMinDate(member, req.query.timeZone as string); @@ -382,33 +456,53 @@ export default class WorkloadGanntController extends WLTasksControllerBase { return WorkloadGanntController.isCountsOnly(query) || query.parent_task; } - private static flatString(text: string) { - return (text || "").split(" ").map(s => `'${s}'`).join(","); + private static getFilterByMembersWhereClosure(text: string, paramOffset: number): { clause: string; params: string[] } { + if (!text) return { clause: "", params: [] }; + const memberIds = text.split(" ").filter(id => id.trim()); + const { clause } = SqlHelper.buildInClause(memberIds, paramOffset); + return { + clause: `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id IN (${clause}))`, + params: memberIds + }; } - private static getFilterByDatesWhereClosure(text: string) { - let closure = ""; - switch (text.trim()) { - case "": - closure = ``; - break; - case WorkloadGanntController.TASKS_START_DATE_NULL_FILTER: - closure = `start_date IS NULL AND end_date IS NOT NULL`; - break; - case WorkloadGanntController.TASKS_END_DATE_NULL_FILTER: - closure = `start_date IS NOT NULL AND end_date IS NULL`; - break; - case WorkloadGanntController.TASKS_START_END_DATES_NULL_FILTER: - closure = `start_date IS NULL AND end_date IS NULL`; - break; + private static getTaskDateRangeFilter(startDate?: string, endDate?: string): string { + if (!startDate && !endDate) return ""; + const conditions: string[] = []; + if (startDate) { + conditions.push(`start_date >= '${startDate}'`); + } + if (endDate) { + conditions.push(`end_date <= '${endDate}'`); } - return closure; + return conditions.length > 0 ? `AND ${conditions.join(" AND ")}` : ""; } - private static getFilterByMembersWhereClosure(text: string) { - return text - ? `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id IN (${this.flatString(text)}))` - : ""; + private static getLogDateRangeFilter(startDate?: string, endDate?: string): string { + if (!startDate && !endDate) return ""; + const conditions: string[] = []; + if (startDate) { + conditions.push(`twl.created_at::date >= '${startDate}'`); + } + if (endDate) { + conditions.push(`twl.created_at::date <= '${endDate}'`); + } + return conditions.length > 0 ? `AND ${conditions.join(" AND ")}` : ""; + } + + private static getFilterByDatesWhereClosure(dateChecker?: string): string { + if (!dateChecker) return ""; + + switch (dateChecker) { + case this.TASKS_START_DATE_NULL_FILTER: + return "start_date IS NULL"; + case this.TASKS_END_DATE_NULL_FILTER: + return "end_date IS NULL"; + case this.TASKS_START_END_DATES_NULL_FILTER: + return "start_date IS NULL AND end_date IS NULL"; + default: + return ""; + } } private static getStatusesQuery(filterBy: string) { @@ -443,9 +537,17 @@ export default class WorkloadGanntController extends WLTasksControllerBase { const isSubTasks = !!options.parent_task; + const queryParams: any[] = []; + let paramOffset = 1; + const sortFields = sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || "sort_order"; // Filter tasks by its members - const membersFilter = WorkloadGanntController.getFilterByMembersWhereClosure(options.members as string); + const membersResult = WorkloadGanntController.getFilterByMembersWhereClosure(options.members as string, paramOffset); + if (membersResult.params.length > 0) { + queryParams.push(...membersResult.params); + paramOffset += membersResult.params.length; + } + const membersFilter = membersResult.clause; // Returns statuses of each task as a json array if filterBy === "member" const statusesQuery = WorkloadGanntController.getStatusesQuery(options.filterBy as string); diff --git a/worklenz-backend/src/controllers/projects-controller.ts b/worklenz-backend/src/controllers/projects-controller.ts index da2048325..2aca0458c 100644 --- a/worklenz-backend/src/controllers/projects-controller.ts +++ b/worklenz-backend/src/controllers/projects-controller.ts @@ -4,7 +4,8 @@ import HandleExceptions from "../decorators/handle-exceptions"; import {IWorkLenzRequest} from "../interfaces/worklenz-request"; import {IWorkLenzResponse} from "../interfaces/worklenz-response"; import {ServerResponse} from "../models/server-response"; -import {LOG_DESCRIPTIONS} from "../shared/constants"; +import {LOG_DESCRIPTIONS, LOG_I18N_KEYS} from "../shared/constants"; +import {SqlHelper} from "../shared/sql-helpers"; import {getColor} from "../shared/utils"; import {generateProjectKey} from "../utils/generate-project-key"; import WorklenzControllerBase from "./worklenz-controller-base"; @@ -13,9 +14,12 @@ import { IPassportSession } from "../interfaces/passport-session"; import { SocketEvents } from "../socket.io/events"; import { IO } from "../shared/io"; import { getCurrentProjectsCount, getFreePlanSettings } from "../shared/paddle-utils"; +import { ActivityLoggingService } from "../services/activity-logging.service"; export default class ProjectsController extends WorklenzControllerBase { + // Legacy logging methods removed - now using ActivityLoggingService + private static async getAllKeysByTeamId(teamId?: string) { if (!teamId) return []; try { @@ -90,6 +94,16 @@ export default class ProjectsController extends WorklenzControllerBase { const result = await db.query(q, [JSON.stringify(req.body)]); const [data] = result.rows; + // Log project creation after successful database operation + if (data.project?.id) { + await ActivityLoggingService.logProjectCreated( + req.user?.team_id || "", + data.project.id, + req.user?.id || "", + req.body.name + ); + } + return res.status(200).send(new ServerResponse(true, data.project || {})); } @@ -118,13 +132,22 @@ export default class ProjectsController extends WorklenzControllerBase { @HandleExceptions() public static async getMyProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const {searchQuery, size, offset} = this.toPaginationOptions(req.query, "name"); - - const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : ""; - + const {searchQuery, searchParams = [], size, offset} = this.toPaginationOptions(req.query, "name", false, 1); + const userId = req.user?.id; + + // Use parameterized queries for user ID + // Calculate parameter offsets: team_id=$1, then searchParams, then userId references + const teamIdParam = 1; + const firstSearchParam = teamIdParam + 1; + const userIdParam = firstSearchParam + searchParams.length; + const limitParam = userIdParam + 1; + const offsetParam = userIdParam + 2; + + const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = $${userIdParam} AND project_id = projects.id)` : ""; + const isArchived = req.query.filter === "2" - ? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` - : ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`; + ? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = $${userIdParam} AND project_id = projects.id)` + : ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = $${userIdParam} AND project_id = projects.id)`; const q = ` SELECT ROW_TO_JSON(rec) AS projects FROM (SELECT COUNT(*) AS total, @@ -169,19 +192,19 @@ export default class ProjectsController extends WorklenzControllerBase { AND project_id = projects.id) ELSE updated_at END) AS updated_at FROM projects - WHERE team_id = $1 ${isArchived} ${isFavorites} ${searchQuery} + WHERE team_id = $${teamIdParam} ${isArchived} ${isFavorites} ${searchQuery} AND is_member_of_project(projects.id - , '${req.user?.id}' - , $1) + , $${userIdParam} + , $${teamIdParam}) ORDER BY updated_at DESC - LIMIT $2 OFFSET $3) t) AS data + LIMIT $${limitParam} OFFSET $${offsetParam}) t) AS data FROM projects - WHERE team_id = $1 ${isArchived} ${isFavorites} ${searchQuery} + WHERE team_id = $${teamIdParam} ${isArchived} ${isFavorites} ${searchQuery} AND is_member_of_project(projects.id - , '${req.user?.id}' - , $1)) rec; + , $${userIdParam} + , $${teamIdParam})) rec; `; - const result = await db.query(q, [req.user?.team_id || null, size, offset]); + const result = await db.query(q, [req.user?.team_id || null, ...searchParams, userId, size, offset]); const [data] = result.rows; const projects = Array.isArray(data?.projects.data) ? data?.projects.data : []; for (const project of projects) { @@ -192,31 +215,154 @@ export default class ProjectsController extends WorklenzControllerBase { return res.status(200).send(new ServerResponse(true, data?.projects || this.paginatedDatasetDefaultStruct)); } - private static flatString(text: string) { - return (text || "").split(" ").map(s => `'${s}'`).join(","); + private static getFilterByCategoryWhereClosure(text: string, paramOffset: number): { clause: string; params: string[] } { + if (!text) return { clause: "", params: [] }; + const categoryIds = text.split(" ").filter(id => id.trim()); + const { clause } = SqlHelper.buildInClause(categoryIds, paramOffset); + return { clause: `AND category_id IN (${clause})`, params: categoryIds }; } - private static getFilterByCategoryWhereClosure(text: string) { - return text ? `AND category_id IN (${this.flatString(text)})` : ""; + private static getFilterByStatusWhereClosure(text: string, paramOffset: number): { clause: string; params: string[] } { + if (!text) return { clause: "", params: [] }; + const statusIds = text.split(" ").filter(id => id.trim()); + const { clause } = SqlHelper.buildInClause(statusIds, paramOffset); + return { clause: `AND status_id IN (${clause})`, params: statusIds }; } - private static getFilterByStatusWhereClosure(text: string) { - return text ? `AND status_id IN (${this.flatString(text)})` : ""; + /** + * Validates and maps sort field + * Maps frontend field names to safe database column names + */ + private static validateAndMapSortField(field: string | string[] | undefined, defaultField: string = "name"): string { + // If field is an array, use the first element or default + const sortField = Array.isArray(field) ? field[0] : (field || defaultField); + + // Whitelist of allowed sort fields for projects + // Maps frontend field names to safe database column names + const fieldMapping: Record = { + 'name': 'name', + 'updated_at': 'updated_at', + 'created_at': 'created_at', + 'start_date': 'start_date', + 'end_date': 'end_date', + 'status': 'status_id', + 'category': 'category_id', + 'client_name': 'client_id', + 'project_owner': 'owner_id', + }; + + // If the field is already a valid database column name (contains dot or matches exactly) + if (typeof sortField === 'string') { + // Check if it's already a qualified column name (e.g., "projects.name") + if (sortField.includes('.') || sortField === 'updated_at') { + // Validate it's a safe column name (alphanumeric, underscore, dot only) + // Remove any invalid characters + const sanitized = sortField.replace(/[^a-zA-Z0-9_.]/g, ''); + if (/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(sanitized)) { + return sanitized; + } + } + + // Map frontend field name to database column + if (fieldMapping[sortField]) { + return fieldMapping[sortField]; + } + } + + // Default to safe field if invalid + return fieldMapping[defaultField] || 'name'; + } + + /** + * Validates and maps sort field for project members + */ + private static validateAndMapMemberSortField(field: string | string[] | undefined, defaultField: string = "name"): string { + const sortField = Array.isArray(field) ? field[0] : (field || defaultField); + + // Whitelist of allowed sort fields for project members + const fieldMapping: Record = { + 'name': 'name', + 'email': 'email', + 'access': 'access', + 'job_title': 'job_title', + 'all_tasks_count': 'all_tasks_count', + 'completed_tasks_count': 'completed_tasks_count', + }; + + if (typeof sortField === 'string') { + // Validate it's a safe column name + const sanitized = sortField.replace(/[^a-zA-Z0-9_]/g, ''); + if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(sanitized)) { + // Check if it's in the whitelist + if (fieldMapping[sanitized]) { + return fieldMapping[sanitized]; + } + } + } + + return fieldMapping[defaultField] || 'name'; } @HandleExceptions() public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name"); + const queryParams: any[] = [req.user?.team_id || null]; + let paramOffset = 2; + + // User ID parameters - only add if actually used + const userId = req.user?.id; + let filterByMember = ""; + let isFavorites = ""; + let isArchived = ""; + + if (!req.user?.owner && !req.user?.is_admin) { + queryParams.push(userId); + filterByMember = ` AND is_member_of_project(projects.id, $${paramOffset}, $1) `; + paramOffset++; + } - const filterByMember = !req.user?.owner && !req.user?.is_admin ? - ` AND is_member_of_project(projects.id, '${req.user?.id}', $1) ` : ""; + if (req.query.filter === "1") { + queryParams.push(userId); + isFavorites = ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = $${paramOffset} AND project_id = projects.id)`; + paramOffset++; + } + + if (req.query.filter === "2") { + queryParams.push(userId); + isArchived = ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = $${paramOffset} AND project_id = projects.id)`; + paramOffset++; + } else { + queryParams.push(userId); + isArchived = ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = $${paramOffset} AND project_id = projects.id)`; + paramOffset++; + } - const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : ""; - const isArchived = req.query.filter === "2" - ? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` - : ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`; - const categories = this.getFilterByCategoryWhereClosure(req.query.categories as string); - const statuses = this.getFilterByStatusWhereClosure(req.query.statuses as string); + const categoriesResult = this.getFilterByCategoryWhereClosure(req.query.categories as string, paramOffset); + if (categoriesResult.params.length > 0) { + queryParams.push(...categoriesResult.params); + paramOffset += categoriesResult.params.length; + } + + const statusesResult = this.getFilterByStatusWhereClosure(req.query.statuses as string, paramOffset); + if (statusesResult.params.length > 0) { + queryParams.push(...statusesResult.params); + paramOffset += statusesResult.params.length; + } + + // Now get search query with correct paramOffset + const {searchQuery, searchParams, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name", false, paramOffset); + + // Add search parameters to queryParams + if (searchParams.length > 0) { + queryParams.push(...searchParams); + paramOffset += searchParams.length; + } + + // Validate and sanitize sort field + const safeSortField = this.validateAndMapSortField(sortField, "name"); + const safeSortOrder = (sortOrder === "desc" || sortOrder === "DESC") ? "DESC" : "ASC"; + + const categories = categoriesResult.clause; + const statuses = statusesResult.clause; const q = ` SELECT ROW_TO_JSON(rec) AS projects @@ -286,12 +432,17 @@ export default class ProjectsController extends WorklenzControllerBase { ELSE updated_at END) AS updated_at FROM projects WHERE team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery} - ORDER BY ${sortField} ${sortOrder} - LIMIT $2 OFFSET $3) t) AS data + ORDER BY ${safeSortField} ${safeSortOrder} + LIMIT $${paramOffset} OFFSET $${paramOffset + 1}) t) AS data FROM projects WHERE team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}) rec; `; - const result = await db.query(q, [req.user?.team_id || null, size, offset]); + + // Add pagination parameters at the end + queryParams.push(size, offset); + + + const result = await db.query(q, queryParams); const [data] = result.rows; for (const project of data?.projects.data || []) { @@ -317,6 +468,11 @@ export default class ProjectsController extends WorklenzControllerBase { @HandleExceptions() public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name"); + + // Validate and sanitize sort field + const safeSortField = this.validateAndMapMemberSortField(sortField, "name"); + const safeSortOrder = (sortOrder === "desc" || sortOrder === "DESC") ? "DESC" : "ASC"; + const search = (req.query.search || "").toString().trim(); let searchFilter = ""; @@ -354,10 +510,8 @@ export default class ProjectsController extends WorklenzControllerBase { (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) FROM ( SELECT * FROM filtered_members - ORDER BY ${sortField} ${sortOrder} - LIMIT $3 OFFSET $4 - ) t - ) AS data + ORDER BY ${safeSortField} ${safeSortOrder} + LIMIT $3 OFFSET $4) t) AS data `; const result = await db.query(q, params); @@ -388,6 +542,8 @@ export default class ProjectsController extends WorklenzControllerBase { projects.folder_id, projects.phase_label, projects.category_id, + projects.currency, + projects.budget, (projects.estimated_man_days) AS man_days, (projects.estimated_working_days) AS working_days, (projects.hours_per_day) AS hours_per_day, @@ -472,6 +628,25 @@ export default class ProjectsController extends WorklenzControllerBase { const result = await db.query(q, [JSON.stringify(req.body)]); const [data] = result.rows; + // Log the project update using the centralized service + await ActivityLoggingService.logProjectUpdated( + req.user?.team_id || "", + req.params.id, + req.user?.id || "", + req.body.name + ); + + // Log project manager assignment if changed + if (req.body.project_manager && req.body.project_manager.id) { + await ActivityLoggingService.logProjectActivity({ + teamId: req.user?.team_id || "", + projectId: req.params.id, + userId: req.user?.id || "", + i18nKey: LOG_I18N_KEYS.PROJECT_MANAGER_ASSIGNED, + projectName: req.body.name + }); + } + this.notifyProjecManagertUpdates(req.params.id, req.user as IPassportSession, req.body.project_manager ? req.body.project_manager.id : null); return res.status(200).send(new ServerResponse(true, data.project)); @@ -479,12 +654,39 @@ export default class ProjectsController extends WorklenzControllerBase { @HandleExceptions() public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const q = `DELETE - FROM projects - WHERE id = $1 - AND team_id = $2`; - const result = await db.query(q, [req.params.id, req.user?.team_id || null]); - return res.status(200).send(new ServerResponse(true, result.rows)); + // Get project details before deletion for logging + const getProjectQ = `SELECT name, color_code FROM projects WHERE id = $1 AND team_id = $2`; + const projectResult = await db.query(getProjectQ, [req.params.id, req.user?.team_id || null]); + + if (projectResult.rows.length === 0) { + return res.status(404).send(new ServerResponse(false, null, "Project not found")); + } + + const project = projectResult.rows[0]; + const userName = req.user?.name || "Unknown User"; + + // Log project deletion + await ActivityLoggingService.logProjectDeleted( + req.user?.team_id || "", + req.params.id, + req.user?.id || "", + project.name + ); + + // Delete the project + const deleteQ = `DELETE + FROM projects + WHERE id = $1 + AND team_id = $2`; + const result = await db.query(deleteQ, [req.params.id, req.user?.team_id || null]); + + return res.status(200).send(new ServerResponse(true, { + message: `Project "${project.name}" has been successfully deleted`, + deleted_project: { + name: project.name, + color_code: project.color_code + } + })); } @HandleExceptions() @@ -609,23 +811,30 @@ export default class ProjectsController extends WorklenzControllerBase { @HandleExceptions() public static async getAllTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const {searchQuery, size, offset} = this.toPaginationOptions(req.query, ["tasks.name"]); + const {searchQuery, searchParams = [], size, offset} = this.toPaginationOptions(req.query, ["tasks.name"], false, 2); + const userId = req.user?.id; + // Use parameterized query for user ID const filterByMember = !req.user?.owner && !req.user?.is_admin ? - ` AND is_member_of_project(p.id, '${req.user?.id}', $1) ` : ""; + ` AND is_member_of_project(p.id, $${searchParams.length + 1}, $1) ` : ""; const isDueSoon = req.query.filter == "1"; const dueSoon = isDueSoon ? "AND tasks.end_date IS NOT NULL" : ""; const orderBy = isDueSoon ? "tasks.end_date DESC" : "p.name"; + // Use parameterized query for user ID + const userIdParam = searchParams.length + 1; const assignedToMe = req.query.filter == "2" ? ` AND tasks.id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id = (SELECT id FROM team_members - WHERE user_id = '${req.user?.id}' + WHERE user_id = $${userIdParam} AND team_id = $1)) ` : ""; + const limitParam = searchParams.length + (filterByMember ? 2 : 1) + 1; + const offsetParam = limitParam + 1; + const q = ` SELECT ROW_TO_JSON(rec) AS projects FROM (SELECT COUNT(*) AS total, @@ -648,13 +857,18 @@ export default class ProjectsController extends WorklenzControllerBase { WHERE tasks.archived IS FALSE AND p.team_id = $1 ${filterByMember} ${dueSoon} ${searchQuery} ${assignedToMe} ORDER BY ${orderBy} - LIMIT $2 OFFSET $3) t) AS data + LIMIT $${limitParam} OFFSET $${offsetParam}) t) AS data FROM tasks INNER JOIN projects p ON tasks.project_id = p.id WHERE tasks.archived IS FALSE AND p.team_id = $1 ${filterByMember} ${dueSoon} ${searchQuery} ${assignedToMe}) rec; `; - const result = await db.query(q, [req.user?.team_id || null, size, offset]); + const queryParams: any[] = [req.user?.team_id || null, ...searchParams]; + if (filterByMember || assignedToMe) { + queryParams.push(userId || null); + } + queryParams.push(size, offset); + const result = await db.query(q, queryParams); const [data] = result.rows; for (const project of data?.projects.data || []) { @@ -680,15 +894,75 @@ export default class ProjectsController extends WorklenzControllerBase { @HandleExceptions() public static async toggleFavorite(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + // Check current favorite status and get project name + const checkQ = `SELECT + p.name, + EXISTS(SELECT 1 FROM favorite_projects WHERE user_id = $1 AND project_id = $2) AS is_favorite + FROM projects p + WHERE p.id = $2 AND p.team_id = $3`; + const checkResult = await db.query(checkQ, [req.user?.id, req.params.id, req.user?.team_id]); + + if (checkResult.rows.length === 0) { + return res.status(404).send(new ServerResponse(false, null, "Project not found")); + } + + const project = checkResult.rows[0]; + const wasFavorite = project.is_favorite; + const q = `SELECT toggle_favorite_project($1, $2);`; const result = await db.query(q, [req.user?.id, req.params.id]); + + // Log the favorite/unfavorite action + const i18nKey = wasFavorite ? LOG_I18N_KEYS.PROJECT_UNFAVORITED : LOG_I18N_KEYS.PROJECT_FAVORITED; + await ActivityLoggingService.logProjectActivity({ + teamId: req.user?.team_id || "", + projectId: req.params.id, + userId: req.user?.id || "", + i18nKey, + projectName: project.name + }); + return res.status(200).send(new ServerResponse(true, result.rows || [])); } @HandleExceptions() public static async toggleArchive(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + // Check current archive status and get project name + const checkQ = `SELECT + p.name, + EXISTS(SELECT 1 FROM archived_projects WHERE user_id = $1 AND project_id = $2) AS is_archived + FROM projects p + WHERE p.id = $2 AND p.team_id = $3`; + const checkResult = await db.query(checkQ, [req.user?.id, req.params.id, req.user?.team_id]); + + if (checkResult.rows.length === 0) { + return res.status(404).send(new ServerResponse(false, null, "Project not found")); + } + + const project = checkResult.rows[0]; + const wasArchived = project.is_archived; + const q = `SELECT toggle_archive_project($1, $2);`; const result = await db.query(q, [req.user?.id, req.params.id]); + + // Log the archive/unarchive action + if (wasArchived) { + await ActivityLoggingService.logProjectActivity({ + teamId: req.user?.team_id || "", + projectId: req.params.id, + userId: req.user?.id || "", + i18nKey: LOG_I18N_KEYS.PROJECT_UNARCHIVED, + projectName: project.name + }); + } else { + await ActivityLoggingService.logProjectArchived( + req.user?.team_id || "", + req.params.id, + req.user?.id || "", + project.name + ); + } + return res.status(200).send(new ServerResponse(true, result.rows || [])); } @@ -752,18 +1026,39 @@ export default class ProjectsController extends WorklenzControllerBase { @HandleExceptions() public static async getGrouped(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { // Use qualified field name for projects to avoid ambiguity - const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, ["projects.name"]); + const {searchQuery, searchParams = [], sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, ["projects.name"], false, 1); const groupBy = req.query.groupBy as string || "category"; - + const userId = req.user?.id; + + // Use parameterized queries for user ID + // Calculate parameter offsets: team_id=$1, then searchParams, then categories, statuses, userId + const teamIdParam = 1; + let paramOffset = 2 + searchParams.length; // Start at 2 (after $1 for teamId) + + const categoriesResult = this.getFilterByCategoryWhereClosure(req.query.categories as string, paramOffset); + const categories = categoriesResult.clause; + paramOffset += categoriesResult.params.length; + + const statusesResult = this.getFilterByStatusWhereClosure(req.query.statuses as string, paramOffset); + const statuses = statusesResult.clause; + paramOffset += statusesResult.params.length; + + const userIdParam = paramOffset; + paramOffset++; + + const sizeParam = paramOffset; + paramOffset++; + + const offsetParam = paramOffset; + paramOffset++; + const filterByMember = !req.user?.owner && !req.user?.is_admin ? - ` AND is_member_of_project(projects.id, '${req.user?.id}', $1) ` : ""; + ` AND is_member_of_project(projects.id, $${userIdParam}, $${teamIdParam}) ` : ""; - const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : ""; + const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = $${userIdParam} AND project_id = projects.id)` : ""; const isArchived = req.query.filter === "2" - ? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` - : ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`; - const categories = this.getFilterByCategoryWhereClosure(req.query.categories as string); - const statuses = this.getFilterByStatusWhereClosure(req.query.statuses as string); + ? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = $${userIdParam} AND project_id = projects.id)` + : ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = $${userIdParam} AND project_id = projects.id)`; // Determine grouping field and join based on groupBy parameter let groupField = ""; @@ -800,13 +1095,13 @@ export default class ProjectsController extends WorklenzControllerBase { groupOrderBy = "COALESCE(project_categories.name, 'Uncategorized')"; } + // Validate and sanitize sort field + const safeSortField = this.validateAndMapSortField(sortField, "projects.name"); + const safeSortOrder = (sortOrder === "desc" || sortOrder === "DESC") ? "DESC" : "ASC"; + // Ensure sortField is properly qualified for the inner project query - let qualifiedSortField = sortField; - if (Array.isArray(sortField)) { - qualifiedSortField = sortField[0]; // Take the first field if it's an array - } // Replace "projects." with "p2." for the inner query - const innerSortField = qualifiedSortField.replace("projects.", "p2."); + const innerSortField = safeSortField.replace("projects.", "p2."); const q = ` SELECT ROW_TO_JSON(rec) AS groups @@ -827,11 +1122,11 @@ export default class ProjectsController extends WorklenzControllerBase { (SELECT sys_project_statuses.icon FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_icon, EXISTS(SELECT user_id FROM favorite_projects - WHERE user_id = '${req.user?.id}' + WHERE user_id = $${userIdParam} AND project_id = p2.id) AS favorite, EXISTS(SELECT user_id FROM archived_projects - WHERE user_id = '${req.user?.id}' + WHERE user_id = $${userIdParam} AND project_id = p2.id) AS archived, p2.color_code, p2.start_date, @@ -867,7 +1162,7 @@ export default class ProjectsController extends WorklenzControllerBase { (SELECT project_members.default_view FROM project_members WHERE project_members.project_id = p2.id - AND project_members.team_member_id = '${req.user?.team_member_id}') AS team_member_default_view, + AND project_members.team_member_id = (SELECT id FROM team_members WHERE user_id = $${userIdParam} AND team_id = $${teamIdParam} LIMIT 1)) AS team_member_default_view, (SELECT CASE WHEN ((SELECT MAX(tasks.updated_at) FROM tasks @@ -889,24 +1184,33 @@ export default class ProjectsController extends WorklenzControllerBase { ${isFavorites.replace("projects.", "p2.")} ${filterByMember.replace("projects.", "p2.")} ${searchQuery.replace("projects.", "p2.")} - ORDER BY ${innerSortField} ${sortOrder} + ORDER BY ${innerSortField} ${safeSortOrder} ) project_data ) AS projects FROM projects ${groupJoin} - WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery} + WHERE projects.team_id = $${teamIdParam} ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery} GROUP BY ${groupByFields} ORDER BY ${groupOrderBy} - LIMIT $2 OFFSET $3 + LIMIT $${sizeParam}::INTEGER OFFSET $${offsetParam}::INTEGER ) group_data ) AS data FROM projects ${groupJoin} - WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery} + WHERE projects.team_id = $${teamIdParam} ) rec; `; - const result = await db.query(q, [req.user?.team_id || null, size, offset]); + // Build parameter array: team_id, searchParams, categories params, statuses params, userId, size, offset + const queryParams: any[] = [ + req.user?.team_id || null, + ...searchParams, + ...categoriesResult.params, + ...statusesResult.params, + userId + ]; + queryParams.push(size, offset); + const result = await db.query(q, queryParams); const [data] = result.rows; // Process the grouped data 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 19bf6474d..d6381f792 100644 --- a/worklenz-backend/src/controllers/reporting/projects/reporting-projects-controller.ts +++ b/worklenz-backend/src/controllers/reporting/projects/reporting-projects-controller.ts @@ -7,48 +7,80 @@ import ReportingControllerBase from "../reporting-controller-base"; import moment from "moment"; import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../../shared/constants"; import { getColor, int, formatDuration, formatLogText } from "../../../shared/utils"; +import { SqlHelper } from "../../../shared/sql-helpers"; import db from "../../../config/db"; export default class ReportingProjectsController extends ReportingProjectsBase { - private static flatString(text: string) { - return (text || "").split(",").map(s => `'${s}'`).join(","); - } - @HandleExceptions() public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const { searchQuery, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, ["p.name"]); + // teamId is $1, size is $2, offset is $3, so search params start at $4 + const { searchQuery, searchParams, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, ["p.name"], false, 4); const archived = req.query.archived === "true"; const teamId = this.getCurrentTeamId(req); - const statusesClause = req.query.statuses as string - ? `AND p.status_id IN (${this.flatString(req.query.statuses as string)})` - : ""; + // Note: teamId is $1, size is $2, offset is $3, search params are $4+, then filter params continue after + const filterParams: any[] = [...searchParams]; + let paramOffset = 4 + searchParams.length; // Start after teamId, size, offset, and search params + + let statusesClause = ""; + if (req.query.statuses) { + const statusIds = (req.query.statuses as string).split(",").filter(id => id.trim()); + const { clause, params } = SqlHelper.buildOptionalInClause(statusIds, 'status_id', paramOffset); + statusesClause = clause.replace('status_id', 'p.status_id'); + filterParams.push(...params); + paramOffset += params.length; + } - const healthsClause = req.query.healths as string - ? `AND p.health_id IN (${this.flatString(req.query.healths as string)})` - : ""; + let healthsClause = ""; + if (req.query.healths) { + const healthIds = (req.query.healths as string).split(",").filter(id => id.trim()); + const { clause, params } = SqlHelper.buildOptionalInClause(healthIds, 'health_id', paramOffset); + healthsClause = clause.replace('health_id', 'p.health_id'); + filterParams.push(...params); + paramOffset += params.length; + } - const categoriesClause = req.query.categories as string - ? `AND p.category_id IN (${this.flatString(req.query.categories as string)})` - : ""; + let categoriesClause = ""; + if (req.query.categories) { + const categoryIds = (req.query.categories as string).split(",").filter(id => id.trim()); + const { clause, params } = SqlHelper.buildOptionalInClause(categoryIds, 'category_id', paramOffset); + categoriesClause = clause.replace('category_id', 'p.category_id'); + filterParams.push(...params); + paramOffset += params.length; + } - // const projectManagersClause = req.query.project_managers as string - // ? `AND p.id IN (SELECT project_id from project_members WHERE team_member_id IN (${this.flatString(req.query.project_managers as string)}) AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER'))` - // : ""; + let projectManagersClause = ""; + if (req.query.project_managers) { + const managerIds = (req.query.project_managers as string).split(",").filter(id => id.trim()); + const { clause, params } = SqlHelper.buildInClause(managerIds, paramOffset); + projectManagersClause = `AND p.id IN(SELECT project_id FROM project_members WHERE team_member_id IN(SELECT id FROM team_members WHERE user_id IN (${clause})) AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER'))`; + filterParams.push(...params); + paramOffset += params.length; + } - const projectManagersClause = req.query.project_managers as string - ? `AND p.id IN(SELECT project_id FROM project_members WHERE team_member_id IN(SELECT id FROM team_members WHERE user_id IN (${this.flatString(req.query.project_managers as string)})) AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER'))` - : ""; + let teamsClause = ""; + if (req.query.teams) { + const teamIds = (req.query.teams as string).split(",").filter(id => id.trim()); + const { clause, params } = SqlHelper.buildOptionalInClause(teamIds, 'team_id', paramOffset); + teamsClause = clause.replace('team_id', 'p.team_id'); + filterParams.push(...params); + paramOffset += params.length; + } - const archivedClause = archived - ? "" - : `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = '${req.user?.id}') `; + let archivedClause = ""; + if (!archived) { + archivedClause = `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = $${paramOffset})`; + filterParams.push(req.user?.id); + paramOffset++; + } - const teamFilterClause = `in_organization(p.team_id, $1)`; + // Add project filtering for Team Leads + 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); + const result = await ReportingControllerBase.getProjectsByTeam(teamId as string, size, offset, searchQuery, sortField, 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; @@ -97,25 +129,29 @@ export default class ReportingProjectsController extends ReportingProjectsBase { return res.status(200).send(new ServerResponse(true, result)); } - protected static getMinMaxDates(key: string, dateRange: string[]) { + protected static getMinMaxDates(key: string, dateRange: string[], paramOffset = 1): { clause: string; params: any[] } { if (dateRange.length === 2) { + // Use parameterized queries for dates const start = moment(dateRange[0]).format("YYYY-MM-DD"); const end = moment(dateRange[1]).format("YYYY-MM-DD"); - return `,(SELECT '${start}'::DATE )AS start_date, (SELECT '${end}'::DATE )AS end_date`; + return { + clause: `,(SELECT $${paramOffset}::DATE )AS start_date, (SELECT $${paramOffset + 1}::DATE )AS end_date`, + params: [start, end] + }; } if (key === DATE_RANGES.YESTERDAY) - return ",(SELECT (CURRENT_DATE - INTERVAL '1 day')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date"; + return { clause: ",(SELECT (CURRENT_DATE - INTERVAL '1 day')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date", params: [] }; if (key === DATE_RANGES.LAST_WEEK) - return ",(SELECT (CURRENT_DATE - INTERVAL '1 week')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date"; + return { clause: ",(SELECT (CURRENT_DATE - INTERVAL '1 week')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date", params: [] }; if (key === DATE_RANGES.LAST_MONTH) - return ",(SELECT (CURRENT_DATE - INTERVAL '1 month')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date"; + return { clause: ",(SELECT (CURRENT_DATE - INTERVAL '1 month')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date", params: [] }; if (key === DATE_RANGES.LAST_QUARTER) - return ",(SELECT (CURRENT_DATE - INTERVAL '3 months')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date"; + return { clause: ",(SELECT (CURRENT_DATE - INTERVAL '3 months')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date", params: [] }; if (key === DATE_RANGES.ALL_TIME) - return ",(SELECT (MIN(task_work_log.created_at)::DATE) FROM task_work_log WHERE task_id IN (SELECT id FROM tasks WHERE project_id = $1)) AS start_date, (SELECT (MAX(task_work_log.created_at)::DATE) FROM task_work_log WHERE task_id IN (SELECT id FROM tasks WHERE project_id = $1)) AS end_date"; + return { clause: `,(SELECT (MIN(task_work_log.created_at)::DATE) FROM task_work_log WHERE task_id IN (SELECT id FROM tasks WHERE project_id = $${paramOffset})) AS start_date, (SELECT (MAX(task_work_log.created_at)::DATE) FROM task_work_log WHERE task_id IN (SELECT id FROM tasks WHERE project_id = $${paramOffset})) AS end_date`, params: [] }; - return ""; + return { clause: "", params: [] }; } @HandleExceptions() @@ -124,7 +160,10 @@ export default class ReportingProjectsController extends ReportingProjectsBase { const { duration, date_range } = req.body; const durationClause = this.getDateRangeClause(duration || DATE_RANGES.LAST_WEEK, date_range); - const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range); + // Extract clause and params from getMinMaxDates + const minMaxDateClauseResult = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, 2); + const minMaxDateClause = minMaxDateClauseResult.clause; + const minMaxParams = minMaxDateClauseResult.params; const q = `SELECT (SELECT name FROM projects WHERE projects.id = $1) AS project_name, @@ -143,7 +182,9 @@ export default class ReportingProjectsController extends ReportingProjectsBase { ${durationClause} ORDER BY task_work_log.created_at DESC`; - const result = await db.query(q, [projectId]); + // Pass all parameters + const queryParams = [projectId, ...minMaxParams]; + const result = await db.query(q, queryParams); const formattedResult = await this.formatLog(result.rows); @@ -215,4 +256,203 @@ export default class ReportingProjectsController extends ReportingProjectsBase { return format; } + @HandleExceptions() + public static async getGrouped(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + // teamId is $1, so search params start at $2 + const { searchQuery, searchParams, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, ["p.name"], false, 2); + const archived = req.query.archived === "true"; + const groupBy = (req.query.group_by as string) || "category"; + + const teamId = this.getCurrentTeamId(req); + + // Note: teamId is $1, search params are $2+, filter params continue after (no LIMIT/OFFSET in grouped query) + const filterParams: any[] = [...searchParams]; + let paramOffset = 2 + searchParams.length; // Start after teamId and search params + + let statusesClause = ""; + if (req.query.statuses) { + const statusIds = (req.query.statuses as string).split(",").filter(id => id.trim()); + const { clause, params } = SqlHelper.buildOptionalInClause(statusIds, 'status_id', paramOffset); + statusesClause = clause.replace('status_id', 'p.status_id'); + filterParams.push(...params); + paramOffset += params.length; + } + + let healthsClause = ""; + if (req.query.healths) { + const healthIds = (req.query.healths as string).split(",").filter(id => id.trim()); + const { clause, params } = SqlHelper.buildOptionalInClause(healthIds, 'health_id', paramOffset); + healthsClause = clause.replace('health_id', 'p.health_id'); + filterParams.push(...params); + paramOffset += params.length; + } + + let categoriesClause = ""; + if (req.query.categories) { + const categoryIds = (req.query.categories as string).split(",").filter(id => id.trim()); + const { clause, params } = SqlHelper.buildOptionalInClause(categoryIds, 'category_id', paramOffset); + categoriesClause = clause.replace('category_id', 'p.category_id'); + filterParams.push(...params); + paramOffset += params.length; + } + + let projectManagersClause = ""; + if (req.query.project_managers) { + const managerIds = (req.query.project_managers as string).split(",").filter(id => id.trim()); + const { clause, params } = SqlHelper.buildInClause(managerIds, paramOffset); + projectManagersClause = `AND p.id IN(SELECT project_id FROM project_members WHERE team_member_id IN(SELECT id FROM team_members WHERE user_id IN (${clause})) AND project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER'))`; + filterParams.push(...params); + paramOffset += params.length; + } + + let teamsClause = ""; + if (req.query.teams) { + const teamIds = (req.query.teams as string).split(",").filter(id => id.trim()); + const { clause, params } = SqlHelper.buildOptionalInClause(teamIds, 'team_id', paramOffset); + teamsClause = clause.replace('team_id', 'p.team_id'); + filterParams.push(...params); + paramOffset += params.length; + } + + let archivedClause = ""; + if (!archived) { + archivedClause = `AND p.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = p.id AND user_id = $${paramOffset})`; + filterParams.push(req.user?.id); + paramOffset++; + } + + // Add project filtering for Team Leads + const projectFilterClause = await this.buildProjectFilterForTeamLead(req); + const teamFilterClause = `in_organization(p.team_id, $1) ${projectFilterClause} ${teamsClause}`; + + // Determine grouping fields based on groupBy parameter + let groupField = ""; + let groupName = ""; + let groupColor = ""; + let groupJoin = ""; + let groupByFields = ""; + let groupOrderBy = ""; + + switch (groupBy) { + case "status": + groupField = "COALESCE(p.status_id::text, 'no-status')"; + groupName = "COALESCE(ps.name, 'No Status')"; + groupColor = "COALESCE(ps.color_code, '#888')"; + groupByFields = "p.status_id, ps.name, ps.color_code"; + groupOrderBy = "COALESCE(ps.name, 'No Status')"; + break; + case "health": + groupField = "COALESCE(p.health_id::text, 'not-set')"; + groupName = "COALESCE(sph.name, 'Not Set')"; + groupColor = "COALESCE(sph.color_code, '#888')"; + // Join already exists at line 427: LEFT JOIN sys_project_healths sph ON p.health_id = sph.id + groupByFields = "p.health_id, sph.name, sph.color_code"; + groupOrderBy = "COALESCE(sph.name, 'Not Set')"; + break; + case "team": + groupField = "COALESCE(p.team_id::text, 'no-team')"; + groupName = "COALESCE(t.name, 'No Team')"; + groupColor = "COALESCE('#1890ff', '#888')"; + groupJoin = "LEFT JOIN teams t ON p.team_id = t.id"; + groupByFields = "p.team_id, t.name"; + groupOrderBy = "COALESCE(t.name, 'No Team')"; + break; + case "manager": + groupField = "COALESCE((SELECT pm.team_member_id::text FROM project_members pm WHERE pm.project_id = p.id AND pm.project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER') LIMIT 1), 'no-manager')"; + groupName = "COALESCE((SELECT name FROM team_member_info_view tmiv WHERE tmiv.team_member_id = (SELECT pm.team_member_id FROM project_members pm WHERE pm.project_id = p.id AND pm.project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER') LIMIT 1)), 'No Manager')"; + groupColor = "'#1890ff'"; + groupByFields = "(SELECT pm.team_member_id FROM project_members pm WHERE pm.project_id = p.id AND pm.project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER') LIMIT 1), (SELECT name FROM team_member_info_view tmiv WHERE tmiv.team_member_id = (SELECT pm.team_member_id FROM project_members pm WHERE pm.project_id = p.id AND pm.project_access_level_id = (SELECT id FROM project_access_levels WHERE key = 'PROJECT_MANAGER') LIMIT 1))"; + groupOrderBy = groupName; + break; + case "category": + default: + groupField = "COALESCE(p.category_id::text, 'uncategorized')"; + groupName = "COALESCE(pc.name, 'Uncategorized')"; + groupColor = "COALESCE(pc.color_code, '#888')"; + groupByFields = "p.category_id, pc.name, pc.color_code"; + groupOrderBy = "COALESCE(pc.name, 'Uncategorized')"; + } + + // Build optimized query with group-level task aggregations + const q = ` + WITH project_tasks AS ( + SELECT + t.project_id, + COUNT(t.id) AS total_tasks, + COUNT(CASE WHEN is_completed(t.status_id, t.project_id) IS TRUE THEN 1 END) AS done_tasks, + COUNT(CASE WHEN is_doing(t.status_id, t.project_id) IS TRUE THEN 1 END) AS doing_tasks, + COUNT(CASE WHEN is_todo(t.status_id, t.project_id) IS TRUE THEN 1 END) AS todo_tasks + FROM tasks t + WHERE t.archived IS FALSE + GROUP BY t.project_id + ) + SELECT + ${groupField} AS group_id, + ${groupName} AS group_name, + ${groupColor} AS group_color, + COUNT(DISTINCT p.id) AS project_count, + COALESCE(SUM(pt.total_tasks), 0)::INT AS total_tasks, + COALESCE(SUM(pt.done_tasks), 0)::INT AS done_tasks, + COALESCE(SUM(pt.doing_tasks), 0)::INT AS doing_tasks, + COALESCE(SUM(pt.todo_tasks), 0)::INT AS todo_tasks, + COALESCE(ARRAY_TO_JSON(ARRAY_AGG( + JSON_BUILD_OBJECT( + 'id', p.id, + 'name', p.name, + 'color_code', p.color_code, + 'category_id', pc.id, + 'category_name', pc.name, + 'category_color', pc.color_code, + 'status_id', ps.id, + 'status_name', ps.name, + 'status_color', ps.color_code, + 'health_id', p.health_id, + 'health_name', sph.name, + 'health_color', sph.color_code, + 'team_id', p.team_id, + 'team_name', (SELECT name FROM teams WHERE id = p.team_id), + 'start_date', p.start_date, + 'end_date', p.end_date, + 'tasks_stat', JSON_BUILD_OBJECT( + 'total', COALESCE(pt.total_tasks, 0), + 'todo', COALESCE(pt.todo_tasks, 0), + 'doing', COALESCE(pt.doing_tasks, 0), + 'done', COALESCE(pt.done_tasks, 0) + ) + ) ORDER BY p.name + )), '[]'::JSON) AS projects + FROM projects p + LEFT JOIN project_categories pc ON p.category_id = pc.id + LEFT JOIN sys_project_statuses ps ON p.status_id = ps.id + LEFT JOIN sys_project_healths sph ON p.health_id = sph.id + LEFT JOIN project_tasks pt ON p.id = pt.project_id + ${groupJoin} + WHERE ${teamFilterClause} ${searchQuery} ${healthsClause} ${statusesClause} ${categoriesClause} ${projectManagersClause} ${archivedClause} + GROUP BY ${groupByFields} + ORDER BY ${groupOrderBy} + `; + + // Build final params: teamId ($1), searchParams ($2+), then filter params + // Note: getGrouped query doesn't use LIMIT/OFFSET + const finalParams = [teamId, ...filterParams]; + const result = await db.query(q, finalParams); + + const groups = result.rows.map(row => ({ + group_id: row.group_id, + group_name: row.group_name, + group_color: row.group_color, + project_count: int(row.project_count), + total_tasks: int(row.total_tasks), + done_tasks: int(row.done_tasks), + doing_tasks: int(row.doing_tasks), + todo_tasks: int(row.todo_tasks), + projects: row.projects + })); + + return res.status(200).send(new ServerResponse(true, { + groups, + total_groups: groups.length + })); + } + } diff --git a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts index 60a3da765..f10cd1c7c 100644 --- a/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts +++ b/worklenz-backend/src/controllers/reporting/reporting-members-controller.ts @@ -4,9 +4,11 @@ import HandleExceptions from "../../decorators/handle-exceptions"; import { IWorkLenzRequest } from "../../interfaces/worklenz-request"; import { IWorkLenzResponse } from "../../interfaces/worklenz-response"; import { ServerResponse } from "../../models/server-response"; +import { SqlHelper } from "../../shared/sql-helpers"; import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants"; import { formatDuration, getColor, int } from "../../shared/utils"; import ReportingControllerBaseWithTimezone from "./reporting-controller-base-with-timezone"; +import ReportingControllerBase from "./reporting-controller-base"; import Excel from "exceljs"; export default class ReportingMembersController extends ReportingControllerBaseWithTimezone { @@ -72,24 +74,84 @@ export default class ReportingMembersController extends ReportingControllerBaseW private static async getMembers( teamId: string, searchQuery = "", + searchParams: string[] = [], size: number | null = null, offset: number | null = null, teamsClause = "", + teamIdsParams: string[] = [], key = DATE_RANGES.LAST_WEEK, dateRange: string[] = [], includeArchived: boolean, - userId: string + userId: string, + req?: any ) { const pagingClause = (size !== null && offset !== null) ? `LIMIT ${size} OFFSET ${offset}` : ""; - 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 = '${userId}')`; - // const durationFilterClause = this.memberTasksDurationFilter(key, dateRange); - const assignClause = this.memberAssignDurationFilter(key, dateRange); - const completedDurationClasue = this.completedDurationFilter(key, dateRange); - const overdueActivityLogsClause = this.getActivityLogsOverdue(key, dateRange); - const activityLogCreationFilter = this.getActivityLogsCreationClause(key, dateRange); + // Use parameterized queries + // Note: $1 is teamId, searchParams use $2+, so other parameters start after searchParams + let paramOffset = 2 + searchParams.length; + + // Build archived clause with parameterized userId + let archivedClause = ""; + let archivedParams: string[] = []; + if (!includeArchived) { + const userIdParamIndex = paramOffset; + archivedClause = `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = $${userIdParamIndex})`; + archivedParams = [userId]; + paramOffset += 1; + } + + // Build archived clause for time log subqueries (reuse same parameter) + const timeLogArchivedClause = includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = $${archivedParams.length > 0 ? paramOffset - 1 : paramOffset})`; + const assignClauseResult = this.memberAssignDurationFilter(key, dateRange, paramOffset); + 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; + paramOffset += timeLogParams.length; + + // Add project filtering for Team Leads - only show members working on assigned projects + let memberFilterClause = ""; + let projectParams: any[] = []; + if (req) { + const projectFilter = await ReportingControllerBase.buildProjectFilterForTeamLead(req); + if (projectFilter && projectFilter !== "") { + // Team Lead: only show members who work on their assigned projects + const assignedProjects = await ReportingControllerBase.getTeamLeadProjects(req.user?.id, teamId); + if (assignedProjects.length > 0) { + // Use parameterized query for array with correct offset + const { clause, params } = SqlHelper.buildInClause(assignedProjects, paramOffset); + projectParams = params; + memberFilterClause = `AND tmiv.team_member_id IN ( + SELECT DISTINCT pm.team_member_id + FROM project_members pm + WHERE pm.project_id IN (${clause}) + )`; + paramOffset += projectParams.length; + } else { + // Team Lead with no projects assigned - show no members + memberFilterClause = "AND FALSE"; + } + } + } const q = `SELECT COUNT(DISTINCT email) AS total, (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) @@ -159,24 +221,38 @@ export default class ReportingMembersController extends ReportingControllerBaseW FROM tasks t LEFT JOIN tasks_assignees ta ON t.id = ta.task_id WHERE team_member_id = tmiv.team_member_id - AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs + AND is_doing((SELECT new_value FROM task_activity_logs tl WHERE tl.task_id = t.id AND tl.attribute_type = 'status' ${activityLogCreationFilter} ORDER BY tl.created_at DESC LIMIT 1)::UUID, t.project_id) ${archivedClause}) AS ongoing_by_activity_logs, + + (SELECT COALESCE(SUM(twl.time_spent), 0) + FROM task_work_log twl + LEFT JOIN tasks t ON twl.task_id = t.id + WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id) + AND t.billable IS TRUE + AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1) + ${timeLogDateRangeClause} + ${timeLogArchivedClause}) AS billable_time, + + (SELECT COALESCE(SUM(twl.time_spent), 0) + FROM task_work_log twl + LEFT JOIN tasks t ON twl.task_id = t.id + WHERE twl.user_id = (SELECT user_id FROM team_members WHERE id = tmiv.team_member_id) + AND t.billable IS FALSE + AND t.project_id IN (SELECT id FROM projects WHERE team_id = $1) + ${timeLogDateRangeClause} + ${timeLogArchivedClause}) AS non_billable_time FROM team_member_info_view tmiv - WHERE tmiv.team_id = $1 ${teamsClause} - AND tmiv.team_member_id IN (SELECT team_member_id - FROM project_members - WHERE project_id IN (SELECT id FROM projects WHERE projects.team_id = tmiv.team_id)) + WHERE tmiv.team_id = $1 ${teamsClause} ${memberFilterClause} ${searchQuery} GROUP BY email, name, avatar_url, team_member_id, tmiv.team_id ORDER BY last_user_activity DESC NULLS LAST ${pagingClause}) t) AS members FROM team_member_info_view tmiv - WHERE tmiv.team_id = $1 ${teamsClause} - AND tmiv.team_member_id IN (SELECT team_member_id - FROM project_members - WHERE project_id IN (SELECT id FROM projects WHERE projects.team_id = tmiv.team_id)) + WHERE tmiv.team_id = $1 ${teamsClause} ${memberFilterClause} ${searchQuery}`; - const result = await db.query(q, [teamId]); + // Pass all parameters - searchParams come after teamId, then archivedParams, teamIdsParams, then other filter params + const queryParams = [teamId, ...searchParams, ...archivedParams, ...teamIdsParams, ...assignParams, ...completedParams, ...overdueParams, ...activityLogParams, ...timeLogParams, ...projectParams]; + const result = await db.query(q, queryParams); const [data] = result.rows; for (const member of data.members) { @@ -191,106 +267,129 @@ export default class ReportingMembersController extends ReportingControllerBaseW return data; } - private static flatString(text: string) { - return (text || "").split(" ").map(s => `'${s}'`).join(","); - } - protected static memberTasksDurationFilter(key: string, dateRange: string[]) { + protected static memberTasksDurationFilter(key: string, dateRange: string[], paramOffset = 1): { clause: string; params: any[] } { if (dateRange.length === 2) { + // Use parameterized queries for dates const start = moment(dateRange[0]).format("YYYY-MM-DD"); const end = moment(dateRange[1]).format("YYYY-MM-DD"); if (start === end) { - return `AND t.end_date::DATE = '${start}'::DATE`; + return { + clause: `AND t.end_date::DATE = $${paramOffset}::DATE`, + params: [start] + }; } - return `AND t.end_date::DATE >= '${start}'::DATE AND t.end_date::DATE <= '${end}'::DATE`; + return { + clause: `AND t.end_date::DATE >= $${paramOffset}::DATE AND t.end_date::DATE <= $${paramOffset + 1}::DATE`, + params: [start, end] + }; } if (key === DATE_RANGES.YESTERDAY) - return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND t.end_date::DATE < CURRENT_DATE::DATE`; + return { clause: `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND t.end_date::DATE < CURRENT_DATE::DATE`, params: [] }; if (key === DATE_RANGES.LAST_WEEK) - return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND t.end_date::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`; + return { clause: `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND t.end_date::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; if (key === DATE_RANGES.LAST_MONTH) - return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND t.end_date::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`; + return { clause: `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND t.end_date::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; if (key === DATE_RANGES.LAST_QUARTER) - return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND t.end_date::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`; + return { clause: `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND t.end_date::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; - return ""; + return { clause: "", params: [] }; } - protected static memberAssignDurationFilter(key: string, dateRange: string[]) { + protected static memberAssignDurationFilter(key: string, dateRange: string[], paramOffset = 1): { clause: string; params: any[] } { if (dateRange.length === 2) { + // Use parameterized queries for dates const start = moment(dateRange[0]).format("YYYY-MM-DD"); const end = moment(dateRange[1]).format("YYYY-MM-DD"); if (start === end) { - return `AND ta.updated_at::DATE = '${start}'::DATE`; + return { + clause: `AND ta.updated_at::DATE = $${paramOffset}::DATE`, + params: [start] + }; } - return `AND ta.updated_at::DATE >= '${start}'::DATE AND ta.updated_at::DATE <= '${end}'::DATE`; + return { + clause: `AND ta.updated_at::DATE >= $${paramOffset}::DATE AND ta.updated_at::DATE <= $${paramOffset + 1}::DATE`, + params: [start, end] + }; } if (key === DATE_RANGES.YESTERDAY) - return `AND ta.updated_at::DATE >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND ta.updated_at::DATE < CURRENT_DATE::DATE`; + return { clause: `AND ta.updated_at::DATE >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND ta.updated_at::DATE < CURRENT_DATE::DATE`, params: [] }; if (key === DATE_RANGES.LAST_WEEK) - return `AND ta.updated_at::DATE >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND ta.updated_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`; + return { clause: `AND ta.updated_at::DATE >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND ta.updated_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; if (key === DATE_RANGES.LAST_MONTH) - return `AND ta.updated_at::DATE >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND ta.updated_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`; + return { clause: `AND ta.updated_at::DATE >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND ta.updated_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; if (key === DATE_RANGES.LAST_QUARTER) - return `AND ta.updated_at::DATE >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND ta.updated_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`; + return { clause: `AND ta.updated_at::DATE >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND ta.updated_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; - return ""; + return { clause: "", params: [] }; } - protected static completedDurationFilter(key: string, dateRange: string[]) { + protected static completedDurationFilter(key: string, dateRange: string[], paramOffset = 1): { clause: string; params: any[] } { if (dateRange.length === 2) { + // Use parameterized queries for dates const start = moment(dateRange[0]).format("YYYY-MM-DD"); const end = moment(dateRange[1]).format("YYYY-MM-DD"); if (start === end) { - return `AND t.completed_at::DATE = '${start}'::DATE`; + return { + clause: `AND t.completed_at::DATE = $${paramOffset}::DATE`, + params: [start] + }; } - return `AND t.completed_at::DATE >= '${start}'::DATE AND t.completed_at::DATE <= '${end}'::DATE`; + return { + clause: `AND t.completed_at::DATE >= $${paramOffset}::DATE AND t.completed_at::DATE <= $${paramOffset + 1}::DATE`, + params: [start, end] + }; } if (key === DATE_RANGES.YESTERDAY) - return `AND t.completed_at::DATE >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND t.completed_at::DATE < CURRENT_DATE::DATE`; + return { clause: `AND t.completed_at::DATE >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND t.completed_at::DATE < CURRENT_DATE::DATE`, params: [] }; if (key === DATE_RANGES.LAST_WEEK) - return `AND t.completed_at::DATE >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND t.completed_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`; + return { clause: `AND t.completed_at::DATE >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND t.completed_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; if (key === DATE_RANGES.LAST_MONTH) - return `AND t.completed_at::DATE >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND t.completed_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`; + return { clause: `AND t.completed_at::DATE >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND t.completed_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; if (key === DATE_RANGES.LAST_QUARTER) - return `AND t.completed_at::DATE >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND t.completed_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`; + return { clause: `AND t.completed_at::DATE >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND t.completed_at::DATE < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; - return ""; + return { clause: "", params: [] }; } - protected static getOverdueClause(key: string, dateRange: string[]) { - + protected static getOverdueClause(key: string, dateRange: string[], paramOffset = 1): { clause: string; params: any[] } { if (dateRange.length === 2) { + // Use parameterized queries for dates const start = moment(dateRange[0]).format("YYYY-MM-DD"); const end = moment(dateRange[1]).format("YYYY-MM-DD"); if (start === end) { - return `AND t.end_date::DATE = '${start}'::DATE`; + return { + clause: `AND t.end_date::DATE = $${paramOffset}::DATE`, + params: [start] + }; } - return `AND t.end_date::DATE >= '${start}'::DATE AND t.end_date::DATE <= '${end}'::DATE`; + return { + clause: `AND t.end_date::DATE >= $${paramOffset}::DATE AND t.end_date::DATE <= $${paramOffset + 1}::DATE`, + params: [start, end] + }; } if (key === DATE_RANGES.YESTERDAY) - return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND t.end_date::DATE < NOW()::DATE`; + return { clause: `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND t.end_date::DATE < NOW()::DATE`, params: [] }; if (key === DATE_RANGES.LAST_WEEK) - return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND t.end_date::DATE < NOW()::DATE`; + return { clause: `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND t.end_date::DATE < NOW()::DATE`, params: [] }; if (key === DATE_RANGES.LAST_MONTH) - return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND t.end_date::DATE < NOW()::DATE`; + return { clause: `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND t.end_date::DATE < NOW()::DATE`, params: [] }; if (key === DATE_RANGES.LAST_QUARTER) - return `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND t.end_date::DATE < NOW()::DATE`; - + return { clause: `AND t.end_date::DATE >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND t.end_date::DATE < NOW()::DATE`, params: [] }; - return ` AND t.end_date::DATE < NOW()::DATE `; + return { clause: ` AND t.end_date::DATE < NOW()::DATE `, params: [] }; } protected static getTaskSelectorClause() { @@ -328,46 +427,91 @@ export default class ReportingMembersController extends ReportingControllerBaseW ((SELECT SUM(time_spent) FROM task_work_log twl WHERE twl.task_id = t.id AND twl.user_id = (SELECT user_id FROM team_members WHERE id = $1)) - (total_minutes * 60)) AS overlogged_time`; } - protected static getActivityLogsOverdue(key: string, dateRange: string[]) { - + protected static getActivityLogsOverdue(key: string, dateRange: string[], paramOffset = 1): { clause: string; params: any[] } { if (dateRange.length === 2) { + // Use parameterized queries for dates const end = moment(dateRange[1]).format("YYYY-MM-DD"); - return `AND is_overdue_for_date(t.id, '${end}'::DATE)`; + return { + clause: `AND is_overdue_for_date(t.id, $${paramOffset}::DATE)`, + params: [end] + }; } - return `AND is_overdue_for_date(t.id, NOW()::DATE)`; + return { clause: `AND is_overdue_for_date(t.id, NOW()::DATE)`, params: [] }; } - protected static getActivityLogsCreationClause(key: string, dateRange: string[]) { + protected static getActivityLogsCreationClause(key: string, dateRange: string[], paramOffset = 1): { clause: string; params: any[] } { if (dateRange.length === 2) { + // Use parameterized queries for dates const end = moment(dateRange[1]).format("YYYY-MM-DD"); - return `AND tl.created_at::DATE <= '${end}'::DATE`; + return { + clause: `AND tl.created_at::DATE <= $${paramOffset}::DATE`, + params: [end] + }; } - return `AND tl.created_at::DATE <= NOW()::DATE`; + return { clause: `AND tl.created_at::DATE <= NOW()::DATE`, params: [] }; } - protected static getDateRangeClauseMembers(key: string, dateRange: string[], tableAlias: string) { + protected static getDateRangeClauseMembers(key: string, dateRange: string[], tableAlias: string, paramOffset = 1): { clause: string; params: any[] } { if (dateRange.length === 2) { + // Use parameterized queries for dates const start = moment(dateRange[0]).format("YYYY-MM-DD"); const end = moment(dateRange[1]).format("YYYY-MM-DD"); if (start === end) { - return `AND ${tableAlias}.created_at::DATE = '${start}'::DATE`; + return { + clause: `AND ${tableAlias}.created_at::DATE = $${paramOffset}::DATE`, + params: [start] + }; } - return `AND ${tableAlias}.created_at::DATE >= '${start}'::DATE AND ${tableAlias}.created_at < '${end}'::DATE + INTERVAL '1 day'`; + return { + clause: `AND ${tableAlias}.created_at::DATE >= $${paramOffset}::DATE AND ${tableAlias}.created_at < $${paramOffset + 1}::DATE + INTERVAL '1 day'`, + params: [start, end] + }; } if (key === DATE_RANGES.YESTERDAY) - return `AND ${tableAlias}.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND ${tableAlias}.created_at < CURRENT_DATE::DATE`; + return { clause: `AND ${tableAlias}.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND ${tableAlias}.created_at < CURRENT_DATE::DATE`, params: [] }; if (key === DATE_RANGES.LAST_WEEK) - return `AND ${tableAlias}.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND ${tableAlias}.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`; + return { clause: `AND ${tableAlias}.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND ${tableAlias}.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; if (key === DATE_RANGES.LAST_MONTH) - return `AND ${tableAlias}.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND ${tableAlias}.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`; + return { clause: `AND ${tableAlias}.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND ${tableAlias}.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; if (key === DATE_RANGES.LAST_QUARTER) - return `AND ${tableAlias}.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND ${tableAlias}.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`; + return { clause: `AND ${tableAlias}.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND ${tableAlias}.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; - return ""; + return { clause: "", params: [] }; + } + + protected static getTimeLogDateRangeClause(key: string, dateRange: string[], paramOffset = 1): { clause: string; params: any[] } { + if (dateRange.length === 2) { + // Use parameterized queries for dates + const start = moment(dateRange[0]).format("YYYY-MM-DD"); + const end = moment(dateRange[1]).format("YYYY-MM-DD"); + + if (start === end) { + return { + clause: `AND twl.created_at::DATE = $${paramOffset}::DATE`, + params: [start] + }; + } + + return { + clause: `AND twl.created_at::DATE >= $${paramOffset}::DATE AND twl.created_at < $${paramOffset + 1}::DATE + INTERVAL '1 day'`, + params: [start, end] + }; + } + + if (key === DATE_RANGES.YESTERDAY) + return { clause: `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::DATE AND twl.created_at < CURRENT_DATE::DATE`, params: [] }; + if (key === DATE_RANGES.LAST_WEEK) + return { clause: `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; + if (key === DATE_RANGES.LAST_MONTH) + return { clause: `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; + if (key === DATE_RANGES.LAST_QUARTER) + return { clause: `AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'`, params: [] }; + + return { clause: "", params: [] }; } private static formatDuration(duration: moment.Duration) { @@ -395,7 +539,8 @@ export default class ReportingMembersController extends ReportingControllerBaseW @HandleExceptions() public static async getReportingMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const { searchQuery, size, offset } = this.toPaginationOptions(req.query, ["name"]); + // teamId is $1, so search params start at $2 + const { searchQuery, searchParams, size, offset } = this.toPaginationOptions(req.query, ["tmiv.name"], false, 2); const { duration, date_range } = req.query; const archived = req.query.archived === "true"; @@ -404,13 +549,18 @@ export default class ReportingMembersController extends ReportingControllerBaseW dateRange = date_range.split(","); } - const teamsClause = - req.query.teams as string - ? `AND tmiv.team_id IN (${this.flatString(req.query.teams as string)})` - : ""; + let teamsClause = ""; + let teamIdsParams: string[] = []; + if (req.query.teams) { + const teamIds = (req.query.teams as string).split(" ").filter(id => id.trim()); + // Parameters will be added after searchParams, so offset = 2 + searchParams.length + const { clause } = SqlHelper.buildInClause(teamIds, 2 + searchParams.length); + teamsClause = `AND tmiv.team_id IN (${clause})`; + teamIdsParams = teamIds; + } const teamId = this.getCurrentTeamId(req); - const result = await this.getMembers(teamId as string, searchQuery, size, offset, teamsClause, duration as string, dateRange, archived, req.user?.id as string); + const result = await this.getMembers(teamId as string, searchQuery, searchParams, size, offset, teamsClause, teamIdsParams, duration as string, dateRange, archived, req.user?.id as string, req); const body = { total: result.total, members: result.members, @@ -442,7 +592,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW const teamId = this.getCurrentTeamId(req); const teamName = (req.query.team_name as string)?.trim() || null; - const result = await this.getMembers(teamId as string, "", null, null, "", duration as string, dateRange, archived, req.user?.id as string); + const result = await this.getMembers(teamId as string, "", [], null, null, "", [], duration as string, dateRange, archived, req.user?.id as string, req); let start = "-"; let end = "-"; @@ -482,6 +632,8 @@ export default class ReportingMembersController extends ReportingControllerBaseW { header: "Overdue Tasks", key: "overdue_tasks", width: 20 }, { header: "Completed Tasks", key: "completed_tasks", width: 20 }, { header: "Ongoing Tasks", key: "ongoing_tasks", width: 20 }, + { header: "Billable Time (seconds)", key: "billable_time", width: 25 }, + { header: "Non-Billable Time (seconds)", key: "non_billable_time", width: 25 }, { header: "Done Tasks(%)", key: "done_tasks", width: 20 }, { header: "Doing Tasks(%)", key: "doing_tasks", width: 20 }, { header: "Todo Tasks(%)", key: "todo_tasks", width: 20 } @@ -506,7 +658,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW sheet.mergeCells("A3:D3"); // set table headers - sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"]; + sheet.getRow(5).values = ["Member", "Email", "Tasks Assigned", "Overdue Tasks", "Completed Tasks", "Ongoing Tasks", "Billable Time (seconds)", "Non-Billable Time (seconds)", "Done Tasks(%)", "Doing Tasks(%)", "Todo Tasks(%)"]; sheet.getRow(5).font = { bold: true }; for (const member of result.members) { @@ -517,6 +669,8 @@ export default class ReportingMembersController extends ReportingControllerBaseW overdue_tasks: member.overdue, completed_tasks: member.completed, ongoing_tasks: member.ongoing, + billable_time: member.billable_time || 0, + non_billable_time: member.non_billable_time || 0, done_tasks: member.completed, doing_tasks: member.ongoing_by_activity_logs, todo_tasks: member.todo_by_activity_logs @@ -548,11 +702,38 @@ export default class ReportingMembersController extends ReportingControllerBaseW // Get user timezone for proper date filtering const userTimezone = await this.getUserTimezone(req.user?.id as string); - const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone); - const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log"); + // $1 => team_id, $2 => team_member_id, so date params start at $3 + const durationClauseResult = this.getDateRangeClauseWithTimezoneParams( + (duration as string) || DATE_RANGES.LAST_WEEK, + dateRange, + userTimezone, + 3 + ); + const durationClause = durationClauseResult.clause; + const durationParams = durationClauseResult.params; + + const minMaxDateClauseResult = this.getMinMaxDates( + (duration as string) || DATE_RANGES.LAST_WEEK, + dateRange, + "task_work_log", + 3 + durationParams.length + ); + const minMaxDateClause = minMaxDateClauseResult.clause; + const minMaxParams = minMaxDateClauseResult.params; const memberName = (req.query.member_name as string)?.trim() || null; - const logGroups = await this.memberTimeLogsData(durationClause, minMaxDateClause, team_id as string, team_member_id as string, includeArchived, req.user?.id as string); + const queryParams = [...durationParams, ...minMaxParams]; + + const logGroups = await this.memberTimeLogsData( + durationClause, + minMaxDateClause, + team_id as string, + team_member_id as string, + includeArchived, + req.user?.id as string, + "", + queryParams + ); let start = "-"; let end = "-"; @@ -642,11 +823,31 @@ export default class ReportingMembersController extends ReportingControllerBaseW dateRange = date_range.split(","); } - const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "tal"); - const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_activity_logs"); + // Use parameterized queries + // $1 => team_id, $2 => team_member_id, so date params start at $3 + const durationClauseResult = ReportingMembersController.getDateRangeClauseMembers( + duration as string || DATE_RANGES.LAST_WEEK, + dateRange, + "tal", + 3 + ); + const durationClause = durationClauseResult.clause; + const durationParams = durationClauseResult.params; + + const minMaxDateClauseResult = this.getMinMaxDates( + duration as string || DATE_RANGES.LAST_WEEK, + dateRange, + "task_activity_logs", + 3 + durationParams.length + ); + const minMaxDateClause = minMaxDateClauseResult.clause; + const minMaxParams = minMaxDateClauseResult.params; + const memberName = (req.query.member_name as string)?.trim() || null; - const logGroups = await this.memberActivityLogsData(durationClause, minMaxDateClause, team_id as string, team_member_id as string, includeArchived, req.user?.id as string); + // Combine all parameters for the query + const allParams = [...durationParams, ...minMaxParams]; + const logGroups = await this.memberActivityLogsData(durationClause, minMaxDateClause, team_id as string, team_member_id as string, includeArchived, req.user?.id as string, allParams); let start = "-"; let end = "-"; @@ -746,17 +947,33 @@ export default class ReportingMembersController extends ReportingControllerBaseW } - public static async getMemberProjectsData(teamId: string, teamMemberId: string, searchQuery: string, archived: boolean, userId: string) { + public static async getMemberProjectsData(teamId: string, teamMemberId: string, searchQuery: string, searchParams: string[] = [], archived: boolean, userId: string, req?: any) { + + // Build parameterized queries to prevent SQL injection + let paramOffset = searchParams.length + 1; + const additionalParams: any[] = []; const teamClause = teamId - ? `team_member_id = '${teamMemberId as string}'` + ? `team_member_id = $${paramOffset++}` : `team_member_id IN (SELECT team_member_id FROM team_member_info_view tmiv - WHERE email = (SELECT email + WHERE LOWER(email) = LOWER((SELECT email FROM team_member_info_view tmiv2 - WHERE tmiv2.team_member_id = '${teamMemberId}' AND in_organization(p.team_id, tmiv2.team_id)))`; + WHERE tmiv2.team_member_id = $${paramOffset++} AND in_organization(p.team_id, tmiv2.team_id))))`; + additionalParams.push(teamMemberId); - const archivedClause = archived ? `` : ` AND pm.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = pm.project_id AND user_id = '${userId}')`; + let archivedClause = ""; + if (!archived) { + archivedClause = ` AND pm.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = pm.project_id AND user_id = $${paramOffset++})`; + additionalParams.push(userId); + } + + // Add project filtering for Team Leads + let projectFilterClause = ""; + if (req) { + const filter = await ReportingControllerBase.buildProjectFilterForTeamLead(req); + projectFilterClause = filter.replace('p.id', 'pm.project_id'); + } const q = `SELECT p.id, p.name, pm.team_member_id, (SELECT name FROM teams WHERE id = p.team_id) AS team, @@ -795,9 +1012,9 @@ export default class ReportingMembersController extends ReportingControllerBaseW AND user_id = (SELECT user_id FROM team_member_info_view tmiv WHERE pm.team_member_id = tmiv.team_member_id)) AS time_logged FROM project_members pm LEFT JOIN projects p ON p.id = pm.project_id - WHERE ${teamClause} ${searchQuery} ${archivedClause} + WHERE ${teamClause} ${searchQuery} ${archivedClause} ${projectFilterClause} ORDER BY name;`; - const result = await db.query(q, []); + const result = await db.query(q, [...searchParams, ...additionalParams]); for (const project of result.rows) { project.time_logged = formatDuration(moment.duration(project.time_logged, "seconds")); @@ -809,35 +1026,40 @@ export default class ReportingMembersController extends ReportingControllerBaseW @HandleExceptions() public static async getMemberProjects(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const { searchQuery } = this.toPaginationOptions(req.query, ["p.name"]); + // No other parameters before search params, so they start at $1 + const { searchQuery, searchParams } = this.toPaginationOptions(req.query, ["p.name"], false, 1); const { teamMemberId, teamId } = req.query; const archived = req.query.archived === "true"; - const result = await this.getMemberProjectsData(teamId as string, teamMemberId as string, searchQuery, archived, req.user?.id as string); + const result = await this.getMemberProjectsData(teamId as string, teamMemberId as string, searchQuery, searchParams, archived, req.user?.id as string, req); return res.status(200).send(new ServerResponse(true, result)); } - protected static getMinMaxDates(key: string, dateRange: string[], tableName: string) { + protected static getMinMaxDates(key: string, dateRange: string[], tableName: string, paramOffset = 1): { clause: string; params: any[] } { if (dateRange.length === 2) { + // Use parameterized queries for dates const start = moment(dateRange[0]).format("YYYY-MM-DD"); const end = moment(dateRange[1]).format("YYYY-MM-DD"); - return `,(SELECT '${start}'::DATE )AS start_date, (SELECT '${end}'::DATE )AS end_date`; + return { + clause: `,(SELECT $${paramOffset}::DATE )AS start_date, (SELECT $${paramOffset + 1}::DATE )AS end_date`, + params: [start, end] + }; } if (key === DATE_RANGES.YESTERDAY) - return `,(SELECT (CURRENT_DATE - INTERVAL '1 day')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date`; + return { clause: `,(SELECT (CURRENT_DATE - INTERVAL '1 day')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date`, params: [] }; if (key === DATE_RANGES.LAST_WEEK) - return `,(SELECT (CURRENT_DATE - INTERVAL '1 week')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date`; + return { clause: `,(SELECT (CURRENT_DATE - INTERVAL '1 week')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date`, params: [] }; if (key === DATE_RANGES.LAST_MONTH) - return `,(SELECT (CURRENT_DATE - INTERVAL '1 month')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date`; + return { clause: `,(SELECT (CURRENT_DATE - INTERVAL '1 month')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date`, params: [] }; if (key === DATE_RANGES.LAST_QUARTER) - return `,(SELECT (CURRENT_DATE - INTERVAL '3 months')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date`; + return { clause: `,(SELECT (CURRENT_DATE - INTERVAL '3 months')::DATE) AS start_date, (SELECT (CURRENT_DATE)::DATE) AS end_date`, params: [] }; if (key === DATE_RANGES.ALL_TIME) - return `,(SELECT (MIN(created_at)::DATE) FROM ${tableName} WHERE task_id IN (SELECT id FROM tasks WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1))) AS start_date, (SELECT (MAX(created_at)::DATE) FROM ${tableName} WHERE task_id IN (SELECT id FROM tasks WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1))) AS end_date`; + return { clause: `,(SELECT (MIN(created_at)::DATE) FROM ${tableName} WHERE task_id IN (SELECT id FROM tasks WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1))) AS start_date, (SELECT (MAX(created_at)::DATE) FROM ${tableName} WHERE task_id IN (SELECT id FROM tasks WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1))) AS end_date`, params: [] }; - return ""; + return { clause: "", params: [] }; } @@ -846,10 +1068,29 @@ export default class ReportingMembersController extends ReportingControllerBaseW public static async getMemberActivities(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const { team_member_id, team_id, duration, date_range, archived } = req.body; - const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration || DATE_RANGES.LAST_WEEK, date_range, "tal"); - const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_activity_logs"); - - const logGroups = await this.memberActivityLogsData(durationClause, minMaxDateClause, team_id, team_member_id, archived, req.user?.id as string); + // Use parameterized queries + // $1 => team_id, $2 => team_member_id, so date params start at $3 + const durationClauseResult = ReportingMembersController.getDateRangeClauseMembers( + duration || DATE_RANGES.LAST_WEEK, + date_range, + "tal", + 3 + ); + const durationClause = durationClauseResult.clause; + const durationParams = durationClauseResult.params; + + const minMaxDateClauseResult = this.getMinMaxDates( + duration || DATE_RANGES.LAST_WEEK, + date_range, + "task_activity_logs", + 3 + durationParams.length + ); + const minMaxDateClause = minMaxDateClauseResult.clause; + const minMaxParams = minMaxDateClauseResult.params; + + // Combine all parameters for the query + const allParams = [...durationParams, ...minMaxParams]; + const logGroups = await this.memberActivityLogsData(durationClause, minMaxDateClause, team_id, team_member_id, archived, req.user?.id as string, allParams); return res.status(200).send(new ServerResponse(true, logGroups)); } @@ -923,11 +1164,23 @@ export default class ReportingMembersController extends ReportingControllerBaseW } - private static async memberTimeLogsData(durationClause: string, minMaxDateClause: string, team_id: string, team_member_id: string, includeArchived: boolean, userId: string, billableQuery = "") { + private static async memberTimeLogsData( + durationClause: string, + minMaxDateClause: string, + team_id: string, + team_member_id: string, + includeArchived: boolean, + userId: string, + billableQuery = "", + params: any[] = [] + ) { + // Build archived clause with parameterized userId + // 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 = '${userId}')`; + : `AND project_id NOT IN (SELECT project_id FROM archived_projects WHERE archived_projects.user_id = $${userIdParamIndex})`; const q = ` SELECT user_id, @@ -953,7 +1206,10 @@ export default class ReportingMembersController extends ReportingControllerBaseW AND tmiv.team_member_id = $2 `; - const result = await db.query(q, [team_id, team_member_id]); + const queryParams = includeArchived + ? [team_id, team_member_id, ...params] + : [team_id, team_member_id, ...params, userId]; + const result = await db.query(q, queryParams); let logGroups: any[] = []; @@ -968,9 +1224,14 @@ 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) { + private static async memberActivityLogsData(durationClause: string, minMaxDateClause: string, team_id: string, team_member_id: string, includeArchived:boolean, userId: string, params: any[]) { - const archivedClause = includeArchived ? `` : `AND (SELECT project_id FROM tasks WHERE id = tal.task_id) NOT IN (SELECT project_id FROM archived_projects WHERE archived_projects.user_id = '${userId}')`; + let archivedClause = ""; + let archivedParams: any[] = []; + if (!includeArchived) { + archivedClause = `AND (SELECT project_id FROM tasks WHERE id = tal.task_id) NOT IN (SELECT project_id FROM archived_projects WHERE archived_projects.user_id = $${params.length + 3})`; + archivedParams = [userId]; + } const q = ` SELECT user_id, @@ -1064,7 +1325,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW AND tmiv.team_member_id = $2 `; - const result = await db.query(q, [team_id, team_member_id]); + const result = await db.query(q, [team_id, team_member_id, ...params, ...archivedParams]); let logGroups: any[] = []; @@ -1078,7 +1339,7 @@ export default class ReportingMembersController extends ReportingControllerBaseW } - protected static buildBillableQuery(selectedStatuses: { billable: boolean; nonBillable: boolean }): string { + protected static buildBillableQuery(selectedStatuses: { billable: boolean; nonBillable: boolean }, tableAlias = "tasks"): string { const { billable, nonBillable } = selectedStatuses; if (billable && nonBillable) { @@ -1086,10 +1347,10 @@ export default class ReportingMembersController extends ReportingControllerBaseW return ""; } else if (billable) { // Only billable is enabled - return " AND tasks.billable IS TRUE"; + return ` AND ${tableAlias}.billable IS TRUE`; } else if (nonBillable) { // Only non-billable is enabled - return " AND tasks.billable IS FALSE"; + return ` AND ${tableAlias}.billable IS FALSE`; } return ""; @@ -1101,12 +1362,38 @@ export default class ReportingMembersController extends ReportingControllerBaseW // Get user timezone for proper date filtering const userTimezone = await this.getUserTimezone(req.user?.id as string); - const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone); - const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log"); + // $1 => team_id, $2 => team_member_id, so date params start at $3 + const durationClauseResult = this.getDateRangeClauseWithTimezoneParams( + duration || DATE_RANGES.LAST_WEEK, + date_range, + userTimezone, + 3 + ); + const durationClause = durationClauseResult.clause; + const durationParams = durationClauseResult.params; + + const minMaxDateClauseResult = this.getMinMaxDates( + duration || DATE_RANGES.LAST_WEEK, + date_range, + "task_work_log", + 3 + durationParams.length + ); + const minMaxDateClause = minMaxDateClauseResult.clause; + const minMaxParams = minMaxDateClauseResult.params; const billableQuery = this.buildBillableQuery(billable); - - const logGroups = await this.memberTimeLogsData(durationClause, minMaxDateClause, team_id, team_member_id, archived, req.user?.id as string, billableQuery); + const queryParams = [...durationParams, ...minMaxParams]; + + const logGroups = await this.memberTimeLogsData( + durationClause, + minMaxDateClause, + team_id, + team_member_id, + archived, + req.user?.id as string, + billableQuery, + queryParams + ); return res.status(200).send(new ServerResponse(true, logGroups)); } @@ -1127,11 +1414,29 @@ export default class ReportingMembersController extends ReportingControllerBaseW : `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}')`; - const assignClause = this.memberAssignDurationFilter(duration as string, dateRange); - const completedDurationClasue = this.completedDurationFilter(duration as string, dateRange); - const overdueClauseByDate = this.getActivityLogsOverdue(duration as string, dateRange); + // Use parameterized queries + // Note: $1 is used for team_member_id, so parameter offsets start from 2 + const assignClauseResult = this.memberAssignDurationFilter(duration as string, dateRange, 2); + 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 durationFilter = this.memberTasksDurationFilter(duration as string, dateRange); + + const durationFilterResult = this.memberTasksDurationFilter(duration as string, dateRange, paramOffset); + const durationFilter = durationFilterResult.clause; + const durationParams = durationFilterResult.params; + paramOffset += durationParams.length; const q = ` SELECT name AS team_member_name, @@ -1172,7 +1477,9 @@ export default class ReportingMembersController extends ReportingControllerBaseW FROM team_member_info_view WHERE team_member_id = $1; `; - const result = await db.query(q, [team_member_id]); + // Pass all parameters + const queryParams = [team_member_id, ...assignParams, ...completedParams, ...overdueParams, ...durationParams]; + const result = await db.query(q, queryParams); const [data] = result.rows; if (data) { @@ -1215,6 +1522,340 @@ export default class ReportingMembersController extends ReportingControllerBaseW return res.status(200).send(new ServerResponse(true, body)); } + @HandleExceptions() + public static async getTimelogsFlat(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { team_member_id, duration, date_range, billable, search } = req.body || {}; + + // Get the team_id from request user + const teamId = req.user?.team_id; + + // 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) { + const startDate = moment(date_range[0]).format('YYYY-MM-DD HH:mm:ss'); + const endDate = moment(date_range[1]).add(1, 'day').format('YYYY-MM-DD HH:mm:ss'); + durationClause = `AND twl.created_at >= $${paramIndex}::TIMESTAMP AND twl.created_at < $${paramIndex + 1}::TIMESTAMP`; + params.push(startDate, endDate); + paramIndex += 2; + } else { + // Use default duration logic if no date_range provided + const rawDurationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone); + // This method returns hardcoded $1, $2 - we need to extract the logic or handle it differently + // For now, use a simpler approach for common cases + if (!duration || duration === DATE_RANGES.LAST_WEEK) { + durationClause = `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::TIMESTAMP`; + } else if (duration === DATE_RANGES.YESTERDAY) { + durationClause = `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::TIMESTAMP AND twl.created_at < CURRENT_DATE::TIMESTAMP`; + } else if (duration === DATE_RANGES.LAST_MONTH) { + durationClause = `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::TIMESTAMP`; + } else if (duration === DATE_RANGES.LAST_QUARTER) { + durationClause = `AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::TIMESTAMP`; + } + } + + const billableQuery = this.buildBillableQuery(billable || { billable: true, nonBillable: true }, "t"); + + // Team filter - only show logs from current team if team_id is available + let teamFilter = ''; + + if (teamId) { + teamFilter = `AND p.team_id = $${paramIndex}`; + params.push(teamId); + paramIndex++; + } + + // Optional member filter + const memberFilter = team_member_id ? `AND u.id = (SELECT user_id FROM team_members WHERE id = $${paramIndex})` : ''; + if (team_member_id) { + params.push(team_member_id); + paramIndex++; + } + + // Optional search filter (task, project, member, description) + const searchFilter = search ? `AND ( + LOWER(t.name) LIKE LOWER($${paramIndex}) OR + LOWER(p.name) LIKE LOWER($${paramIndex}) OR + LOWER(u.name) LIKE LOWER($${paramIndex}) OR + LOWER(COALESCE(twl.description, '')) LIKE LOWER($${paramIndex}) + )` : ''; + if (search) { + params.push(`%${search}%`); + } + + const q = ` + SELECT + (twl.created_at AT TIME ZONE 'UTC' AT TIME ZONE $1)::DATE AS log_day, + u.name AS user_name, + p.name AS project_name, + t.name AS task_name, + twl.time_spent, + twl.description + FROM task_work_log twl + JOIN tasks t ON t.id = twl.task_id + JOIN projects p ON p.id = t.project_id + JOIN users u ON u.id = twl.user_id + WHERE 1=1 + ${teamFilter} + ${memberFilter} + ${durationClause} + ${billableQuery} + ${searchFilter} + ORDER BY log_day DESC, user_name ASC`; + + const rows = await db.query(q, params); + + // Group rows by day + const groups: any[] = []; + const byDay: Record = {}; + for (const r of rows.rows) { + if (!byDay[r.log_day]) byDay[r.log_day] = []; + byDay[r.log_day].push({ + user_name: r.user_name, + project_name: r.project_name, + task_name: r.task_name, + time_spent_string: this.secondsToReadable(r.time_spent || 0), + description: r.description || null, + }); + } + for (const day of Object.keys(byDay).sort((a, b) => (a < b ? 1 : -1))) { + groups.push({ log_day: day, logs: byDay[day] }); + } + + return res.status(200).send(new ServerResponse(true, groups)); + } + + @HandleExceptions() + public static async exportTimelogsFlatCSV(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + let { team_member_id, duration, date_range, billable, search } = req.query; + + // Convert query parameters to strings or undefined + const teamMemberIdStr = this.convertQueryParam(team_member_id); + const durationStr = this.convertQueryParam(duration); + const dateRangeStr = this.convertQueryParam(date_range); + const billableStr = this.convertQueryParam(billable); + const searchStr = this.convertQueryParam(search); + + // Get data using shared helper method + const rows = await this.getTimelogsFlatData(req, teamMemberIdStr, durationStr, dateRangeStr, billableStr, searchStr); + + // Prepare CSV data + const exportDate = moment().format("MMM-DD-YYYY"); + const fileName = `Time-Logs-${exportDate}`; + + // Build CSV content + const csvRows: string[] = []; + + // Add headers + csvRows.push("Date,Member,Project,Task,Description,Duration"); + + // Add data rows + for (const row of rows.rows) { + const date = row.log_day || ""; + const member = (row.user_name || "").replace(/"/g, '""'); // Escape quotes + const project = (row.project_name || "").replace(/"/g, '""'); + const task = (row.task_name || "").replace(/"/g, '""'); + const description = (row.description || "").replace(/"/g, '""'); + const duration = this.secondsToReadable(row.time_spent || 0); + + csvRows.push(`"${date}","${member}","${project}","${task}","${description}","${duration}"`); + } + + const csvContent = csvRows.join("\n"); + + // Set response headers for CSV + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="${fileName}.csv"`); + + // Add BOM for better Excel compatibility + res.write('\uFEFF' + csvContent); + res.end(); + } + + private static secondsToReadable(totalSeconds: number): string { + const sec = Math.max(0, Math.floor(totalSeconds || 0)); + const hours = Math.floor(sec / 3600); + const minutes = Math.floor((sec % 3600) / 60); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; + } + + /** + * Helper function to convert query parameters to strings or undefined + */ + private static convertQueryParam(param: any): string | undefined { + if (Array.isArray(param)) { + return param[0] ? String(param[0]) : undefined; + } + return param ? String(param) : undefined; + } + + /** + * Shared helper method to fetch timelogs data for both CSV and Excel exports + */ + private static async getTimelogsFlatData(req: IWorkLenzRequest, team_member_id?: string, duration?: string, date_range?: string, billable?: string, search?: string): Promise { + // Get the team_id from request user + const teamId = req.user?.team_id; + + let dateRange: string[] = []; + if (typeof date_range === "string") { + dateRange = date_range.split(","); + } + + // 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) { + const startDate = moment(dateRange[0]).format('YYYY-MM-DD HH:mm:ss'); + const endDate = moment(dateRange[1]).add(1, 'day').format('YYYY-MM-DD HH:mm:ss'); + durationClause = `AND twl.created_at >= $${paramIndex}::TIMESTAMP AND twl.created_at < $${paramIndex + 1}::TIMESTAMP`; + params.push(startDate, endDate); + paramIndex += 2; + } else { + // Use default duration logic if no date_range provided + if (!duration || duration === DATE_RANGES.LAST_WEEK) { + durationClause = `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 week')::TIMESTAMP`; + } else if (duration === DATE_RANGES.YESTERDAY) { + durationClause = `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 day')::TIMESTAMP AND twl.created_at < CURRENT_DATE::TIMESTAMP`; + } else if (duration === DATE_RANGES.LAST_MONTH) { + durationClause = `AND twl.created_at >= (CURRENT_DATE - INTERVAL '1 month')::TIMESTAMP`; + } else if (duration === DATE_RANGES.LAST_QUARTER) { + durationClause = `AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::TIMESTAMP`; + } + } + + // Parse billable filter + let billableFilter = { billable: true, nonBillable: true }; + if (typeof billable === "string") { + try { + billableFilter = JSON.parse(billable); + } catch (e) { + // Use default + } + } + const billableQuery = this.buildBillableQuery(billableFilter, "t"); + + // Team filter - only show logs from current team if team_id is available + let teamFilter = ''; + if (teamId) { + teamFilter = `AND p.team_id = $${paramIndex}`; + params.push(teamId); + paramIndex++; + } + + // Optional member filter + const memberFilter = team_member_id ? `AND u.id = (SELECT user_id FROM team_members WHERE id = $${paramIndex})` : ''; + if (team_member_id) { + params.push(team_member_id); + paramIndex++; + } + + // Optional search filter + const searchFilter = search ? `AND ( + LOWER(t.name) LIKE LOWER($${paramIndex}) OR + LOWER(p.name) LIKE LOWER($${paramIndex}) OR + LOWER(u.name) LIKE LOWER($${paramIndex}) OR + LOWER(COALESCE(twl.description, '')) LIKE LOWER($${paramIndex}) + )` : ''; + if (search) { + params.push(`%${search}%`); + } + + const q = ` + SELECT + (twl.created_at AT TIME ZONE 'UTC' AT TIME ZONE $1)::DATE AS log_day, + u.name AS user_name, + p.name AS project_name, + t.name AS task_name, + twl.time_spent, + twl.description + FROM task_work_log twl + JOIN tasks t ON t.id = twl.task_id + JOIN projects p ON p.id = t.project_id + JOIN users u ON u.id = twl.user_id + WHERE 1=1 + ${teamFilter} + ${memberFilter} + ${durationClause} + ${billableQuery} + ${searchFilter} + ORDER BY log_day DESC, user_name ASC`; + + return await db.query(q, params); + } + + @HandleExceptions() + public static async exportTimelogsFlatExcel(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + let { team_member_id, duration, date_range, billable, search } = req.query; + + // Convert query parameters to strings or undefined + const teamMemberIdStr = this.convertQueryParam(team_member_id); + const durationStr = this.convertQueryParam(duration); + const dateRangeStr = this.convertQueryParam(date_range); + const billableStr = this.convertQueryParam(billable); + const searchStr = this.convertQueryParam(search); + + // Get data using shared helper method + const rows = await this.getTimelogsFlatData(req, teamMemberIdStr, durationStr, dateRangeStr, billableStr, searchStr); + + // Create Excel workbook + const workbook = new Excel.Workbook(); + const worksheet = workbook.addWorksheet('Time Logs'); + + // Add headers + worksheet.columns = [ + { header: 'Date', key: 'date', width: 15 }, + { header: 'Member', key: 'member', width: 20 }, + { header: 'Project', key: 'project', width: 25 }, + { header: 'Task', key: 'task', width: 30 }, + { header: 'Description', key: 'description', width: 40 }, + { header: 'Duration', key: 'duration', width: 15 } + ]; + + // Style the header row + worksheet.getRow(1).font = { bold: true }; + worksheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE6E6FA' } + }; + + // Add data rows + for (const row of rows.rows) { + worksheet.addRow({ + date: moment(row.log_day).format('MMM DD, YYYY'), + member: row.user_name || '', + project: row.project_name || '', + task: row.task_name || '', + description: row.description || '', + duration: this.secondsToReadable(row.time_spent || 0) + }); + } + + // Set response headers for Excel + const exportDate = moment().format("MMM-DD-YYYY"); + const fileName = `Time-Logs-${exportDate}.xlsx`; + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + + // Write Excel file to response + await workbook.xlsx.write(res); + res.end(); + } + private static updateTaskProperties(tasks: any[]) { for (const task of tasks) { task.project_color = getColor(task.project_name); @@ -1324,7 +1965,7 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen const teamName = (req.query.team_name as string)?.trim() || ""; const archived = req.query.archived === "true"; - const result = await this.getMemberProjectsData(teamId as string, teamMemberId as string, "", archived, req.user?.id as string); + const result = await this.getMemberProjectsData(teamId as string, teamMemberId as string, "", [], archived, req.user?.id as string, req); // excel file const exportDate = moment().format("MMM-DD-YYYY"); @@ -1392,4 +2033,4 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen } -} +} \ No newline at end of file diff --git a/worklenz-backend/src/controllers/schedule/schedule-controller.ts b/worklenz-backend/src/controllers/schedule/schedule-controller.ts index 212145b50..1496502c1 100644 --- a/worklenz-backend/src/controllers/schedule/schedule-controller.ts +++ b/worklenz-backend/src/controllers/schedule/schedule-controller.ts @@ -4,6 +4,7 @@ import HandleExceptions from "../../decorators/handle-exceptions"; import { IWorkLenzRequest } from "../../interfaces/worklenz-request"; import { IWorkLenzResponse } from "../../interfaces/worklenz-response"; import { ServerResponse } from "../../models/server-response"; +import { SqlHelper } from "../../shared/sql-helpers"; import { TASK_PRIORITY_COLOR_ALPHA, TASK_STATUS_COLOR_ALPHA, UNMAPPED } from "../../shared/constants"; import { getColor } from "../../shared/utils"; import moment, { Moment } from "moment"; @@ -130,39 +131,57 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; private static async getFirstLastDates(teamId: string, userId: string) { - const q = `SELECT MIN(LEAST(allocated_from, allocated_to)) AS start_date, - MAX(GREATEST(allocated_from, allocated_to)) AS end_date, - (SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) - FROM (SELECT MIN(min_date) AS start_date, MAX(max_date) AS end_date - FROM (SELECT MIN(start_date) AS min_date, MAX(start_date) AS max_date - FROM tasks - WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1) - AND project_id NOT IN - (SELECT project_id - FROM archived_projects - WHERE user_id = $2) - AND tasks.archived IS FALSE - UNION - SELECT MIN(end_date) AS min_date, MAX(end_date) AS max_date - FROM tasks - WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1) - AND project_id NOT IN - (SELECT project_id - FROM archived_projects - WHERE user_id = $2) - AND tasks.archived IS FALSE) AS dates) rec) AS date_union, - (SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) - FROM (SELECT MIN(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS start_date, - MAX(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS end_date - FROM task_work_log twl - INNER JOIN tasks t ON twl.task_id = t.id AND t.archived IS FALSE - WHERE t.project_id IN (SELECT id FROM projects WHERE team_id = $1) - AND project_id NOT IN - (SELECT project_id - FROM archived_projects - WHERE user_id = $2)) rec) AS logs_date_union - FROM project_member_allocations - WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1)`; + const q = `WITH all_member_dates AS ( + -- Get dates from project_member_allocations + SELECT allocated_from AS date_value, allocated_to AS date_value_2 + FROM project_member_allocations + WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1) + + UNION + + -- Get dates from task assignments + SELECT t.start_date AS date_value, t.end_date AS date_value_2 + FROM tasks t + INNER JOIN tasks_assignees ta ON t.id = ta.task_id + INNER JOIN project_members pm ON ta.project_member_id = pm.id + WHERE t.project_id IN (SELECT id FROM projects WHERE team_id = $1) + AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE user_id = $2) + AND t.archived IS FALSE + AND t.start_date IS NOT NULL + AND t.end_date IS NOT NULL + ) + SELECT MIN(LEAST(date_value, date_value_2)) AS start_date, + MAX(GREATEST(date_value, date_value_2)) AS end_date, + (SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) + FROM (SELECT MIN(min_date) AS start_date, MAX(max_date) AS end_date + FROM (SELECT MIN(start_date) AS min_date, MAX(start_date) AS max_date + FROM tasks + WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1) + AND project_id NOT IN + (SELECT project_id + FROM archived_projects + WHERE user_id = $2) + AND tasks.archived IS FALSE + UNION + SELECT MIN(end_date) AS min_date, MAX(end_date) AS max_date + FROM tasks + WHERE project_id IN (SELECT id FROM projects WHERE team_id = $1) + AND project_id NOT IN + (SELECT project_id + FROM archived_projects + WHERE user_id = $2) + AND tasks.archived IS FALSE) AS dates) rec) AS date_union, + (SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) + FROM (SELECT MIN(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS start_date, + MAX(twl.created_at - INTERVAL '1 second' * twl.time_spent) AS end_date + FROM task_work_log twl + INNER JOIN tasks t ON twl.task_id = t.id AND t.archived IS FALSE + WHERE t.project_id IN (SELECT id FROM projects WHERE team_id = $1) + AND project_id NOT IN + (SELECT project_id + FROM archived_projects + WHERE user_id = $2)) rec) AS logs_date_union + FROM all_member_dates`; const res = await db.query(q, [teamId, userId]); return res.rows[0]; @@ -349,13 +368,29 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; (SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) FROM (SELECT MIN(min_date) AS start_date, MAX(max_date) AS end_date - FROM (SELECT MIN(allocated_from) AS min_date, MAX(allocated_from) AS max_date + FROM ( + -- Dates from project_member_allocations + SELECT MIN(allocated_from) AS min_date, MAX(allocated_from) AS max_date FROM project_member_allocations WHERE project_id = p.id UNION SELECT MIN(allocated_to) AS min_date, MAX(allocated_to) AS max_date FROM project_member_allocations - WHERE project_id = p.id) AS dates) rec) AS date_union, + WHERE project_id = p.id + UNION + -- Dates from task assignments + SELECT MIN(t.start_date) AS min_date, MAX(t.start_date) AS max_date + FROM tasks t + WHERE t.project_id = p.id + AND t.archived IS FALSE + AND t.start_date IS NOT NULL + UNION + SELECT MIN(t.end_date) AS min_date, MAX(t.end_date) AS max_date + FROM tasks t + WHERE t.project_id = p.id + AND t.archived IS FALSE + AND t.end_date IS NOT NULL + ) AS dates) rec) AS date_union, (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON) FROM (SELECT pm.id AS project_member_id, @@ -458,13 +493,29 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; allocated_to, (SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) FROM (SELECT MIN(min_date) AS start_date, MAX(max_date) AS end_date - FROM (SELECT MIN(allocated_from) AS min_date, MAX(allocated_from) AS max_date + FROM ( + -- Dates from project_member_allocations + SELECT MIN(allocated_from) AS min_date, MAX(allocated_from) AS max_date FROM project_member_allocations WHERE project_id = $1 UNION SELECT MIN(allocated_to) AS min_date, MAX(allocated_to) AS max_date FROM project_member_allocations - WHERE project_id = $1) AS dates) rec) AS date_union + WHERE project_id = $1 + UNION + -- Dates from task assignments + SELECT MIN(t.start_date) AS min_date, MAX(t.start_date) AS max_date + FROM tasks t + WHERE t.project_id = $1 + AND t.archived IS FALSE + AND t.start_date IS NOT NULL + UNION + SELECT MIN(t.end_date) AS min_date, MAX(t.end_date) AS max_date + FROM tasks t + WHERE t.project_id = $1 + AND t.archived IS FALSE + AND t.end_date IS NOT NULL + ) AS dates) rec) AS date_union FROM project_member_allocations WHERE team_member_id = $2 AND project_id = $1`; @@ -502,13 +553,29 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; allocated_to, (SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) FROM (SELECT MIN(min_date) AS start_date, MAX(max_date) AS end_date - FROM (SELECT MIN(allocated_from) AS min_date, MAX(allocated_from) AS max_date + FROM ( + -- Dates from project_member_allocations + SELECT MIN(allocated_from) AS min_date, MAX(allocated_from) AS max_date FROM project_member_allocations WHERE project_id = $1 UNION SELECT MIN(allocated_to) AS min_date, MAX(allocated_to) AS max_date FROM project_member_allocations - WHERE project_id = $1) AS dates) rec) AS date_union + WHERE project_id = $1 + UNION + -- Dates from task assignments + SELECT MIN(t.start_date) AS min_date, MAX(t.start_date) AS max_date + FROM tasks t + WHERE t.project_id = $1 + AND t.archived IS FALSE + AND t.start_date IS NOT NULL + UNION + SELECT MIN(t.end_date) AS min_date, MAX(t.end_date) AS max_date + FROM tasks t + WHERE t.project_id = $1 + AND t.archived IS FALSE + AND t.end_date IS NOT NULL + ) AS dates) rec) AS date_union FROM project_member_allocations WHERE project_id = $1`; @@ -550,13 +617,29 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; allocated_to, (SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON) FROM (SELECT MIN(min_date) AS start_date, MAX(max_date) AS end_date - FROM (SELECT MIN(allocated_from) AS min_date, MAX(allocated_from) AS max_date + FROM ( + -- Dates from project_member_allocations + SELECT MIN(allocated_from) AS min_date, MAX(allocated_from) AS max_date FROM project_member_allocations WHERE project_id = $1 UNION SELECT MIN(allocated_to) AS min_date, MAX(allocated_to) AS max_date FROM project_member_allocations - WHERE project_id = $1) AS dates) rec) AS date_union + WHERE project_id = $1 + UNION + -- Dates from task assignments + SELECT MIN(t.start_date) AS min_date, MAX(t.start_date) AS max_date + FROM tasks t + WHERE t.project_id = $1 + AND t.archived IS FALSE + AND t.start_date IS NOT NULL + UNION + SELECT MIN(t.end_date) AS min_date, MAX(t.end_date) AS max_date + FROM tasks t + WHERE t.project_id = $1 + AND t.archived IS FALSE + AND t.end_date IS NOT NULL + ) AS dates) rec) AS date_union FROM project_member_allocations WHERE team_member_id = $2 AND project_id = $1`; @@ -668,9 +751,20 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; @HandleExceptions() public static async deleteMemberAllocations(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const ids = req.body.toString() as string; - const q = `DELETE FROM project_member_allocations WHERE id IN (${(ids || "").split(",").map(s => `'${s}'`).join(",")})`; - await db.query(q); + // Use parameterized queries for DELETE statement + 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); return res.status(200).send(new ServerResponse(true, [])); } @@ -684,14 +778,15 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; return ScheduleControllerV2.isCountsOnly(query) || query.parent_task; } - private static flatString(text: string) { - return (text || "").split(" ").map(s => `'${s}'`).join(","); - } - - private static getFilterByMembersWhereClosure(text: string) { - return text - ? `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id IN (${this.flatString(text)}))` - : ""; + private static getFilterByMembersWhereClosure(text: string, paramOffset: number): { clause: string; params: string[] } { + if (!text) return { clause: "", params: [] }; + const memberIds = text.split(" ").filter(id => id.trim()); + const { clause } = SqlHelper.buildInClause(memberIds, paramOffset); + const fullClause = `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id IN (${clause}))`; + return { + clause: fullClause, + params: memberIds + }; } private static getStatusesQuery(filterBy: string) { @@ -720,18 +815,46 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; } } - private static getQuery(userId: string, options: ParsedQs) { + private static getQuery(userId: string, projectId: string, options: ParsedQs): { query: string; params: any[] } { const searchField = options.search ? "t.name" : "sort_order"; const { searchQuery, sortField } = ScheduleControllerV2.toPaginationOptions(options, searchField); const isSubTasks = !!options.parent_task; + // Start with projectId as $1 + const queryParams: any[] = [projectId]; + let paramOffset = 2; // Next param will be $2 + + // Add parent_task_id if this is subtasks query + if (isSubTasks && options.parent_task) { + queryParams.push(options.parent_task as string); + paramOffset++; + } + const sortFields = sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || "sort_order"; - const membersFilter = ScheduleControllerV2.getFilterByMembersWhereClosure(options.members as string); + const membersResult = ScheduleControllerV2.getFilterByMembersWhereClosure(options.members as string, paramOffset); + if (membersResult.params.length > 0) { + queryParams.push(...membersResult.params); + paramOffset += membersResult.params.length; + } + const membersFilter = membersResult.clause; const statusesQuery = ScheduleControllerV2.getStatusesQuery(options.filterBy as string); const archivedFilter = options.archived === "true" ? "archived IS TRUE" : "archived IS FALSE"; + // Date range filtering + let dateRangeFilter = ""; + if (options.startDate && options.endDate) { + // Filter tasks that overlap with the selected date range + // A task overlaps if: task.start_date <= range.endDate AND task.end_date >= range.startDate + dateRangeFilter = `( + (start_date IS NOT NULL AND end_date IS NOT NULL) AND + (start_date <= $${paramOffset} AND end_date >= $${paramOffset + 1}) + )`; + queryParams.push(options.endDate as string); + queryParams.push(options.startDate as string); + paramOffset += 2; + } let subTasksFilter; @@ -744,12 +867,35 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; const filters = [ subTasksFilter, (isSubTasks ? "1 = 1" : archivedFilter), - membersFilter + membersFilter, + dateRangeFilter ].filter(i => !!i).join(" AND "); - return ` + // 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()); + if (memberIds.length > 0) { + // Create placeholders for member IDs in the time spent query + const memberPlaceholders = memberIds.map((_, index) => `$${paramOffset + index}`).join(', '); + timeSpentQuery = `(SELECT ROUND(SUM(twl.time_spent) / 60.0, 2) + FROM task_work_log twl + 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; + } + } + + const query = ` SELECT id, name, + CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no) AS task_key, t.project_id AS project_id, t.parent_task_id, t.parent_task_id IS NOT NULL AS is_sub_task, @@ -793,12 +939,15 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; (SELECT id FROM task_priorities WHERE id = t.priority_id) AS priority, (SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value, total_minutes, + ${timeSpentQuery}, start_date, end_date ${statusesQuery} FROM tasks t WHERE ${filters} ${searchQuery} AND project_id = $1 ORDER BY end_date DESC NULLS LAST `; + + return { query, params: queryParams }; } public static async getGroups(groupBy: string, projectId: string): Promise { @@ -813,8 +962,8 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; category_id FROM task_statuses WHERE project_id = $1 - ORDER BY sort_order; - `; + ORDER BY sort_order + `; params = [projectId]; break; case GroupBy.PRIORITY: @@ -860,34 +1009,110 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; const isSubTasks = !!req.query.parent_task; const groupBy = (req.query.group || GroupBy.STATUS) as string; - const q = ScheduleControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; - + 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]; + // Get groups metadata from database const groups = await this.getGroups(groupBy, req.params.id); - const map = groups.reduce((g: { [x: string]: IScheduleTaskGroup }, group) => { - if (group.id) - g[group.id] = new IScheduleTaskListGroup(group); - return g; - }, {}); - this.updateMapByGroup(tasks, groupBy, map); + // Transform tasks with necessary preprocessing + const transformedTasks = tasks.map((task, index) => { + task.index = index; + ScheduleControllerV2.updateTaskViewModel(task); + return task; + }); - const updatedGroups = Object.keys(map).map(key => { - const group = map[key]; + // Initialize grouped response structure + const groupedResponse: Record = {}; + + // Initialize groups from database data + groups.forEach((group) => { + if (!group.id) return; + + const groupKey = group.id; + + groupedResponse[groupKey] = { + id: group.id, + name: group.name, + category_id: group.category_id || null, + color_code: group.color_code + TASK_STATUS_COLOR_ALPHA, + tasks: [], + isExpand: true, + // Additional metadata (safely access optional properties) + start_date: (group as any).start_date || null, + end_date: (group as any).end_date || null, + }; + }); - if (groupBy === GroupBy.PHASE) - group.color_code = getColor(group.name) + TASK_PRIORITY_COLOR_ALPHA; + // Distribute tasks into groups + const unmappedTasks: any[] = []; - return { - id: key, - ...group + transformedTasks.forEach((task) => { + let taskAssigned = false; + + if (groupBy === GroupBy.STATUS) { + if (task.status && groupedResponse[task.status]) { + groupedResponse[task.status].tasks.push(task); + taskAssigned = true; + } + } else if (groupBy === GroupBy.PRIORITY) { + if (task.priority && groupedResponse[task.priority]) { + groupedResponse[task.priority].tasks.push(task); + taskAssigned = true; + } + } else if (groupBy === GroupBy.PHASE) { + if (task.phase_id && groupedResponse[task.phase_id]) { + groupedResponse[task.phase_id].tasks.push(task); + taskAssigned = true; + } + } + + if (!taskAssigned) { + unmappedTasks.push(task); + } + }); + + // Add unmapped group if there are tasks without proper assignment + if (unmappedTasks.length > 0) { + groupedResponse[UNMAPPED] = { + id: UNMAPPED, + name: UNMAPPED, + category_id: null, + color_code: "#f0f0f0", + tasks: unmappedTasks, + isExpand: true, + start_date: null, + end_date: null, }; + } + + // Apply color adjustments for phase grouping + Object.values(groupedResponse).forEach((group: any) => { + if (groupBy === GroupBy.PHASE && group.id !== UNMAPPED) { + group.color_code = getColor(group.name) + TASK_PRIORITY_COLOR_ALPHA; + } }); - return res.status(200).send(new ServerResponse(true, updatedGroups)); + // Convert to array format, maintaining database order + // Include ALL groups, even those without tasks + const responseGroups = groups + .filter((group) => group.id) // Filter out groups without id + .map((group) => groupedResponse[group.id!]); // Use non-null assertion since we filtered + + // Add unmapped group to the end if it exists + if (groupedResponse[UNMAPPED]) { + responseGroups.push(groupedResponse[UNMAPPED]); + } + + // Return structured response similar to getTasksV3 + return res.status(200).send(new ServerResponse(true, { + groups: responseGroups, + allTasks: transformedTasks, + grouping: groupBy, + totalTasks: transformedTasks.length, + })); } public static updateMapByGroup(tasks: any[], groupBy: string, map: { [p: string]: IScheduleTaskGroup }) { @@ -921,9 +1146,7 @@ AND p.id NOT IN (SELECT project_id FROM archived_projects)`; @HandleExceptions() public static async getTasksOnly(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { - const isSubTasks = !!req.query.parent_task; - const q = ScheduleControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; + const { query: q, params } = ScheduleControllerV2.getQuery(req.user?.id as string, req.params.id, req.query); const result = await db.query(q, params); let data: any[] = []; diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 8385bcac3..773466ea3 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -1,4 +1,4 @@ -import { ParsedQs } from "qs"; +import { ParsedQs } from "qs"; import db from "../config/db"; import HandleExceptions from "../decorators/handle-exceptions"; @@ -11,6 +11,7 @@ import { UNMAPPED, } from "../shared/constants"; import { getColor, log_error } from "../shared/utils"; +import { SqlHelper } from "../shared/sql-helpers"; import TasksControllerBase, { GroupBy, ITaskGroup, @@ -51,45 +52,162 @@ export default class TasksControllerV2 extends TasksControllerBase { return TasksControllerV2.isCountsOnly(query) || query.parent_task; } - private static flatString(text: string) { - return (text || "") - .split(" ") - .map((s) => `'${s}'`) - .join(","); - } + private static getFilterByStatusWhereClosure( + text: string, + paramOffset: number = 1 + ): { clause: string; params: string[] } { + if (!text) return { clause: "", params: [] }; + + const statusIds = text.split(" ").filter(id => id.trim()); + const { clause, params } = SqlHelper.buildInClause(statusIds, paramOffset); - private static getFilterByStatusWhereClosure(text: string) { - return text ? `status_id IN (${this.flatString(text)})` : ""; + return { + clause: `status_id IN (${clause})`, + params, + }; } - private static getFilterByPriorityWhereClosure(text: string) { - return text ? `priority_id IN (${this.flatString(text)})` : ""; + /** + * Filters tasks by priority, including tasks that have descendants (at any level) matching the priority filter. + * Uses recursive CTE to find all descendants. + * Uses parameterized queries. + */ + private static getFilterByPriorityWhereClosure( + text: string, + paramOffset: number = 1 + ): { clause: string; params: string[] } { + if (!text) return { clause: "", params: [] }; + + const priorityIds = text.split(" ").filter(id => id.trim()); + const { clause: inClause, params } = SqlHelper.buildInClause(priorityIds, paramOffset); + + // Use recursive CTE to find all descendants at any level + const clause = `( + priority_id IN (${inClause}) + OR EXISTS ( + WITH RECURSIVE task_descendants AS ( + -- Base case: direct children + SELECT id, parent_task_id, priority_id + FROM tasks + WHERE parent_task_id = t.id AND archived IS FALSE + + UNION ALL + + -- Recursive case: children of children + SELECT child.id, child.parent_task_id, child.priority_id + FROM tasks child + INNER JOIN task_descendants td ON child.parent_task_id = td.id + WHERE child.archived IS FALSE + ) + SELECT 1 FROM task_descendants + WHERE priority_id IN (${inClause}) + ) + )`; + + return { clause, params }; } - private static getFilterByLabelsWhereClosure(text: string) { - return text - ? `id IN (SELECT task_id FROM task_labels WHERE label_id IN (${this.flatString( - text - )}))` - : ""; + /** + * Filters tasks by labels, including tasks that have descendants (at any level) matching the label filter. + * Uses recursive CTE to find all descendants. + * Uses parameterized queries. + */ + private static getFilterByLabelsWhereClosure( + text: string, + paramOffset: number = 1 + ): { clause: string; params: string[] } { + if (!text) return { clause: "", params: [] }; + + const labelIds = text.split(" ").filter(id => id.trim()); + const { clause: inClause, params } = SqlHelper.buildInClause(labelIds, paramOffset); + + // Use recursive CTE to find all descendants at any level + const clause = `( + id IN (SELECT task_id FROM task_labels WHERE label_id IN (${inClause})) + OR EXISTS ( + WITH RECURSIVE task_descendants AS ( + -- Base case: direct children + SELECT id, parent_task_id + FROM tasks + WHERE parent_task_id = t.id AND archived IS FALSE + + UNION ALL + + -- Recursive case: children of children + SELECT child.id, child.parent_task_id + FROM tasks child + INNER JOIN task_descendants td ON child.parent_task_id = td.id + WHERE child.archived IS FALSE + ) + SELECT 1 FROM task_descendants td + JOIN task_labels tl ON tl.task_id = td.id + WHERE tl.label_id IN (${inClause}) + ) + )`; + + return { clause, params }; } - private static getFilterByMembersWhereClosure(text: string) { - return text - ? `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id IN (${this.flatString( - text - )}))` - : ""; + /** + * Filters tasks by assigned members, including tasks that have descendants (at any level) matching the member filter. + * Uses recursive CTE to find all descendants. + * Uses parameterized queries. + */ + private static getFilterByMembersWhereClosure( + text: string, + paramOffset: number = 1 + ): { clause: string; params: string[] } { + if (!text) return { clause: "", params: [] }; + + const memberIds = text.split(" ").filter(id => id.trim()); + const { clause: inClause, params } = SqlHelper.buildInClause(memberIds, paramOffset); + + // Use recursive CTE to find all descendants at any level + const clause = `( + id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id IN (${inClause})) + OR EXISTS ( + WITH RECURSIVE task_descendants AS ( + -- Base case: direct children + SELECT id, parent_task_id + FROM tasks + WHERE parent_task_id = t.id AND archived IS FALSE + + UNION ALL + + -- Recursive case: children of children + SELECT child.id, child.parent_task_id + FROM tasks child + INNER JOIN task_descendants td ON child.parent_task_id = td.id + WHERE child.archived IS FALSE + ) + SELECT 1 FROM task_descendants td + JOIN tasks_assignees ta ON ta.task_id = td.id + WHERE ta.team_member_id IN (${inClause}) + ) + )`; + + return { clause, params }; } - private static getFilterByProjectsWhereClosure(text: string) { - return text ? `project_id IN (${this.flatString(text)})` : ""; + private static getFilterByProjectsWhereClosure( + text: string, + paramOffset: number = 1 + ): { clause: string; params: string[] } { + if (!text) return { clause: "", params: [] }; + + const projectIds = text.split(" ").filter(id => id.trim()); + const { clause: inClause, params } = SqlHelper.buildInClause(projectIds, paramOffset); + + return { + clause: `project_id IN (${inClause})`, + params, + }; } - private static getFilterByAssignee(filterBy: string) { + private static getFilterByAssignee(filterBy: string, projectIdParam: number) { return filterBy === "member" - ? `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id = $1)` - : "project_id = $1"; + ? `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id = $1::UUID)` + : projectIdParam > 0 ? `project_id = $${projectIdParam}::UUID` : "1 = 1"; } private static getStatusesQuery(filterBy: string) { @@ -125,7 +243,25 @@ export default class TasksControllerV2 extends TasksControllerBase { } } - private static getQuery(userId: string, options: ParsedQs) { + private static getQuery(userId: string, options: ParsedQs, projectId?: string): { query: string; params: any[]; isSubTasks: boolean } { + const queryParams: any[] = [userId]; // $1 is always userId + let paramOffset = 2; // Start at $2 (after userId) + + // Add project_id parameter if provided + let projectIdParam = 0; + if (projectId) { + queryParams.push(projectId); + projectIdParam = paramOffset++; + } + + // Add parent_task parameter early if fetching subtasks (before other filters to maintain parameter positions) + const isSubTasks = !!options.parent_task; + let parentTaskParam = 0; + if (isSubTasks && options.parent_task) { + queryParams.push(options.parent_task as string); + parentTaskParam = paramOffset++; + } + // Determine which sort column to use based on grouping const groupBy = options.group || "status"; let defaultSortColumn = "sort_order"; @@ -145,44 +281,121 @@ export default class TasksControllerV2 extends TasksControllerBase { const searchField = options.search ? [ - "t.name", - "CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no)", - ] + "t.name", + "CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no)", + ] : defaultSortColumn; - const { searchQuery, sortField } = TasksControllerV2.toPaginationOptions( - options, - searchField - ); + const { searchQuery, sortField, sortOrder } = + TasksControllerV2.toPaginationOptions(options, searchField); + + // Map frontend field names to backend column names + const fieldMapping: Record = { + 'name': 't.name', + 'status': '(SELECT sort_order FROM task_statuses WHERE id = t.status_id)', + 'priority': '(SELECT value FROM task_priorities WHERE id = t.priority_id)', + 'start_date': 't.start_date', + 'end_date': 't.end_date', + 'completed_at': 't.completed_at', + 'created_at': "t.created_at", + 'updated_at': "t.updated_at", + }; - const isSubTasks = !!options.parent_task; + // Apply field mapping if needed + let mappedSortField = sortField; + if (typeof sortField === "string" && sortField !== defaultSortColumn) { + if (fieldMapping[sortField]) { + mappedSortField = fieldMapping[sortField]; + } + } + // Construct final sort clause const sortFields = - sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || - defaultSortColumn; + mappedSortField && sortOrder + ? `${mappedSortField} ${sortOrder.toUpperCase()}` + : defaultSortColumn; - // Filter tasks by statuses - const statusesFilter = TasksControllerV2.getFilterByStatusWhereClosure( - options.statuses as string + const statusesResult = TasksControllerV2.getFilterByStatusWhereClosure( + options.statuses as string, + paramOffset ); - // Filter tasks by labels - const labelsFilter = TasksControllerV2.getFilterByLabelsWhereClosure( - options.labels as string + if (statusesResult.params.length > 0) { + queryParams.push(...statusesResult.params); + paramOffset += statusesResult.params.length; + } + + const labelsResult = TasksControllerV2.getFilterByLabelsWhereClosure( + options.labels as string, + paramOffset ); - // Filter tasks by its members - const membersFilter = TasksControllerV2.getFilterByMembersWhereClosure( - options.members as string + if (labelsResult.params.length > 0) { + queryParams.push(...labelsResult.params); + paramOffset += labelsResult.params.length; + } + + const membersResult = TasksControllerV2.getFilterByMembersWhereClosure( + options.members as string, + paramOffset ); - // Filter tasks by projects - const projectsFilter = TasksControllerV2.getFilterByProjectsWhereClosure( - options.projects as string + if (membersResult.params.length > 0) { + queryParams.push(...membersResult.params); + paramOffset += membersResult.params.length; + } + + const projectsResult = TasksControllerV2.getFilterByProjectsWhereClosure( + options.projects as string, + paramOffset ); - // Filter tasks by priorities - const priorityFilter = TasksControllerV2.getFilterByPriorityWhereClosure( - options.priorities as string + if (projectsResult.params.length > 0) { + queryParams.push(...projectsResult.params); + paramOffset += projectsResult.params.length; + } + + const priorityResult = TasksControllerV2.getFilterByPriorityWhereClosure( + options.priorities as string, + paramOffset ); + if (priorityResult.params.length > 0) { + queryParams.push(...priorityResult.params); + paramOffset += priorityResult.params.length; + } + + let enhancedSearchQuery = searchQuery; + let searchParamNum = 0; + + if (options.search) { + const searchTerm = options.search.toString().trim(); + if (searchTerm) { + const searchParam = `%${searchTerm}%`; + queryParams.push(searchParam); + searchParamNum = paramOffset++; + + enhancedSearchQuery = `AND ( + t.name ILIKE $${searchParamNum} + OR CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no) ILIKE $${searchParamNum} + OR EXISTS ( + WITH RECURSIVE task_descendants AS ( + SELECT id, parent_task_id, name, task_no, project_id + FROM tasks + WHERE parent_task_id = t.id AND archived IS FALSE + + UNION ALL + + SELECT child.id, child.parent_task_id, child.name, child.task_no, child.project_id + FROM tasks child + INNER JOIN task_descendants td ON child.parent_task_id = td.id + WHERE child.archived IS FALSE + ) + SELECT 1 FROM task_descendants td + WHERE td.name ILIKE $${searchParamNum} + OR CONCAT((SELECT key FROM projects WHERE id = td.project_id), '-', td.task_no) ILIKE $${searchParamNum} + ) + )`; + } + } // Filter tasks by a single assignee const filterByAssignee = TasksControllerV2.getFilterByAssignee( - options.filterBy as string + options.filterBy as string, + projectIdParam ); // Returns statuses of each task as a json array if filterBy === "member" const statusesQuery = TasksControllerV2.getStatusesQuery( @@ -219,30 +432,165 @@ export default class TasksControllerV2 extends TasksControllerBase { const archivedFilter = options.archived === "true" ? "archived IS TRUE" : "archived IS FALSE"; - let subTasksFilter; + // Add project_id filter if projectId is provided + const projectIdFilter = projectIdParam > 0 ? `t.project_id = $${projectIdParam}::UUID` : ""; + // Handle subtask filter - parent_task parameter was already added earlier if needed + let subTasksFilter; if (options.isSubtasksInclude === "true") { subTasksFilter = ""; } else { - subTasksFilter = isSubTasks - ? "parent_task_id = $2" - : "parent_task_id IS NULL"; + if (isSubTasks && parentTaskParam > 0) { + // Use the parent_task parameter that was already added to queryParams + subTasksFilter = `parent_task_id = $${parentTaskParam}::UUID`; + } else if (isSubTasks) { + // Fallback: if parent_task is not provided, this shouldn't happen but handle gracefully + subTasksFilter = "1 = 0"; // Return no results + } else { + subTasksFilter = "parent_task_id IS NULL"; + } } const filters = [ + projectIdFilter, subTasksFilter, isSubTasks ? "1 = 1" : archivedFilter, - isSubTasks ? "$1 = $1" : filterByAssignee, // ignored filter by member in peoples page for sub-tasks - statusesFilter, - priorityFilter, - labelsFilter, - membersFilter, - projectsFilter, + isSubTasks ? "1 = 1" : filterByAssignee, + statusesResult.clause, + priorityResult.clause, + labelsResult.clause, + membersResult.clause, + projectsResult.clause, ] .filter((i) => !!i) .join(" AND "); - return ` + // Build filtered subtask count query - apply same filters to subtasks + const subtaskFilters = []; + + // Always filter by archived status for subtasks + subtaskFilters.push(archivedFilter); + + // Apply status filter to subtasks if present + if (statusesResult.clause) { + subtaskFilters.push(statusesResult.clause.replace(/\bt\./g, 'subtask.')); + } + + // Apply priority filter to subtasks if present (reuse parameters) + if (options.priorities && priorityResult.clause) { + const priorityIds = (options.priorities as string).split(" ").filter(id => id.trim()); + const priorityParamStart = paramOffset - priorityResult.params.length; + const { clause: inClause } = SqlHelper.buildInClause(priorityIds, priorityParamStart); + subtaskFilters.push(`subtask.priority_id IN (${inClause})`); + } + + // Apply labels filter to subtasks if present (reuse parameters) + if (options.labels && labelsResult.clause) { + const labelIds = (options.labels as string).split(" ").filter(id => id.trim()); + const labelParamStart = paramOffset - labelsResult.params.length - priorityResult.params.length; + const { clause: inClause } = SqlHelper.buildInClause(labelIds, labelParamStart); + subtaskFilters.push(`subtask.id IN (SELECT task_id FROM task_labels WHERE label_id IN (${inClause}))`); + } + + // Apply members filter to subtasks if present (reuse parameters) + if (options.members && membersResult.clause) { + const memberIds = (options.members as string).split(" ").filter(id => id.trim()); + const memberParamStart = paramOffset - membersResult.params.length - projectsResult.params.length; + const { clause: inClause } = SqlHelper.buildInClause(memberIds, memberParamStart); + subtaskFilters.push(`subtask.id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id IN (${inClause}))`); + } + + // Apply search filter to subtasks if present (reuse search parameter) + if (options.search && !isSubTasks && searchParamNum > 0) { + subtaskFilters.push(`( + subtask.name ILIKE $${searchParamNum} + OR CONCAT((SELECT key FROM projects WHERE id = subtask.project_id), '-', subtask.task_no) ILIKE $${searchParamNum} + )`); + } + + const subtaskFilterClause = + subtaskFilters.length > 0 ? `AND ${subtaskFilters.join(" AND ")}` : ""; + + // Build has_filtered_children query - checks if any descendant (at any level) matches the active filters + // This is used to auto-expand parent tasks when their descendants match filters + const hasActiveFilters = !!(options.priorities || options.labels || options.members || (options.search && !isSubTasks)); + + let hasFilteredChildrenQuery = "FALSE"; + if (hasActiveFilters) { + const descendantFilterConditions: string[] = []; + + // Build filter conditions for descendants using the same parameter positions + if (options.priorities) { + const priorityIds = (options.priorities as string).split(" ").filter(id => id.trim()); + // Find the parameter positions for priority IDs (they were added after labels and members) + let priorityParamStart = 2; // Start after userId + if (projectId) priorityParamStart++; + if (isSubTasks && options.parent_task) priorityParamStart++; + priorityParamStart += statusesResult.params.length; + priorityParamStart += labelsResult.params.length; + priorityParamStart += membersResult.params.length; + priorityParamStart += projectsResult.params.length; + + const { clause: inClause } = SqlHelper.buildInClause(priorityIds, priorityParamStart); + descendantFilterConditions.push(`td.priority_id IN (${inClause})`); + } + + if (options.labels) { + const labelIds = (options.labels as string).split(" ").filter(id => id.trim()); + let labelParamStart = 2; + if (projectId) labelParamStart++; + if (isSubTasks && options.parent_task) labelParamStart++; + labelParamStart += statusesResult.params.length; + + const { clause: inClause } = SqlHelper.buildInClause(labelIds, labelParamStart); + descendantFilterConditions.push(`td.id IN (SELECT task_id FROM task_labels WHERE label_id IN (${inClause}))`); + } + + if (options.members) { + const memberIds = (options.members as string).split(" ").filter(id => id.trim()); + let memberParamStart = 2; + if (projectId) memberParamStart++; + if (isSubTasks && options.parent_task) memberParamStart++; + memberParamStart += statusesResult.params.length; + memberParamStart += labelsResult.params.length; + + const { clause: inClause } = SqlHelper.buildInClause(memberIds, memberParamStart); + descendantFilterConditions.push(`td.id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id IN (${inClause}))`); + } + + if (options.search && !isSubTasks && searchParamNum > 0) { + descendantFilterConditions.push(`( + td.name ILIKE $${searchParamNum} + OR CONCAT((SELECT key FROM projects WHERE id = td.project_id), '-', td.task_no) ILIKE $${searchParamNum} + )`); + } + + if (descendantFilterConditions.length > 0) { + const descendantFilterClause = descendantFilterConditions.join(" OR "); + hasFilteredChildrenQuery = `( + EXISTS ( + WITH RECURSIVE task_descendants AS ( + -- Base case: direct children + SELECT id, parent_task_id, priority_id, name, task_no, project_id + FROM tasks + WHERE parent_task_id = t.id AND archived IS FALSE + + UNION ALL + + -- Recursive case: children of children (all levels) + SELECT child.id, child.parent_task_id, child.priority_id, child.name, child.task_no, child.project_id + FROM tasks child + INNER JOIN task_descendants td ON child.parent_task_id = td.id + WHERE child.archived IS FALSE + ) + SELECT 1 FROM task_descendants td + WHERE ${descendantFilterClause} + ) + )`; + } + } + + const q = ` SELECT id, name, CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no) AS task_key, @@ -251,9 +599,11 @@ export default class TasksControllerV2 extends TasksControllerBase { t.parent_task_id, t.parent_task_id IS NOT NULL AS is_sub_task, (SELECT name FROM tasks WHERE id = t.parent_task_id) AS parent_task_name, - (SELECT COUNT(*) - FROM tasks - WHERE parent_task_id = t.id)::INT AS sub_tasks_count, + (SELECT COUNT(*)::INT + FROM tasks subtask + WHERE subtask.parent_task_id = t.id + ${subtaskFilterClause}) AS sub_tasks_count, + ${hasFilteredChildrenQuery} AS has_filtered_children, t.status_id AS status, t.archived, @@ -268,7 +618,7 @@ export default class TasksControllerV2 extends TasksControllerBase { (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, + COALESCE(t.progress_value, 0) AS complete_ratio, (SELECT phase_id FROM task_phase WHERE task_id = t.id) AS phase_id, (SELECT name @@ -283,7 +633,7 @@ export default class TasksControllerV2 extends TasksControllerBase { (SELECT start_time FROM task_timers WHERE task_id = t.id - AND user_id = '${userId}') AS timer_start_time, + AND user_id = $1) AS timer_start_time, (SELECT color_code FROM sys_task_status_categories @@ -333,9 +683,11 @@ export default class TasksControllerV2 extends TasksControllerBase { schedule_id, END_DATE ${customColumnsQuery} ${statusesQuery} FROM tasks t - WHERE ${filters} ${searchQuery} + WHERE ${filters} ${enhancedSearchQuery} ORDER BY ${sortFields} `; + + return { query: q, params: queryParams, isSubTasks }; } public static async getGroups( @@ -399,37 +751,22 @@ export default class TasksControllerV2 extends TasksControllerBase { res: IWorkLenzResponse ): Promise { const startTime = performance.now(); - console.log( - `[PERFORMANCE] getList method called for project ${req.params.id} - THIS METHOD IS DEPRECATED, USE getTasksV3 INSTEAD` - ); // PERFORMANCE OPTIMIZATION: Skip expensive progress calculation by default // Progress values are already calculated and stored in the database // Only refresh if explicitly requested via refresh_progress=true query parameter if (req.query.refresh_progress === "true" && req.params.id) { - console.log( - `[PERFORMANCE] Starting progress refresh for project ${req.params.id} (getList)` - ); const progressStartTime = performance.now(); await this.refreshProjectTaskProgressValues(req.params.id); const progressEndTime = performance.now(); - console.log( - `[PERFORMANCE] Progress refresh completed in ${( - progressEndTime - progressStartTime - ).toFixed(2)}ms` - ); } - const isSubTasks = !!req.query.parent_task; const groupBy = (req.query.group || GroupBy.STATUS) as string; // Add customColumns flag to query params req.query.customColumns = "true"; - const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks - ? [req.params.id || null, req.query.parent_task] - : [req.params.id || null]; + const { query: q, params, isSubTasks } = TasksControllerV2.getQuery(req.user?.id as string, req.query, req.params.id); const result = await db.query(q, params); const tasks = [...result.rows]; @@ -458,15 +795,10 @@ export default class TasksControllerV2 extends TasksControllerBase { const endTime = performance.now(); const totalTime = endTime - startTime; - console.log( - `[PERFORMANCE] getList method completed in ${totalTime.toFixed( - 2 - )}ms for project ${req.params.id} with ${tasks.length} tasks` - ); // Log warning if this deprecated method is taking too long if (totalTime > 1000) { - console.warn( + log_error( `[PERFORMANCE WARNING] DEPRECATED getList method taking ${totalTime.toFixed( 2 )}ms - Frontend should use getTasksV3 instead!` @@ -539,25 +871,9 @@ export default class TasksControllerV2 extends TasksControllerBase { res: IWorkLenzResponse ): Promise { const startTime = performance.now(); - console.log( - `[PERFORMANCE] getTasksOnly method called for project ${req.params.id} - Consider using getTasksV3 for better performance` - ); - // PERFORMANCE OPTIMIZATION: Skip expensive progress calculation by default - // Progress values are already calculated and stored in the database - // Only refresh if explicitly requested via refresh_progress=true query parameter if (req.query.refresh_progress === "true" && req.params.id) { - console.log( - `[PERFORMANCE] Starting progress refresh for project ${req.params.id} (getTasksOnly)` - ); - const progressStartTime = performance.now(); await this.refreshProjectTaskProgressValues(req.params.id); - const progressEndTime = performance.now(); - console.log( - `[PERFORMANCE] Progress refresh completed in ${( - progressEndTime - progressStartTime - ).toFixed(2)}ms` - ); } const isSubTasks = !!req.query.parent_task; @@ -565,10 +881,7 @@ export default class TasksControllerV2 extends TasksControllerBase { // Add customColumns flag to query params req.query.customColumns = "true"; - const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks - ? [req.params.id || null, req.query.parent_task] - : [req.params.id || null]; + const { query: q, params } = TasksControllerV2.getQuery(req.user?.id as string, req.query, req.params.id); const result = await db.query(q, params); let data: any[] = []; @@ -591,15 +904,9 @@ export default class TasksControllerV2 extends TasksControllerBase { const endTime = performance.now(); const totalTime = endTime - startTime; - console.log( - `[PERFORMANCE] getTasksOnly method completed in ${totalTime.toFixed( - 2 - )}ms for project ${req.params.id} with ${data.length} tasks` - ); - // Log warning if this method is taking too long if (totalTime > 1000) { - console.warn( + log_error( `[PERFORMANCE WARNING] getTasksOnly method taking ${totalTime.toFixed( 2 )}ms - Consider using getTasksV3 for better performance!` @@ -663,9 +970,6 @@ export default class TasksControllerV2 extends TasksControllerBase { "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( @@ -687,9 +991,6 @@ export default class TasksControllerV2 extends TasksControllerBase { // 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) { @@ -740,11 +1041,11 @@ export default class TasksControllerV2 extends TasksControllerBase { groupType === "phase" ? [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, - ]; + 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 @@ -813,10 +1114,10 @@ export default class TasksControllerV2 extends TasksControllerBase { name AS label, CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no) AS task_key FROM tasks t - WHERE t.name ILIKE '%${searchString}%' - AND t.project_id = $1 AND t.id != $2 + WHERE t.name ILIKE $1 + AND t.project_id = $2 AND t.id != $3 LIMIT 15;`; - const result = await db.query(q, [projectId, taskId]); + const result = await db.query(q, [`%${searchString}%`, projectId, taskId]); return result.rows; } @@ -922,13 +1223,18 @@ export default class TasksControllerV2 extends TasksControllerBase { const { id } = req.params; const { labels }: { labels: string[] } = req.body; - labels.forEach(async (label: string) => { - const q = `SELECT add_or_remove_task_label($1, $2) AS labels;`; - await db.query(q, [id, label]); - }); + const q = `SELECT replace_task_labels($1, $2) AS labels;`; + const result = await db.query(q, [id, labels]); + return res .status(200) - .send(new ServerResponse(true, null, "Labels assigned successfully")); + .send( + new ServerResponse( + true, + result.rows[0]?.labels || [], + "Labels assigned successfully" + ) + ); } /** @@ -1058,11 +1364,13 @@ export default class TasksControllerV2 extends TasksControllerBase { // Run the recalculate_all_task_progress function only for tasks in this project const query = ` DO $$ + DECLARE + v_project_id UUID := $1; 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}' + WHERE project_id = v_project_id AND EXISTS ( SELECT 1 FROM tasks @@ -1079,7 +1387,7 @@ export default class TasksControllerV2 extends TasksControllerBase { parent_task_id, 0 AS level FROM tasks - WHERE project_id = '${projectId}' + WHERE project_id = v_project_id AND NOT EXISTS ( SELECT 1 FROM tasks AS sub WHERE sub.parent_task_id = tasks.id @@ -1107,15 +1415,12 @@ export default class TasksControllerV2 extends TasksControllerBase { ORDER BY level ) AS ordered_tasks WHERE tasks.id = ordered_tasks.id - AND tasks.project_id = '${projectId}' + AND tasks.project_id = v_project_id AND (manual_progress IS FALSE OR manual_progress IS NULL); END $$; `; - await db.query(query); - console.log( - `Finished refreshing progress values for project ${projectId}` - ); + await db.query(query, [projectId]); } catch (error) { log_error("Error refreshing project task progress values", error); } @@ -1139,8 +1444,6 @@ export default class TasksControllerV2 extends TasksControllerBase { 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", @@ -1192,7 +1495,6 @@ export default class TasksControllerV2 extends TasksControllerBase { res: IWorkLenzResponse ): Promise { const startTime = performance.now(); - const isSubTasks = !!req.query.parent_task; const groupBy = (req.query.group || GroupBy.STATUS) as string; // PERFORMANCE OPTIMIZATION: Skip expensive progress calculation by default @@ -1205,11 +1507,7 @@ export default class TasksControllerV2 extends TasksControllerBase { await this.refreshProjectTaskProgressValues(req.params.id); } - const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); - const params = isSubTasks - ? [req.params.id || null, req.query.parent_task] - : [req.params.id || null]; - + const { query: q, params, isSubTasks } = TasksControllerV2.getQuery(req.user?.id as string, req.query, req.params.id); const result = await db.query(q, params); const tasks = [...result.rows]; @@ -1240,12 +1538,17 @@ export default class TasksControllerV2 extends TasksControllerBase { TasksControllerV2.updateTaskViewModel(task); task.index = index; - // Convert time values - const convertTimeValue = (value: any): number => { - if (typeof value === "number") return value; + // Convert time values to hours + const convertToHours = ( + value: any, + isSeconds: boolean = false + ): number => { + if (typeof value === "number") { + return isSeconds ? value / 3600 : value / 60; // Convert seconds or minutes to hours + } if (typeof value === "string") { const parsed = parseFloat(value); - return isNaN(parsed) ? 0 : parsed; + return isNaN(parsed) ? 0 : isSeconds ? parsed / 3600 : parsed / 60; } if (value && typeof value === "object") { if ("hours" in value || "minutes" in value) { @@ -1257,6 +1560,9 @@ export default class TasksControllerV2 extends TasksControllerBase { return 0; }; + const calculatedProgress = + typeof task.complete_ratio === "number" ? task.complete_ratio : 0; + return { id: task.id, task_key: task.task_key || "", @@ -1268,8 +1574,9 @@ export default class TasksControllerV2 extends TasksControllerBase { priority: priorityMap[task.priority_value?.toString()] || "medium", // Use actual phase name from database phase: task.phase_name || "Development", - progress: - typeof task.complete_ratio === "number" ? task.complete_ratio : 0, + progress: calculatedProgress, + complete_ratio: task.complete_ratio, // Also include original field + progress_value: task.progress_value, // Also include original field assignees: task.assignees?.map((a: any) => a.team_member_id) || [], assignee_names: task.assignee_names || task.names || [], labels: @@ -1283,9 +1590,10 @@ export default class TasksControllerV2 extends TasksControllerBase { all_labels: task.all_labels || [], dueDate: task.end_date || task.END_DATE, startDate: task.start_date, + completed_at: task.completed_at || undefined, timeTracking: { - estimated: convertTimeValue(task.total_time), - logged: convertTimeValue(task.time_spent), + estimated: convertToHours(task.total_minutes, false), // total_minutes is in minutes + logged: convertToHours(task.total_minutes_spent, true), // total_minutes_spent is in seconds }, customFields: {}, custom_column_values: task.custom_column_values || {}, // Include custom column values @@ -1299,6 +1607,8 @@ export default class TasksControllerV2 extends TasksControllerBase { priorityColor: task.priority_color, // Add subtask count sub_tasks_count: task.sub_tasks_count || 0, + // Add flag for auto-expansion when filters match descendants + has_filtered_children: !!task.has_filtered_children, // Add indicator fields for frontend icons comments_count: task.comments_count || 0, has_subscribers: !!task.has_subscribers, @@ -1308,6 +1618,8 @@ export default class TasksControllerV2 extends TasksControllerBase { reporter: task.reporter || null, }; }); + + const groupedResponse: Record = {}; // Initialize groups from database data @@ -1316,9 +1628,9 @@ export default class TasksControllerV2 extends TasksControllerBase { groupBy === GroupBy.STATUS ? group.name.toLowerCase().replace(/\s+/g, "_") : groupBy === GroupBy.PRIORITY - ? priorityMap[(group as any).value?.toString()] || + ? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase() - : group.name.toLowerCase().replace(/\s+/g, "_"); + : group.name.toLowerCase().replace(/\s+/g, "_"); groupedResponse[groupKey] = { id: group.id, @@ -1406,10 +1718,12 @@ export default class TasksControllerV2 extends TasksControllerBase { total > 0 ? +((doingCount / total) * 100).toFixed(0) : 0; group.done_progress = total > 0 ? +((doneCount / total) * 100).toFixed(0) : 0; + } else { + // Only set to 0 if there are no tasks + group.todo_progress = 0; + group.doing_progress = 0; + group.done_progress = 0; } - group.todo_progress = 0; - group.doing_progress = 0; - group.done_progress = 0; }); } @@ -1475,9 +1789,9 @@ export default class TasksControllerV2 extends TasksControllerBase { groupBy === GroupBy.STATUS ? group.name.toLowerCase().replace(/\s+/g, "_") : groupBy === GroupBy.PRIORITY - ? priorityMap[(group as any).value?.toString()] || + ? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase() - : group.name.toLowerCase().replace(/\s+/g, "_"); + : group.name.toLowerCase().replace(/\s+/g, "_"); return groupedResponse[groupKey]; }) @@ -1497,7 +1811,7 @@ export default class TasksControllerV2 extends TasksControllerBase { // Log warning if request is taking too long if (totalTime > 1000) { - console.warn( + log_error( `[PERFORMANCE WARNING] Slow request detected: ${totalTime.toFixed( 2 )}ms for project ${req.params.id} with ${transformedTasks.length} tasks` @@ -1565,18 +1879,10 @@ export default class TasksControllerV2 extends TasksControllerBase { const startTime = performance.now(); if (req.params.id) { - console.log( - `[PERFORMANCE] Starting background progress refresh for project ${req.params.id}` - ); await this.refreshProjectTaskProgressValues(req.params.id); const endTime = performance.now(); const totalTime = endTime - startTime; - console.log( - `[PERFORMANCE] Background progress refresh completed in ${totalTime.toFixed( - 2 - )}ms for project ${req.params.id}` - ); return res.status(200).send( new ServerResponse(true, { @@ -1592,7 +1898,7 @@ export default class TasksControllerV2 extends TasksControllerBase { .status(400) .send(new ServerResponse(false, null, "Project ID is required")); } catch (error) { - console.error("Error refreshing task progress:", error); + log_error("Error refreshing task progress:", error); return res .status(500) .send( @@ -1647,15 +1953,15 @@ export default class TasksControllerV2 extends TasksControllerBase { completionPercentage: stats.total_tasks > 0 ? Math.round( - (parseInt(stats.completed_tasks) / - parseInt(stats.total_tasks)) * - 100 - ) + (parseInt(stats.completed_tasks) / + parseInt(stats.total_tasks)) * + 100 + ) : 0, }) ); } catch (error) { - console.error("Error getting task progress status:", error); + log_error("Error getting task progress status:", error); return res .status(500) .send( @@ -1663,4 +1969,4 @@ export default class TasksControllerV2 extends TasksControllerBase { ); } } -} +} \ No newline at end of file diff --git a/worklenz-backend/src/controllers/worklenz-controller-base.ts b/worklenz-backend/src/controllers/worklenz-controller-base.ts index c494f47b0..0c3ef0cc2 100644 --- a/worklenz-backend/src/controllers/worklenz-controller-base.ts +++ b/worklenz-backend/src/controllers/worklenz-controller-base.ts @@ -54,8 +54,15 @@ export default abstract class WorklenzControllerBase { } } - // Sort - const sortField = /null|undefined/.test(queryParams.field as string) ? searchField : queryParams.field; + // Sort - validate field and order + const field = queryParams.field; + let sortField = searchField; + + // Only use provided field if it's NOT literally "null" or "undefined" and is a valid string + if (field && field !== "null" && field !== "undefined" && typeof field === 'string' && field.trim().length > 0) { + sortField = field; + } + const sortOrder = queryParams.order === "descend" ? "desc" : "asc"; return {searchQuery, sortField, sortOrder, size, offset, paging}; diff --git a/worklenz-backend/src/shared/sql-helpers.ts b/worklenz-backend/src/shared/sql-helpers.ts new file mode 100644 index 000000000..e92ee01b8 --- /dev/null +++ b/worklenz-backend/src/shared/sql-helpers.ts @@ -0,0 +1,304 @@ +/** + * This module provides secure utilities for building SQL queries with proper + * parameterization for secure query building. + * + * These helpers replace unsafe patterns like: + * - Direct string interpolation: `SELECT * FROM users WHERE id = '${userId}'` + * - flatString() for IN clauses: `WHERE id IN (${flatString(ids)})` + * - String concatenation in queries + */ + +/** + * Interface for parameterized query result + */ +export interface ParameterizedQuery { + query: string; + params: any[]; +} + +/** + * SQL Helper class with secure query building methods + */ +export class SqlHelper { + /** + * Build a safe IN clause with parameterized values + * + * @example + * const { clause, params } = SqlHelper.buildInClause(['id1', 'id2', 'id3'], 1); + * // Returns: { clause: '$1, $2, $3', params: ['id1', 'id2', 'id3'] } + * const query = `SELECT * FROM tasks WHERE id IN (${clause})`; + * await db.query(query, params); + */ + static buildInClause(values: any[], paramOffset = 1): { clause: string; params: any[] } { + if (!values || values.length === 0) { + return { clause: "", params: [] }; + } + + const placeholders = values.map((_, index) => `$${paramOffset + index}`).join(", "); + return { + clause: placeholders, + params: values, + }; + } + + /** + * Build an optional parameterized IN clause for SQL queries with column name. + * Returns empty clause if values array is empty, making it safe for optional filters. + * The column name is validated to prevent SQL injection. + * + * @param values - Array of values to include in the IN clause (can be empty) + * @param columnName - Name of the column for the IN clause (will be validated) + * @param startIndex - Starting parameter index + * @returns Object with clause string and params array (empty if values is empty) + * + * @example + * const teamIds = []; // Empty array + * const result = SqlHelper.buildOptionalInClause(teamIds, 'team_id', 1); + * // result.clause: "" + * // result.params: [] + * const query = `SELECT * FROM projects WHERE 1=1 ${result.clause}`; + * await db.query(query, result.params); + * + * @example + * const teamIds = ['team1', 'team2']; + * const result = SqlHelper.buildOptionalInClause(teamIds, 'team_id', 1); + * // result.clause: "AND team_id IN ($1, $2)" + * // result.params: ['team1', 'team2'] + */ + static buildOptionalInClause(values: any[], columnName: string, startIndex: number): { clause: string; params: any[] } { + if (!Array.isArray(values) || values.length === 0) { + return { + clause: '', + params: [] + }; + } + + // Validate columnName to prevent SQL injection + const safeColumnName = this.escapeIdentifier(columnName); + + const placeholders = values.map((_, index) => `$${startIndex + index}`).join(', '); + return { + clause: `AND ${safeColumnName} IN (${placeholders})`, + params: values + }; + } + + /** + * Build a safe WHERE clause with multiple conditions + * + * @example + * const conditions = [ + * { field: 'status', operator: '=', value: 'active' }, + * { field: 'priority', operator: 'IN', value: ['high', 'medium'] } + * ]; + * const { where, params } = SqlHelper.buildWhereClause(conditions); + */ + static buildWhereClause( + conditions: Array<{ + field: string; + operator: string; + value: any; + conjunction?: "AND" | "OR"; + }>, + paramOffset = 1 + ): { where: string; params: any[] } { + if (!conditions || conditions.length === 0) { + return { where: "", params: [] }; + } + + const params: any[] = []; + const clauses: string[] = []; + let currentParam = paramOffset; + + conditions.forEach((condition, index) => { + const conjunction = index === 0 ? "" : ` ${condition.conjunction || "AND"} `; + + if (condition.operator.toUpperCase() === "IN") { + const values = Array.isArray(condition.value) ? condition.value : [condition.value]; + const { clause, params: inParams } = this.buildInClause(values, currentParam); + clauses.push(`${conjunction}${condition.field} IN (${clause})`); + params.push(...inParams); + currentParam += inParams.length; + } else if (condition.operator.toUpperCase() === "IS NULL" || condition.operator.toUpperCase() === "IS NOT NULL") { + clauses.push(`${conjunction}${condition.field} ${condition.operator}`); + } else { + clauses.push(`${conjunction}${condition.field} ${condition.operator} $${currentParam}`); + params.push(condition.value); + currentParam++; + } + }); + + return { + where: clauses.join(""), + params, + }; + } + + /** + * Build a safe LIKE clause for text search + */ + static buildLikeClause( + field: string, + searchTerm: string, + paramOffset = 1, + options: { + caseSensitive?: boolean; + prefix?: boolean; + suffix?: boolean; + } = {} + ): { clause: string; params: string[] } { + const { caseSensitive = false, prefix = true, suffix = true } = options; + + let pattern = searchTerm; + if (prefix) pattern = `%${pattern}`; + if (suffix) pattern = `${pattern}%`; + + const operator = caseSensitive ? "LIKE" : "ILIKE"; + + return { + clause: `${field} ${operator} $${paramOffset}`, + params: [pattern], + }; + } + + /** + * Build a safe full-text search clause for multiple fields + */ + static buildSearchClause( + fields: string[], + searchTerm: string, + paramOffset = 1, + caseSensitive = false + ): { clause: string; params: string[] } { + if (!searchTerm || searchTerm.trim() === "") { + return { clause: "", params: [] }; + } + + const operator = caseSensitive ? "LIKE" : "ILIKE"; + const pattern = `%${searchTerm}%`; + const clauses = fields.map(field => `${field} ${operator} $${paramOffset}`); + + return { + clause: `(${clauses.join(" OR ")})`, + params: [pattern], + }; + } + + /** + * Build a safe ORDER BY clause + */ + static buildOrderByClause( + field: string, + order: "ASC" | "DESC" | "asc" | "desc", + allowedFields: string[] + ): string { + if (!allowedFields.includes(field)) { + throw new Error(`Invalid sort field: ${field}`); + } + + const normalizedOrder = order.toUpperCase(); + if (normalizedOrder !== "ASC" && normalizedOrder !== "DESC") { + throw new Error(`Invalid sort order: ${order}`); + } + + return `${field} ${normalizedOrder}`; + } + + /** + * Build a safe LIMIT/OFFSET clause + */ + static buildPaginationClause( + limit: number, + offset: number, + paramOffset = 1 + ): { clause: string; params: number[] } { + const safeLimit = Math.max(1, Math.min(1000, parseInt(String(limit), 10) || 10)); + const safeOffset = Math.max(0, parseInt(String(offset), 10) || 0); + + return { + clause: `LIMIT $${paramOffset} OFFSET $${paramOffset + 1}`, + params: [safeLimit, safeOffset], + }; + } + + /** + * Escape identifier (table/column name) for secure query building + * Supports both simple identifiers (e.g., "status_id") and qualified identifiers (e.g., "p.status_id") + */ + static escapeIdentifier(identifier: string): string { + // Handle qualified identifiers (e.g., "p.status_id") + if (identifier.includes('.')) { + const segments = identifier.split('.'); + const escapedSegments = segments.map(segment => { + const cleaned = segment.replace(/"/g, ""); + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cleaned)) { + throw new Error(`Invalid identifier segment: ${segment}`); + } + + return `"${cleaned}"`; + }); + + return escapedSegments.join('.'); + } + + // Handle simple identifiers + const cleaned = identifier.replace(/"/g, ""); + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cleaned)) { + throw new Error(`Invalid identifier: ${identifier}`); + } + + return `"${cleaned}"`; + } + + /** + * Build a safe UPDATE query + */ + static buildUpdateQuery(options: { + table: string; + set: Record; + where: Array<{ field: string; operator: string; value: any; conjunction?: "AND" | "OR" }>; + }): ParameterizedQuery { + const { table, set, where } = options; + + const setEntries = Object.entries(set); + if (setEntries.length === 0) { + throw new Error("UPDATE query must have at least one field to set"); + } + + const params: any[] = []; + let paramOffset = 1; + + const setClauses = setEntries.map(([field, value]) => { + params.push(value); + return `${field} = $${paramOffset++}`; + }); + + let query = `UPDATE ${table} SET ${setClauses.join(", ")}`; + + if (where.length > 0) { + const { where: whereClause, params: whereParams } = this.buildWhereClause(where, paramOffset); + query += ` WHERE ${whereClause}`; + params.push(...whereParams); + } + + return { query, params }; + } +} + +/** + * Legacy flatString replacement - DEPRECATED + * Use SqlHelper.buildInClause() instead + * + * @deprecated This function is unsafe and will be removed in Phase 3 + */ +export function flatString(text: string): string { + console.warn("flatString() is deprecated and unsafe. Use SqlHelper.buildInClause() instead."); + return (text || "") + .split(" ") + .map((s) => `'${s}'`) + .join(","); +} + +export default SqlHelper; diff --git a/worklenz-backend/src/socket.io/commands/on-task-timer-stop.ts b/worklenz-backend/src/socket.io/commands/on-task-timer-stop.ts index 278c9743d..1881b684b 100644 --- a/worklenz-backend/src/socket.io/commands/on-task-timer-stop.ts +++ b/worklenz-backend/src/socket.io/commands/on-task-timer-stop.ts @@ -5,34 +5,70 @@ import {SocketEvents} from "../events"; import {getLoggedInUserIdFromSocket, log_error, notifyProjectUpdates} from "../util"; export async function on_task_timer_stop(_io: Server, socket: Socket, data?: string) { + let client; + try { + client = await db.pool.connect(); + const body = JSON.parse(data as string); const userId = getLoggedInUserIdFromSocket(socket); - const q = ` - DO - $$ - DECLARE - _start_time TIMESTAMPTZ; - _time_spent NUMERIC; - BEGIN - - SELECT start_time FROM task_timers WHERE user_id = '${userId}' AND task_id = '${body.task_id}' INTO _start_time; - - _time_spent = COALESCE(EXTRACT(EPOCH FROM - (DATE_TRUNC('second', (CURRENT_TIMESTAMP - _start_time::TIMESTAMPTZ)))::INTERVAL), - 0); - - IF (_time_spent > 0) - THEN - INSERT INTO task_work_log (time_spent, task_id, user_id, logged_by_timer, created_at) - VALUES (_time_spent, '${body.task_id}', '${userId}', TRUE, _start_time); - END IF; - - DELETE FROM task_timers WHERE user_id = '${userId}' AND task_id = '${body.task_id}'; - END - $$; - `; - await db.query(q, []); + + // Validate userId (authentication check) + if (!userId) { + socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), null); + return; + } + + // Validate inputs + if (!body.task_id || typeof body.task_id !== 'string') { + socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), null); + return; + } + + // Validate UUID format (defense in depth - parameterized queries already provide security) + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(body.task_id)) { + socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), null); + return; + } + + await client.query("BEGIN"); + + try { + // First, get the timer data and calculate time spent + const timerQuery = ` + WITH timer_data AS ( + SELECT start_time + FROM task_timers + WHERE user_id = $1 AND task_id = $2 + ), + time_calculation AS ( + SELECT + COALESCE( + EXTRACT(EPOCH FROM ( + DATE_TRUNC('second', (CURRENT_TIMESTAMP - timer_data.start_time::TIMESTAMPTZ)) + )::INTERVAL), + 0 + ) as time_spent, + timer_data.start_time + FROM timer_data + ) + INSERT INTO task_work_log (time_spent, task_id, user_id, logged_by_timer, created_at) + SELECT time_spent, $2, $1, TRUE, start_time + FROM time_calculation + WHERE time_spent > 0; + `; + await client.query(timerQuery, [userId, body.task_id]); + + // Then, delete the timer + const deleteQuery = `DELETE FROM task_timers WHERE user_id = $1 AND task_id = $2;`; + await client.query(deleteQuery, [userId, body.task_id]); + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), { id: body.task_id, @@ -42,7 +78,10 @@ export async function on_task_timer_stop(_io: Server, socket: Socket, data?: str return; } catch (error) { log_error(error); + socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), null); + } finally { + if (client) { + client.release(); + } } - - socket.emit(SocketEvents.TASK_TIMER_STOP.toString(), null); }