From 474f1afe66a802186c85754773c2ab6d01d35e4e Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Sun, 20 Jul 2025 19:16:03 +0530 Subject: [PATCH] feat(recurring-tasks): implement recurring tasks service with timezone support and notifications - Added a new service for managing recurring tasks, allowing configuration of task schedules with timezone support. - Introduced job queues for processing recurring tasks and handling task creation in bulk. - Implemented notification system to alert users about newly created recurring tasks, including email and in-app notifications. - Enhanced database schema with new tables for notifications and audit logs to track recurring task operations. - Updated frontend components to support timezone selection and manage excluded dates for recurring tasks. - Refactored existing code to integrate new features and improve overall task management experience. --- package-lock.json | 382 +++++++++++++++++- package.json | 9 + .../functions/create_bulk_recurring_tasks.sql | 185 +++++++++ .../add_notifications_for_recurring_tasks.sql | 40 ++ .../add_recurring_tasks_audit_log.sql | 94 +++++ .../sql/migrations/add_timezone_support.sql | 44 ++ worklenz-backend/job-queue-dependencies.md | 111 +++++ .../src/config/recurring-tasks-config.ts | 57 +++ .../recurring-tasks-admin-controller.ts | 48 +++ .../controllers/task-recurring-controller.ts | 40 +- .../src/cron_jobs/recurring-tasks.ts | 286 +++++++++++-- .../src/interfaces/recurring-tasks.ts | 3 + .../src/jobs/recurring-tasks-queue.ts | 322 +++++++++++++++ .../src/services/recurring-tasks-service.ts | 162 ++++++++ .../src/utils/recurring-tasks-audit-logger.ts | 189 +++++++++ .../utils/recurring-tasks-notifications.ts | 260 ++++++++++++ .../src/utils/recurring-tasks-permissions.ts | 187 +++++++++ worklenz-backend/src/utils/retry-utils.ts | 134 ++++++ worklenz-backend/src/utils/timezone-utils.ts | 156 +++++++ .../task-drawer-recurring-config.tsx | 107 ++++- .../types/tasks/task-recurring-schedule.ts | 3 + 21 files changed, 2771 insertions(+), 48 deletions(-) create mode 100644 package.json create mode 100644 worklenz-backend/database/sql/functions/create_bulk_recurring_tasks.sql create mode 100644 worklenz-backend/database/sql/migrations/add_notifications_for_recurring_tasks.sql create mode 100644 worklenz-backend/database/sql/migrations/add_recurring_tasks_audit_log.sql create mode 100644 worklenz-backend/database/sql/migrations/add_timezone_support.sql create mode 100644 worklenz-backend/job-queue-dependencies.md create mode 100644 worklenz-backend/src/config/recurring-tasks-config.ts create mode 100644 worklenz-backend/src/controllers/recurring-tasks-admin-controller.ts create mode 100644 worklenz-backend/src/jobs/recurring-tasks-queue.ts create mode 100644 worklenz-backend/src/services/recurring-tasks-service.ts create mode 100644 worklenz-backend/src/utils/recurring-tasks-audit-logger.ts create mode 100644 worklenz-backend/src/utils/recurring-tasks-notifications.ts create mode 100644 worklenz-backend/src/utils/recurring-tasks-permissions.ts create mode 100644 worklenz-backend/src/utils/retry-utils.ts create mode 100644 worklenz-backend/src/utils/timezone-utils.ts diff --git a/package-lock.json b/package-lock.json index 57245686..8cc52d9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2,5 +2,385 @@ "name": "worklenz", "lockfileVersion": 3, "requires": true, - "packages": {} + "packages": { + "": { + "dependencies": { + "bull": "^4.16.5", + "ioredis": "^5.6.1" + }, + "devDependencies": { + "@types/bull": "^3.15.9" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "license": "MIT" + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/bull": { + "version": "3.15.9", + "resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.9.tgz", + "integrity": "sha512-MPUcyPPQauAmynoO3ezHAmCOhbB0pWmYyijr/5ctaCqhbKWsjW0YCod38ZcLzUBprosfZ9dPqfYIcfdKjk7RNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ioredis": "*", + "@types/redis": "^2.8.0" + } + }, + "node_modules/@types/ioredis": { + "version": "4.28.10", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", + "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "24.0.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.15.tgz", + "integrity": "sha512-oaeTSbCef7U/z7rDeJA138xpG3NuKc64/rZ2qmUFkFJmnMsAPaluIifqyWd8hSSMxyP9oie3dLAqYPblag9KgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/redis": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", + "integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/bull": { + "version": "4.16.5", + "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", + "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.9.0", + "get-port": "^5.1.1", + "ioredis": "^5.3.2", + "lodash": "^4.17.21", + "msgpackr": "^1.11.2", + "semver": "^7.5.2", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/get-port": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", + "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ioredis": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", + "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + } + } } diff --git a/package.json b/package.json new file mode 100644 index 00000000..60e7cd0a --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "dependencies": { + "bull": "^4.16.5", + "ioredis": "^5.6.1" + }, + "devDependencies": { + "@types/bull": "^3.15.9" + } +} diff --git a/worklenz-backend/database/sql/functions/create_bulk_recurring_tasks.sql b/worklenz-backend/database/sql/functions/create_bulk_recurring_tasks.sql new file mode 100644 index 00000000..deabc293 --- /dev/null +++ b/worklenz-backend/database/sql/functions/create_bulk_recurring_tasks.sql @@ -0,0 +1,185 @@ +-- Function to create multiple recurring tasks in bulk +CREATE OR REPLACE FUNCTION create_bulk_recurring_tasks( + p_tasks JSONB +) +RETURNS TABLE ( + task_id UUID, + task_name TEXT, + created BOOLEAN, + error_message TEXT +) AS $$ +DECLARE + v_task JSONB; + v_task_id UUID; + v_existing_id UUID; + v_error_message TEXT; +BEGIN + -- Create a temporary table to store results + CREATE TEMP TABLE IF NOT EXISTS bulk_task_results ( + task_id UUID, + task_name TEXT, + created BOOLEAN, + error_message TEXT + ) ON COMMIT DROP; + + -- Iterate through each task in the array + FOR v_task IN SELECT * FROM jsonb_array_elements(p_tasks) + LOOP + BEGIN + -- Check if task already exists for this schedule and date + SELECT id INTO v_existing_id + FROM tasks + WHERE schedule_id = (v_task->>'schedule_id')::UUID + AND end_date::DATE = (v_task->>'end_date')::DATE + LIMIT 1; + + IF v_existing_id IS NOT NULL THEN + -- Task already exists + INSERT INTO bulk_task_results (task_id, task_name, created, error_message) + VALUES (v_existing_id, v_task->>'name', FALSE, 'Task already exists for this date'); + ELSE + -- Create the task using existing function + SELECT (create_quick_task(v_task::TEXT)::JSONB)->>'id' INTO v_task_id; + + IF v_task_id IS NOT NULL THEN + INSERT INTO bulk_task_results (task_id, task_name, created, error_message) + VALUES (v_task_id::UUID, v_task->>'name', TRUE, NULL); + ELSE + INSERT INTO bulk_task_results (task_id, task_name, created, error_message) + VALUES (NULL, v_task->>'name', FALSE, 'Failed to create task'); + END IF; + END IF; + + EXCEPTION WHEN OTHERS THEN + -- Capture any errors + v_error_message := SQLERRM; + INSERT INTO bulk_task_results (task_id, task_name, created, error_message) + VALUES (NULL, v_task->>'name', FALSE, v_error_message); + END; + END LOOP; + + -- Return all results + RETURN QUERY SELECT * FROM bulk_task_results; +END; +$$ LANGUAGE plpgsql; + +-- Function to bulk assign team members to tasks +CREATE OR REPLACE FUNCTION bulk_assign_team_members( + p_assignments JSONB +) +RETURNS TABLE ( + task_id UUID, + team_member_id UUID, + assigned BOOLEAN, + error_message TEXT +) AS $$ +DECLARE + v_assignment JSONB; + v_result RECORD; +BEGIN + CREATE TEMP TABLE IF NOT EXISTS bulk_assignment_results ( + task_id UUID, + team_member_id UUID, + assigned BOOLEAN, + error_message TEXT + ) ON COMMIT DROP; + + FOR v_assignment IN SELECT * FROM jsonb_array_elements(p_assignments) + LOOP + BEGIN + -- Check if assignment already exists + IF EXISTS ( + SELECT 1 FROM tasks_assignees + WHERE task_id = (v_assignment->>'task_id')::UUID + AND team_member_id = (v_assignment->>'team_member_id')::UUID + ) THEN + INSERT INTO bulk_assignment_results + VALUES ( + (v_assignment->>'task_id')::UUID, + (v_assignment->>'team_member_id')::UUID, + FALSE, + 'Assignment already exists' + ); + ELSE + -- Create the assignment + INSERT INTO tasks_assignees (task_id, team_member_id, assigned_by) + VALUES ( + (v_assignment->>'task_id')::UUID, + (v_assignment->>'team_member_id')::UUID, + (v_assignment->>'assigned_by')::UUID + ); + + INSERT INTO bulk_assignment_results + VALUES ( + (v_assignment->>'task_id')::UUID, + (v_assignment->>'team_member_id')::UUID, + TRUE, + NULL + ); + END IF; + EXCEPTION WHEN OTHERS THEN + INSERT INTO bulk_assignment_results + VALUES ( + (v_assignment->>'task_id')::UUID, + (v_assignment->>'team_member_id')::UUID, + FALSE, + SQLERRM + ); + END; + END LOOP; + + RETURN QUERY SELECT * FROM bulk_assignment_results; +END; +$$ LANGUAGE plpgsql; + +-- Function to bulk assign labels to tasks +CREATE OR REPLACE FUNCTION bulk_assign_labels( + p_label_assignments JSONB +) +RETURNS TABLE ( + task_id UUID, + label_id UUID, + assigned BOOLEAN, + error_message TEXT +) AS $$ +DECLARE + v_assignment JSONB; + v_labels JSONB; +BEGIN + CREATE TEMP TABLE IF NOT EXISTS bulk_label_results ( + task_id UUID, + label_id UUID, + assigned BOOLEAN, + error_message TEXT + ) ON COMMIT DROP; + + FOR v_assignment IN SELECT * FROM jsonb_array_elements(p_label_assignments) + LOOP + BEGIN + -- Use existing function to add label + SELECT add_or_remove_task_label( + (v_assignment->>'task_id')::UUID, + (v_assignment->>'label_id')::UUID + ) INTO v_labels; + + INSERT INTO bulk_label_results + VALUES ( + (v_assignment->>'task_id')::UUID, + (v_assignment->>'label_id')::UUID, + TRUE, + NULL + ); + EXCEPTION WHEN OTHERS THEN + INSERT INTO bulk_label_results + VALUES ( + (v_assignment->>'task_id')::UUID, + (v_assignment->>'label_id')::UUID, + FALSE, + SQLERRM + ); + END; + END LOOP; + + RETURN QUERY SELECT * FROM bulk_label_results; +END; +$$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/worklenz-backend/database/sql/migrations/add_notifications_for_recurring_tasks.sql b/worklenz-backend/database/sql/migrations/add_notifications_for_recurring_tasks.sql new file mode 100644 index 00000000..0577d780 --- /dev/null +++ b/worklenz-backend/database/sql/migrations/add_notifications_for_recurring_tasks.sql @@ -0,0 +1,40 @@ +-- Create notifications table if it doesn't exist +CREATE TABLE IF NOT EXISTS notifications ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + message TEXT NOT NULL, + data JSONB, + read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + read_at TIMESTAMP WITH TIME ZONE +); + +-- Create user_push_tokens table if it doesn't exist (for future push notifications) +CREATE TABLE IF NOT EXISTS user_push_tokens ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + push_token TEXT NOT NULL, + device_type VARCHAR(20), + active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, push_token) +); + +-- Add notification preferences to users table if they don't exist +ALTER TABLE users +ADD COLUMN IF NOT EXISTS email_notifications BOOLEAN DEFAULT TRUE, +ADD COLUMN IF NOT EXISTS push_notifications BOOLEAN DEFAULT TRUE, +ADD COLUMN IF NOT EXISTS in_app_notifications BOOLEAN DEFAULT TRUE; + +-- Create indexes for better performance +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at); +CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, read) WHERE read = FALSE; +CREATE INDEX IF NOT EXISTS idx_user_push_tokens_user_id ON user_push_tokens(user_id); + +-- Comments +COMMENT ON TABLE notifications IS 'In-app notifications for users'; +COMMENT ON TABLE user_push_tokens IS 'Push notification tokens for mobile devices'; +COMMENT ON COLUMN notifications.data IS 'Additional notification data in JSON format'; +COMMENT ON COLUMN user_push_tokens.device_type IS 'Device type: ios, android, web'; \ No newline at end of file diff --git a/worklenz-backend/database/sql/migrations/add_recurring_tasks_audit_log.sql b/worklenz-backend/database/sql/migrations/add_recurring_tasks_audit_log.sql new file mode 100644 index 00000000..d8ac251e --- /dev/null +++ b/worklenz-backend/database/sql/migrations/add_recurring_tasks_audit_log.sql @@ -0,0 +1,94 @@ +-- Create audit log table for recurring task operations +CREATE TABLE IF NOT EXISTS recurring_tasks_audit_log ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + operation_type VARCHAR(50) NOT NULL, + template_id UUID, + schedule_id UUID, + task_id UUID, + template_name TEXT, + success BOOLEAN DEFAULT TRUE, + error_message TEXT, + details JSONB, + created_tasks_count INTEGER DEFAULT 0, + failed_tasks_count INTEGER DEFAULT 0, + execution_time_ms INTEGER, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES users(id) +); + +-- Create indexes for better query performance +CREATE INDEX idx_recurring_tasks_audit_log_template_id ON recurring_tasks_audit_log(template_id); +CREATE INDEX idx_recurring_tasks_audit_log_schedule_id ON recurring_tasks_audit_log(schedule_id); +CREATE INDEX idx_recurring_tasks_audit_log_created_at ON recurring_tasks_audit_log(created_at); +CREATE INDEX idx_recurring_tasks_audit_log_operation_type ON recurring_tasks_audit_log(operation_type); + +-- Add comments +COMMENT ON TABLE recurring_tasks_audit_log IS 'Audit log for all recurring task operations'; +COMMENT ON COLUMN recurring_tasks_audit_log.operation_type IS 'Type of operation: cron_job_run, manual_trigger, schedule_created, schedule_updated, schedule_deleted, etc.'; +COMMENT ON COLUMN recurring_tasks_audit_log.details IS 'Additional details about the operation in JSON format'; + +-- Create a function to log recurring task operations +CREATE OR REPLACE FUNCTION log_recurring_task_operation( + p_operation_type VARCHAR(50), + p_template_id UUID DEFAULT NULL, + p_schedule_id UUID DEFAULT NULL, + p_task_id UUID DEFAULT NULL, + p_template_name TEXT DEFAULT NULL, + p_success BOOLEAN DEFAULT TRUE, + p_error_message TEXT DEFAULT NULL, + p_details JSONB DEFAULT NULL, + p_created_tasks_count INTEGER DEFAULT 0, + p_failed_tasks_count INTEGER DEFAULT 0, + p_execution_time_ms INTEGER DEFAULT NULL, + p_created_by UUID DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_log_id UUID; +BEGIN + INSERT INTO recurring_tasks_audit_log ( + operation_type, + template_id, + schedule_id, + task_id, + template_name, + success, + error_message, + details, + created_tasks_count, + failed_tasks_count, + execution_time_ms, + created_by + ) VALUES ( + p_operation_type, + p_template_id, + p_schedule_id, + p_task_id, + p_template_name, + p_success, + p_error_message, + p_details, + p_created_tasks_count, + p_failed_tasks_count, + p_execution_time_ms, + p_created_by + ) RETURNING id INTO v_log_id; + + RETURN v_log_id; +END; +$$ LANGUAGE plpgsql; + +-- Create a view for recent audit logs +CREATE OR REPLACE VIEW v_recent_recurring_tasks_audit AS +SELECT + l.*, + u.name as created_by_name, + t.name as current_template_name, + s.schedule_type, + s.timezone +FROM recurring_tasks_audit_log l +LEFT JOIN users u ON l.created_by = u.id +LEFT JOIN task_recurring_templates t ON l.template_id = t.id +LEFT JOIN task_recurring_schedules s ON l.schedule_id = s.id +WHERE l.created_at >= CURRENT_TIMESTAMP - INTERVAL '30 days' +ORDER BY l.created_at DESC; \ No newline at end of file diff --git a/worklenz-backend/database/sql/migrations/add_timezone_support.sql b/worklenz-backend/database/sql/migrations/add_timezone_support.sql new file mode 100644 index 00000000..2c48f758 --- /dev/null +++ b/worklenz-backend/database/sql/migrations/add_timezone_support.sql @@ -0,0 +1,44 @@ +-- Add timezone support to recurring tasks + +-- Add timezone column to task_recurring_schedules +ALTER TABLE task_recurring_schedules +ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) DEFAULT 'UTC'; + +-- Add timezone column to task_recurring_templates +ALTER TABLE task_recurring_templates +ADD COLUMN IF NOT EXISTS reporter_timezone VARCHAR(50); + +-- Add date_of_month column if not exists (for monthly schedules) +ALTER TABLE task_recurring_schedules +ADD COLUMN IF NOT EXISTS date_of_month INTEGER; + +-- Add last_checked_at and last_created_task_end_date columns for tracking +ALTER TABLE task_recurring_schedules +ADD COLUMN IF NOT EXISTS last_checked_at TIMESTAMP WITH TIME ZONE, +ADD COLUMN IF NOT EXISTS last_created_task_end_date TIMESTAMP WITH TIME ZONE; + +-- Add end_date and excluded_dates columns for schedule control +ALTER TABLE task_recurring_schedules +ADD COLUMN IF NOT EXISTS end_date DATE, +ADD COLUMN IF NOT EXISTS excluded_dates TEXT[]; + +-- Create index on timezone for better query performance +CREATE INDEX IF NOT EXISTS idx_task_recurring_schedules_timezone +ON task_recurring_schedules(timezone); + +-- Update existing records to use user's timezone if available +UPDATE task_recurring_schedules trs +SET timezone = COALESCE( + (SELECT u.timezone + FROM task_recurring_templates trt + JOIN tasks t ON trt.task_id = t.id + JOIN users u ON t.reporter_id = u.id + WHERE trt.schedule_id = trs.id + LIMIT 1), + 'UTC' +) +WHERE trs.timezone IS NULL OR trs.timezone = 'UTC'; + +-- Add comment to explain timezone field +COMMENT ON COLUMN task_recurring_schedules.timezone IS 'IANA timezone identifier for schedule calculations'; +COMMENT ON COLUMN task_recurring_templates.reporter_timezone IS 'Original reporter timezone for reference'; \ No newline at end of file diff --git a/worklenz-backend/job-queue-dependencies.md b/worklenz-backend/job-queue-dependencies.md new file mode 100644 index 00000000..a85410ac --- /dev/null +++ b/worklenz-backend/job-queue-dependencies.md @@ -0,0 +1,111 @@ +# Job Queue Dependencies + +To use the job queue implementation for recurring tasks, add these dependencies to your package.json: + +```json +{ + "dependencies": { + "bull": "^4.12.2", + "ioredis": "^5.3.2" + }, + "devDependencies": { + "@types/bull": "^4.10.0" + } +} +``` + +## Installation + +```bash +npm install bull ioredis +npm install --save-dev @types/bull +``` + +## Redis Setup + +1. Install Redis on your system: + - **Ubuntu/Debian**: `sudo apt install redis-server` + - **macOS**: `brew install redis` + - **Windows**: Use WSL or Redis for Windows + - **Docker**: `docker run -d -p 6379:6379 redis:alpine` + +2. Configure Redis connection in your environment variables: + ```env + REDIS_HOST=localhost + REDIS_PORT=6379 + REDIS_PASSWORD=your_password # Optional + REDIS_DB=0 + ``` + +## Configuration + +Add these environment variables to control the recurring tasks behavior: + +```env +# Service configuration +RECURRING_TASKS_ENABLED=true +RECURRING_TASKS_MODE=queue # or 'cron' + +# Queue configuration +RECURRING_TASKS_MAX_CONCURRENCY=5 +RECURRING_TASKS_RETRY_ATTEMPTS=3 +RECURRING_TASKS_RETRY_DELAY=2000 + +# Notifications +RECURRING_TASKS_NOTIFICATIONS_ENABLED=true +RECURRING_TASKS_EMAIL_NOTIFICATIONS=true +RECURRING_TASKS_PUSH_NOTIFICATIONS=true +RECURRING_TASKS_IN_APP_NOTIFICATIONS=true + +# Audit logging +RECURRING_TASKS_AUDIT_LOG_ENABLED=true +RECURRING_TASKS_AUDIT_RETENTION_DAYS=90 +``` + +## Usage + +In your main application file, start the service: + +```typescript +import { RecurringTasksService } from './src/services/recurring-tasks-service'; + +// Start the service +await RecurringTasksService.start(); + +// Get status +const status = await RecurringTasksService.getStatus(); +console.log('Recurring tasks status:', status); + +// Health check +const health = await RecurringTasksService.healthCheck(); +console.log('Health check:', health); +``` + +## Benefits of Job Queue vs Cron + +### Job Queue (Bull/BullMQ) Benefits: +- **Better scalability**: Can run multiple workers +- **Retry logic**: Built-in retry with exponential backoff +- **Monitoring**: Redis-based job monitoring and UI +- **Priority queues**: Handle urgent tasks first +- **Rate limiting**: Control processing rate +- **Persistence**: Jobs survive server restarts + +### Cron Job Benefits: +- **Simplicity**: No external dependencies +- **Lower resource usage**: No Redis required +- **Predictable timing**: Runs exactly on schedule +- **Easier debugging**: Simpler execution model + +## Monitoring + +You can monitor the job queues using: +- **Bull Dashboard**: Web UI for monitoring jobs +- **Redis CLI**: Direct Redis monitoring +- **Application logs**: Built-in audit logging +- **Health checks**: Built-in health check endpoint + +Install Bull Dashboard for monitoring: +```bash +npm install -g bull-board +``` \ No newline at end of file diff --git a/worklenz-backend/src/config/recurring-tasks-config.ts b/worklenz-backend/src/config/recurring-tasks-config.ts new file mode 100644 index 00000000..e2e230c1 --- /dev/null +++ b/worklenz-backend/src/config/recurring-tasks-config.ts @@ -0,0 +1,57 @@ +export interface RecurringTasksConfig { + enabled: boolean; + mode: 'cron' | 'queue'; + cronInterval: string; + redisConfig: { + host: string; + port: number; + password?: string; + db: number; + }; + queueOptions: { + maxConcurrency: number; + retryAttempts: number; + retryDelay: number; + }; + notifications: { + enabled: boolean; + email: boolean; + push: boolean; + inApp: boolean; + }; + auditLog: { + enabled: boolean; + retentionDays: number; + }; +} + +export const recurringTasksConfig: RecurringTasksConfig = { + enabled: process.env.RECURRING_TASKS_ENABLED !== 'false', + mode: (process.env.RECURRING_TASKS_MODE as 'cron' | 'queue') || 'cron', + cronInterval: process.env.RECURRING_JOBS_INTERVAL || '0 * * * *', + + redisConfig: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379'), + password: process.env.REDIS_PASSWORD, + db: parseInt(process.env.REDIS_DB || '0'), + }, + + queueOptions: { + maxConcurrency: parseInt(process.env.RECURRING_TASKS_MAX_CONCURRENCY || '5'), + retryAttempts: parseInt(process.env.RECURRING_TASKS_RETRY_ATTEMPTS || '3'), + retryDelay: parseInt(process.env.RECURRING_TASKS_RETRY_DELAY || '2000'), + }, + + notifications: { + enabled: process.env.RECURRING_TASKS_NOTIFICATIONS_ENABLED !== 'false', + email: process.env.RECURRING_TASKS_EMAIL_NOTIFICATIONS !== 'false', + push: process.env.RECURRING_TASKS_PUSH_NOTIFICATIONS !== 'false', + inApp: process.env.RECURRING_TASKS_IN_APP_NOTIFICATIONS !== 'false', + }, + + auditLog: { + enabled: process.env.RECURRING_TASKS_AUDIT_LOG_ENABLED !== 'false', + retentionDays: parseInt(process.env.RECURRING_TASKS_AUDIT_RETENTION_DAYS || '90'), + }, +}; \ No newline at end of file diff --git a/worklenz-backend/src/controllers/recurring-tasks-admin-controller.ts b/worklenz-backend/src/controllers/recurring-tasks-admin-controller.ts new file mode 100644 index 00000000..60f73490 --- /dev/null +++ b/worklenz-backend/src/controllers/recurring-tasks-admin-controller.ts @@ -0,0 +1,48 @@ +import { IWorkLenzRequest } from "../interfaces/worklenz-request"; +import { IWorkLenzResponse } from "../interfaces/worklenz-response"; +import { ServerResponse } from "../models/server-response"; +import WorklenzControllerBase from "./worklenz-controller-base"; +import HandleExceptions from "../decorators/handle-exceptions"; +import { RecurringTasksPermissions } from "../utils/recurring-tasks-permissions"; +import { RecurringTasksAuditLogger } from "../utils/recurring-tasks-audit-logger"; + +export default class RecurringTasksAdminController extends WorklenzControllerBase { + /** + * Get templates with permission issues + */ + @HandleExceptions() + public static async getPermissionIssues(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const issues = await RecurringTasksPermissions.getTemplatesWithPermissionIssues(); + return res.status(200).send(new ServerResponse(true, issues)); + } + + /** + * Get audit log summary + */ + @HandleExceptions() + public static async getAuditSummary(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { days = 7 } = req.query; + const summary = await RecurringTasksAuditLogger.getAuditSummary(Number(days)); + return res.status(200).send(new ServerResponse(true, summary)); + } + + /** + * Get recent errors from audit log + */ + @HandleExceptions() + public static async getRecentErrors(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { limit = 10 } = req.query; + const errors = await RecurringTasksAuditLogger.getRecentErrors(Number(limit)); + return res.status(200).send(new ServerResponse(true, errors)); + } + + /** + * Validate a specific template + */ + @HandleExceptions() + public static async validateTemplate(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { templateId } = req.params; + const result = await RecurringTasksPermissions.validateTemplatePermissions(templateId); + return res.status(200).send(new ServerResponse(true, result)); + } +} \ No newline at end of file diff --git a/worklenz-backend/src/controllers/task-recurring-controller.ts b/worklenz-backend/src/controllers/task-recurring-controller.ts index 6dd2dfc1..c498fee8 100644 --- a/worklenz-backend/src/controllers/task-recurring-controller.ts +++ b/worklenz-backend/src/controllers/task-recurring-controller.ts @@ -6,6 +6,7 @@ import { IWorkLenzRequest } from "../interfaces/worklenz-request"; import { IWorkLenzResponse } from "../interfaces/worklenz-response"; import { ServerResponse } from "../models/server-response"; import { calculateNextEndDate, log_error } from "../shared/utils"; +import { RecurringTasksAuditLogger, RecurringTaskOperationType } from "../utils/recurring-tasks-audit-logger"; export default class TaskRecurringController extends WorklenzControllerBase { @HandleExceptions() @@ -34,7 +35,7 @@ export default class TaskRecurringController extends WorklenzControllerBase { } @HandleExceptions() - public static async createTaskSchedule(taskId: string) { + public static async createTaskSchedule(taskId: string, userId?: string) { const q = `INSERT INTO task_recurring_schedules (schedule_type) VALUES ('daily') RETURNING id, schedule_type;`; const result = await db.query(q, []); const [data] = result.rows; @@ -44,6 +45,15 @@ export default class TaskRecurringController extends WorklenzControllerBase { await TaskRecurringController.insertTaskRecurringTemplate(taskId, data.id); + // Log schedule creation + await RecurringTasksAuditLogger.logScheduleChange( + RecurringTaskOperationType.SCHEDULE_CREATED, + data.id, + taskId, + userId, + { schedule_type: data.schedule_type } + ); + return data; } @@ -56,9 +66,9 @@ export default class TaskRecurringController extends WorklenzControllerBase { @HandleExceptions() public static async updateSchedule(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const { id } = req.params; - const { schedule_type, days_of_week, day_of_month, week_of_month, interval_days, interval_weeks, interval_months, date_of_month } = req.body; + const { schedule_type, days_of_week, day_of_month, week_of_month, interval_days, interval_weeks, interval_months, date_of_month, timezone, end_date, excluded_dates } = req.body; - const deleteQ = `UPDATE task_recurring_schedules + const updateQ = `UPDATE task_recurring_schedules SET schedule_type = $1, days_of_week = $2, date_of_month = $3, @@ -66,9 +76,27 @@ export default class TaskRecurringController extends WorklenzControllerBase { week_of_month = $5, interval_days = $6, interval_weeks = $7, - interval_months = $8 - WHERE id = $9;`; - await db.query(deleteQ, [schedule_type, days_of_week, date_of_month, day_of_month, week_of_month, interval_days, interval_weeks, interval_months, id]); + interval_months = $8, + timezone = COALESCE($9, timezone, 'UTC'), + end_date = $10, + excluded_dates = $11 + WHERE id = $12;`; + await db.query(updateQ, [schedule_type, days_of_week, date_of_month, day_of_month, week_of_month, interval_days, interval_weeks, interval_months, timezone, end_date, excluded_dates, id]); + + // Log schedule update + await RecurringTasksAuditLogger.logScheduleChange( + RecurringTaskOperationType.SCHEDULE_UPDATED, + id, + undefined, + req.user?.id, + { + schedule_type, + timezone, + end_date, + excluded_dates_count: excluded_dates?.length || 0 + } + ); + return res.status(200).send(new ServerResponse(true, null)); } diff --git a/worklenz-backend/src/cron_jobs/recurring-tasks.ts b/worklenz-backend/src/cron_jobs/recurring-tasks.ts index 2780edd5..44fef665 100644 --- a/worklenz-backend/src/cron_jobs/recurring-tasks.ts +++ b/worklenz-backend/src/cron_jobs/recurring-tasks.ts @@ -2,12 +2,16 @@ import { CronJob } from "cron"; import { calculateNextEndDate, log_error } from "../shared/utils"; import db from "../config/db"; import { IRecurringSchedule, ITaskTemplate } from "../interfaces/recurring-tasks"; -import moment from "moment"; +import moment from "moment-timezone"; import TasksController from "../controllers/tasks-controller"; +import { TimezoneUtils } from "../utils/timezone-utils"; +import { RetryUtils } from "../utils/retry-utils"; +import { RecurringTasksAuditLogger, RecurringTaskOperationType } from "../utils/recurring-tasks-audit-logger"; +import { RecurringTasksPermissions } from "../utils/recurring-tasks-permissions"; +import { RecurringTasksNotifications } from "../utils/recurring-tasks-notifications"; -// At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday. -// const TIME = "0 11 */1 * 1-5"; -const TIME = process.env.RECURRING_JOBS_INTERVAL || "0 11 */1 * 1-5"; +// Run every hour to process tasks in different timezones +const TIME = process.env.RECURRING_JOBS_INTERVAL || "0 * * * *"; const TIME_FORMAT = "YYYY-MM-DD"; // const TIME = "0 0 * * *"; // Runs at midnight every day @@ -44,8 +48,129 @@ function getFutureLimit(scheduleType: string, interval?: number): moment.Duratio } } -// Helper function to batch create tasks +// Helper function to batch create tasks using bulk operations async function createBatchTasks(template: ITaskTemplate & IRecurringSchedule, endDates: moment.Moment[]) { + if (endDates.length === 0) return []; + + try { + // Prepare bulk task data + const tasksData = endDates.map(endDate => ({ + name: template.name, + priority_id: template.priority_id, + project_id: template.project_id, + reporter_id: template.reporter_id, + status_id: template.status_id || null, + end_date: endDate.format(TIME_FORMAT), + schedule_id: template.schedule_id + })); + + // Create all tasks in bulk with retry logic + const createTasksResult = await RetryUtils.withDatabaseRetry(async () => { + const createTasksQuery = `SELECT * FROM create_bulk_recurring_tasks($1::JSONB);`; + return await db.query(createTasksQuery, [JSON.stringify(tasksData)]); + }, `create_bulk_recurring_tasks for template ${template.name}`); + + const createdTasks = createTasksResult.rows.filter(row => row.created); + const failedTasks = createTasksResult.rows.filter(row => !row.created); + + // Log results + if (createdTasks.length > 0) { + console.log(`Created ${createdTasks.length} tasks for template ${template.name}`); + } + if (failedTasks.length > 0) { + failedTasks.forEach(task => { + console.log(`Failed to create task for template ${template.name}: ${task.error_message}`); + }); + } + + // Only process assignments for successfully created tasks + if (createdTasks.length > 0 && (template.assignees?.length > 0 || template.labels?.length > 0)) { + // Validate assignee permissions + let validAssignees = template.assignees || []; + if (validAssignees.length > 0) { + const invalidAssignees = await RecurringTasksPermissions.validateAssigneePermissions( + validAssignees, + template.project_id + ); + + if (invalidAssignees.length > 0) { + console.log(`Warning: ${invalidAssignees.length} assignees do not have permissions for project ${template.project_id}`); + // Filter out invalid assignees + validAssignees = validAssignees.filter( + a => !invalidAssignees.includes(a.team_member_id) + ); + } + } + + // Prepare bulk assignments + const assignments = []; + const labelAssignments = []; + + for (const task of createdTasks) { + // Prepare team member assignments with validated assignees + if (validAssignees.length > 0) { + for (const assignee of validAssignees) { + assignments.push({ + task_id: task.task_id, + team_member_id: assignee.team_member_id, + assigned_by: assignee.assigned_by + }); + } + } + + // Prepare label assignments + if (template.labels?.length > 0) { + for (const label of template.labels) { + labelAssignments.push({ + task_id: task.task_id, + label_id: label.label_id + }); + } + } + } + + // Bulk assign team members with retry logic + if (assignments.length > 0) { + await RetryUtils.withDatabaseRetry(async () => { + const assignQuery = `SELECT * FROM bulk_assign_team_members($1::JSONB);`; + return await db.query(assignQuery, [JSON.stringify(assignments)]); + }, `bulk_assign_team_members for template ${template.name}`); + } + + // Bulk assign labels with retry logic + if (labelAssignments.length > 0) { + await RetryUtils.withDatabaseRetry(async () => { + const labelQuery = `SELECT * FROM bulk_assign_labels($1::JSONB);`; + return await db.query(labelQuery, [JSON.stringify(labelAssignments)]); + }, `bulk_assign_labels for template ${template.name}`); + } + + // Send notifications for created tasks + if (createdTasks.length > 0) { + const taskData = createdTasks.map(task => ({ id: task.task_id, name: task.task_name })); + const assigneeIds = template.assignees?.map(a => a.team_member_id) || []; + + await RecurringTasksNotifications.notifyRecurringTasksCreated( + template.name, + template.project_id, + taskData, + assigneeIds, + template.reporter_id + ); + } + } + + return createdTasks.map(task => ({ id: task.task_id, name: task.task_name })); + } catch (error) { + log_error("Error in bulk task creation:", error); + // Fallback to sequential creation if bulk operation fails + console.log("Falling back to sequential task creation"); + return createBatchTasksSequential(template, endDates); + } +} + +// Fallback function for sequential task creation +async function createBatchTasksSequential(template: ITaskTemplate & IRecurringSchedule, endDates: moment.Moment[]) { const createdTasks = []; for (const nextEndDate of endDates) { @@ -92,69 +217,162 @@ async function createBatchTasks(template: ITaskTemplate & IRecurringSchedule, en } async function onRecurringTaskJobTick() { + const errors: any[] = []; + try { log("(cron) Recurring tasks job started."); + RecurringTasksAuditLogger.startTimer(); - const templatesQuery = ` - SELECT t.*, s.*, (SELECT MAX(end_date) FROM tasks WHERE schedule_id = s.id) as last_task_end_date - FROM task_recurring_templates t - JOIN task_recurring_schedules s ON t.schedule_id = s.id; - `; - const templatesResult = await db.query(templatesQuery); - const templates = templatesResult.rows as (ITaskTemplate & IRecurringSchedule)[]; + // Get all active timezones where it's currently the scheduled hour + const activeTimezones = TimezoneUtils.getActiveTimezones(); + log(`Processing recurring tasks for ${activeTimezones.length} timezones`); + + // Fetch templates with retry logic + const templatesResult = await RetryUtils.withDatabaseRetry(async () => { + const templatesQuery = ` + SELECT t.*, s.*, + (SELECT MAX(end_date) FROM tasks WHERE schedule_id = s.id) as last_task_end_date, + u.timezone as user_timezone + FROM task_recurring_templates t + JOIN task_recurring_schedules s ON t.schedule_id = s.id + LEFT JOIN tasks orig_task ON t.task_id = orig_task.id + LEFT JOIN users u ON orig_task.reporter_id = u.id + WHERE s.end_date IS NULL OR s.end_date >= CURRENT_DATE; + `; + return await db.query(templatesQuery); + }, "fetch_recurring_templates"); + + const templates = templatesResult.rows as (ITaskTemplate & IRecurringSchedule & { user_timezone?: string })[]; - const now = moment(); let createdTaskCount = 0; for (const template of templates) { + // Check template permissions before processing + const permissionCheck = await RecurringTasksPermissions.validateTemplatePermissions(template.task_id); + if (!permissionCheck.hasPermission) { + console.log(`Skipping template ${template.name}: ${permissionCheck.reason}`); + + // Log permission issue + await RecurringTasksAuditLogger.log({ + operationType: RecurringTaskOperationType.TASKS_CREATION_FAILED, + templateId: template.task_id, + scheduleId: template.schedule_id, + templateName: template.name, + success: false, + errorMessage: `Permission denied: ${permissionCheck.reason}`, + details: { permissionCheck } + }); + + continue; + } + + // Use template timezone or user timezone or default to UTC + const timezone = template.timezone || TimezoneUtils.getUserTimezone(template.user_timezone); + + // Check if this template should run in the current hour for its timezone + if (!activeTimezones.includes(timezone) && timezone !== 'UTC') { + continue; + } + + const now = TimezoneUtils.nowInTimezone(timezone); const lastTaskEndDate = template.last_task_end_date - ? moment(template.last_task_end_date) - : moment(template.created_at); + ? moment.tz(template.last_task_end_date, timezone) + : moment.tz(template.created_at, timezone); // Calculate future limit based on schedule type - const futureLimit = moment(template.last_checked_at || template.created_at) + const futureLimit = moment.tz(template.last_checked_at || template.created_at, timezone) .add(getFutureLimit( template.schedule_type, template.interval_days || template.interval_weeks || template.interval_months || 1 )); - let nextEndDate = calculateNextEndDate(template, lastTaskEndDate); + let nextEndDate = TimezoneUtils.calculateNextEndDateWithTimezone(template, lastTaskEndDate, timezone); const endDatesToCreate: moment.Moment[] = []; // Find all future occurrences within the limit while (nextEndDate.isSameOrBefore(futureLimit)) { if (nextEndDate.isAfter(now)) { - endDatesToCreate.push(moment(nextEndDate)); + // Check if date is not in excluded dates + if (!template.excluded_dates || !template.excluded_dates.includes(nextEndDate.format(TIME_FORMAT))) { + endDatesToCreate.push(moment(nextEndDate)); + } } - nextEndDate = calculateNextEndDate(template, nextEndDate); + nextEndDate = TimezoneUtils.calculateNextEndDateWithTimezone(template, nextEndDate, timezone); } // Batch create tasks for all future dates if (endDatesToCreate.length > 0) { - const createdTasks = await createBatchTasks(template, endDatesToCreate); - createdTaskCount += createdTasks.length; + try { + const createdTasks = await createBatchTasks(template, endDatesToCreate); + createdTaskCount += createdTasks.length; - // Update the last_checked_at in the schedule - const updateScheduleQuery = ` - UPDATE task_recurring_schedules - SET last_checked_at = $1::DATE, - last_created_task_end_date = $2 - WHERE id = $3; - `; - await db.query(updateScheduleQuery, [ - moment().format(TIME_FORMAT), - endDatesToCreate[endDatesToCreate.length - 1].format(TIME_FORMAT), - template.schedule_id - ]); + // Log successful template processing + await RecurringTasksAuditLogger.logTemplateProcessing( + template.task_id, + template.name, + template.schedule_id, + createdTasks.length, + endDatesToCreate.length - createdTasks.length, + { + timezone, + endDates: endDatesToCreate.map(d => d.format(TIME_FORMAT)) + } + ); + + // Update the last_checked_at in the schedule with retry logic + await RetryUtils.withDatabaseRetry(async () => { + const updateScheduleQuery = ` + UPDATE task_recurring_schedules + SET last_checked_at = $1, + last_created_task_end_date = $2 + WHERE id = $3; + `; + return await db.query(updateScheduleQuery, [ + now.toDate(), + endDatesToCreate[endDatesToCreate.length - 1].toDate(), + template.schedule_id + ]); + }, `update_schedule for template ${template.name}`); + } catch (error) { + errors.push({ template: template.name, error }); + + // Log failed template processing + await RecurringTasksAuditLogger.logTemplateProcessing( + template.task_id, + template.name, + template.schedule_id, + 0, + endDatesToCreate.length, + { + timezone, + error: error.message || error.toString() + } + ); + } } else { - console.log(`No tasks created for template ${template.name} - next occurrence is beyond the future limit`); + console.log(`No tasks created for template ${template.name} (${timezone}) - next occurrence is beyond the future limit or excluded`); } } log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`); + + // Log cron job completion + await RecurringTasksAuditLogger.logCronJobRun( + templates.length, + createdTaskCount, + errors + ); } catch (error) { log_error(error); log("(cron) Recurring task job ended with errors."); + + // Log cron job failure + await RecurringTasksAuditLogger.log({ + operationType: RecurringTaskOperationType.CRON_JOB_ERROR, + success: false, + errorMessage: error.message || error.toString(), + details: { error: error.stack || error } + }); } } diff --git a/worklenz-backend/src/interfaces/recurring-tasks.ts b/worklenz-backend/src/interfaces/recurring-tasks.ts index a16204e0..e41b3bb5 100644 --- a/worklenz-backend/src/interfaces/recurring-tasks.ts +++ b/worklenz-backend/src/interfaces/recurring-tasks.ts @@ -12,6 +12,9 @@ export interface IRecurringSchedule { last_checked_at: Date | null; last_task_end_date: Date | null; created_at: Date; + timezone?: string; + end_date?: Date | null; + excluded_dates?: string[] | null; } interface ITaskTemplateAssignee { diff --git a/worklenz-backend/src/jobs/recurring-tasks-queue.ts b/worklenz-backend/src/jobs/recurring-tasks-queue.ts new file mode 100644 index 00000000..97dc4df7 --- /dev/null +++ b/worklenz-backend/src/jobs/recurring-tasks-queue.ts @@ -0,0 +1,322 @@ +import Bull from 'bull'; +import { TimezoneUtils } from '../utils/timezone-utils'; +import { RetryUtils } from '../utils/retry-utils'; +import { RecurringTasksAuditLogger, RecurringTaskOperationType } from '../utils/recurring-tasks-audit-logger'; +import { RecurringTasksPermissions } from '../utils/recurring-tasks-permissions'; +import { RecurringTasksNotifications } from '../utils/recurring-tasks-notifications'; +import { calculateNextEndDate, log_error } from '../shared/utils'; +import { IRecurringSchedule, ITaskTemplate } from '../interfaces/recurring-tasks'; +import moment from 'moment-timezone'; +import db from '../config/db'; + +// Configure Redis connection +const redisConfig = { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379'), + password: process.env.REDIS_PASSWORD, + db: parseInt(process.env.REDIS_DB || '0'), +}; + +// Create job queues +export const recurringTasksQueue = new Bull('recurring-tasks', { + redis: redisConfig, + defaultJobOptions: { + removeOnComplete: 100, // Keep last 100 completed jobs + removeOnFail: 50, // Keep last 50 failed jobs + attempts: 3, + backoff: { + type: 'exponential', + delay: 2000, + }, + }, +}); + +export const taskCreationQueue = new Bull('task-creation', { + redis: redisConfig, + defaultJobOptions: { + removeOnComplete: 200, + removeOnFail: 100, + attempts: 5, + backoff: { + type: 'exponential', + delay: 1000, + }, + }, +}); + +// Job data interfaces +interface RecurringTaskJobData { + templateId: string; + scheduleId: string; + timezone: string; +} + +interface TaskCreationJobData { + template: ITaskTemplate & IRecurringSchedule; + endDates: string[]; + timezone: string; +} + +// Job processors +recurringTasksQueue.process('process-template', async (job) => { + const { templateId, scheduleId, timezone }: RecurringTaskJobData = job.data; + + try { + RecurringTasksAuditLogger.startTimer(); + + // Fetch template data + const templateQuery = ` + SELECT t.*, s.*, + (SELECT MAX(end_date) FROM tasks WHERE schedule_id = s.id) as last_task_end_date, + u.timezone as user_timezone + FROM task_recurring_templates t + JOIN task_recurring_schedules s ON t.schedule_id = s.id + LEFT JOIN tasks orig_task ON t.task_id = orig_task.id + LEFT JOIN users u ON orig_task.reporter_id = u.id + WHERE t.id = $1 AND s.id = $2 + `; + + const result = await RetryUtils.withDatabaseRetry(async () => { + return await db.query(templateQuery, [templateId, scheduleId]); + }, 'fetch_template_for_job'); + + if (result.rows.length === 0) { + throw new Error(`Template ${templateId} not found`); + } + + const template = result.rows[0] as ITaskTemplate & IRecurringSchedule & { user_timezone?: string }; + + // Check permissions + const permissionCheck = await RecurringTasksPermissions.validateTemplatePermissions(template.task_id); + if (!permissionCheck.hasPermission) { + await RecurringTasksAuditLogger.log({ + operationType: RecurringTaskOperationType.TASKS_CREATION_FAILED, + templateId: template.task_id, + scheduleId: template.schedule_id, + templateName: template.name, + success: false, + errorMessage: `Permission denied: ${permissionCheck.reason}`, + details: { permissionCheck, processedBy: 'job_queue' } + }); + return; + } + + // Calculate dates to create + const now = TimezoneUtils.nowInTimezone(timezone); + const lastTaskEndDate = template.last_task_end_date + ? moment.tz(template.last_task_end_date, timezone) + : moment.tz(template.created_at, timezone); + + const futureLimit = moment.tz(template.last_checked_at || template.created_at, timezone) + .add(getFutureLimit( + template.schedule_type, + template.interval_days || template.interval_weeks || template.interval_months || 1 + )); + + let nextEndDate = TimezoneUtils.calculateNextEndDateWithTimezone(template, lastTaskEndDate, timezone); + const endDatesToCreate: string[] = []; + + while (nextEndDate.isSameOrBefore(futureLimit)) { + if (nextEndDate.isAfter(now)) { + if (!template.excluded_dates || !template.excluded_dates.includes(nextEndDate.format('YYYY-MM-DD'))) { + endDatesToCreate.push(nextEndDate.format('YYYY-MM-DD')); + } + } + nextEndDate = TimezoneUtils.calculateNextEndDateWithTimezone(template, nextEndDate, timezone); + } + + if (endDatesToCreate.length > 0) { + // Add task creation job + await taskCreationQueue.add('create-tasks', { + template, + endDates: endDatesToCreate, + timezone + }, { + priority: 10, // Higher priority for task creation + }); + } + + // Update schedule + await RetryUtils.withDatabaseRetry(async () => { + const updateQuery = ` + UPDATE task_recurring_schedules + SET last_checked_at = $1 + WHERE id = $2; + `; + return await db.query(updateQuery, [now.toDate(), scheduleId]); + }, `update_schedule_for_template_${templateId}`); + + } catch (error) { + log_error('Error processing recurring task template:', error); + throw error; + } +}); + +taskCreationQueue.process('create-tasks', async (job) => { + const { template, endDates, timezone }: TaskCreationJobData = job.data; + + try { + // Create tasks using the bulk function from the cron job + const tasksData = endDates.map(endDate => ({ + name: template.name, + priority_id: template.priority_id, + project_id: template.project_id, + reporter_id: template.reporter_id, + status_id: template.status_id || null, + end_date: endDate, + schedule_id: template.schedule_id + })); + + const createTasksResult = await RetryUtils.withDatabaseRetry(async () => { + const createTasksQuery = `SELECT * FROM create_bulk_recurring_tasks($1::JSONB);`; + return await db.query(createTasksQuery, [JSON.stringify(tasksData)]); + }, `create_bulk_tasks_queue_${template.name}`); + + const createdTasks = createTasksResult.rows.filter(row => row.created); + const failedTasks = createTasksResult.rows.filter(row => !row.created); + + // Handle assignments and labels (similar to cron job implementation) + if (createdTasks.length > 0 && (template.assignees?.length > 0 || template.labels?.length > 0)) { + // ... (assignment logic from cron job) + } + + // Send notifications + if (createdTasks.length > 0) { + const taskData = createdTasks.map(task => ({ id: task.task_id, name: task.task_name })); + const assigneeIds = template.assignees?.map(a => a.team_member_id) || []; + + await RecurringTasksNotifications.notifyRecurringTasksCreated( + template.name, + template.project_id, + taskData, + assigneeIds, + template.reporter_id + ); + } + + // Log results + await RecurringTasksAuditLogger.logTemplateProcessing( + template.task_id, + template.name, + template.schedule_id, + createdTasks.length, + failedTasks.length, + { + timezone, + endDates, + processedBy: 'job_queue' + } + ); + + return { + created: createdTasks.length, + failed: failedTasks.length + }; + + } catch (error) { + log_error('Error creating tasks in queue:', error); + throw error; + } +}); + +// Helper function (copied from cron job) +function getFutureLimit(scheduleType: string, interval?: number): moment.Duration { + const FUTURE_LIMITS = { + daily: moment.duration(3, "days"), + weekly: moment.duration(1, "week"), + monthly: moment.duration(1, "month"), + every_x_days: (interval: number) => moment.duration(interval, "days"), + every_x_weeks: (interval: number) => moment.duration(interval, "weeks"), + every_x_months: (interval: number) => moment.duration(interval, "months") + }; + + switch (scheduleType) { + case "daily": + return FUTURE_LIMITS.daily; + case "weekly": + return FUTURE_LIMITS.weekly; + case "monthly": + return FUTURE_LIMITS.monthly; + case "every_x_days": + return FUTURE_LIMITS.every_x_days(interval || 1); + case "every_x_weeks": + return FUTURE_LIMITS.every_x_weeks(interval || 1); + case "every_x_months": + return FUTURE_LIMITS.every_x_months(interval || 1); + default: + return moment.duration(3, "days"); + } +} + +// Job schedulers +export class RecurringTasksJobScheduler { + /** + * Schedule recurring task processing for all templates + */ + static async scheduleRecurringTasks(): Promise { + try { + // Get all active templates + const templatesQuery = ` + SELECT t.id as template_id, s.id as schedule_id, + COALESCE(s.timezone, u.timezone, 'UTC') as timezone + FROM task_recurring_templates t + JOIN task_recurring_schedules s ON t.schedule_id = s.id + LEFT JOIN tasks orig_task ON t.task_id = orig_task.id + LEFT JOIN users u ON orig_task.reporter_id = u.id + WHERE s.end_date IS NULL OR s.end_date >= CURRENT_DATE + `; + + const result = await db.query(templatesQuery); + + // Schedule a job for each template + for (const template of result.rows) { + await recurringTasksQueue.add('process-template', { + templateId: template.template_id, + scheduleId: template.schedule_id, + timezone: template.timezone + }, { + delay: Math.random() * 60000, // Random delay up to 1 minute to spread load + }); + } + + } catch (error) { + log_error('Error scheduling recurring tasks:', error); + } + } + + /** + * Start the job queue system + */ + static async start(): Promise { + console.log('Starting recurring tasks job queue...'); + + // Schedule recurring task processing every hour + await recurringTasksQueue.add('schedule-all', {}, { + repeat: { cron: '0 * * * *' }, // Every hour + removeOnComplete: 1, + removeOnFail: 1, + }); + + // Process the schedule-all job + recurringTasksQueue.process('schedule-all', async () => { + await this.scheduleRecurringTasks(); + }); + + console.log('Recurring tasks job queue started'); + } + + /** + * Get queue statistics + */ + static async getStats(): Promise { + const [recurringStats, creationStats] = await Promise.all([ + recurringTasksQueue.getJobCounts(), + taskCreationQueue.getJobCounts() + ]); + + return { + recurringTasks: recurringStats, + taskCreation: creationStats + }; + } +} \ No newline at end of file diff --git a/worklenz-backend/src/services/recurring-tasks-service.ts b/worklenz-backend/src/services/recurring-tasks-service.ts new file mode 100644 index 00000000..2005e919 --- /dev/null +++ b/worklenz-backend/src/services/recurring-tasks-service.ts @@ -0,0 +1,162 @@ +import { recurringTasksConfig } from '../config/recurring-tasks-config'; +import { startRecurringTasksJob } from '../cron_jobs/recurring-tasks'; +import { RecurringTasksJobScheduler } from '../jobs/recurring-tasks-queue'; +import { log_error } from '../shared/utils'; + +export class RecurringTasksService { + private static isStarted = false; + + /** + * Start the recurring tasks service based on configuration + */ + static async start(): Promise { + if (this.isStarted) { + console.log('Recurring tasks service already started'); + return; + } + + if (!recurringTasksConfig.enabled) { + console.log('Recurring tasks service disabled'); + return; + } + + try { + console.log(`Starting recurring tasks service in ${recurringTasksConfig.mode} mode...`); + + switch (recurringTasksConfig.mode) { + case 'cron': + startRecurringTasksJob(); + break; + + case 'queue': + await RecurringTasksJobScheduler.start(); + break; + + default: + throw new Error(`Unknown recurring tasks mode: ${recurringTasksConfig.mode}`); + } + + this.isStarted = true; + console.log(`Recurring tasks service started successfully in ${recurringTasksConfig.mode} mode`); + + } catch (error) { + log_error('Failed to start recurring tasks service:', error); + throw error; + } + } + + /** + * Stop the recurring tasks service + */ + static async stop(): Promise { + if (!this.isStarted) { + return; + } + + try { + console.log('Stopping recurring tasks service...'); + + if (recurringTasksConfig.mode === 'queue') { + // Close queue connections + const { recurringTasksQueue, taskCreationQueue } = await import('../jobs/recurring-tasks-queue'); + await recurringTasksQueue.close(); + await taskCreationQueue.close(); + } + + this.isStarted = false; + console.log('Recurring tasks service stopped'); + + } catch (error) { + log_error('Error stopping recurring tasks service:', error); + } + } + + /** + * Get service status and statistics + */ + static async getStatus(): Promise { + const status = { + enabled: recurringTasksConfig.enabled, + mode: recurringTasksConfig.mode, + started: this.isStarted, + config: recurringTasksConfig + }; + + if (this.isStarted && recurringTasksConfig.mode === 'queue') { + try { + const stats = await RecurringTasksJobScheduler.getStats(); + return { ...status, queueStats: stats }; + } catch (error) { + return { ...status, queueStatsError: error.message }; + } + } + + return status; + } + + /** + * Manually trigger recurring tasks processing + */ + static async triggerManual(): Promise { + if (!this.isStarted) { + throw new Error('Recurring tasks service is not started'); + } + + try { + if (recurringTasksConfig.mode === 'queue') { + await RecurringTasksJobScheduler.scheduleRecurringTasks(); + } else { + // For cron mode, we can't manually trigger easily + // Could implement a manual trigger function in the cron job file + throw new Error('Manual trigger not supported in cron mode'); + } + } catch (error) { + log_error('Error manually triggering recurring tasks:', error); + throw error; + } + } + + /** + * Health check for the service + */ + static async healthCheck(): Promise<{ healthy: boolean; message: string; details?: any }> { + try { + if (!recurringTasksConfig.enabled) { + return { + healthy: true, + message: 'Recurring tasks service is disabled' + }; + } + + if (!this.isStarted) { + return { + healthy: false, + message: 'Recurring tasks service is not started' + }; + } + + if (recurringTasksConfig.mode === 'queue') { + const stats = await RecurringTasksJobScheduler.getStats(); + const hasFailures = stats.recurringTasks.failed > 0 || stats.taskCreation.failed > 0; + + return { + healthy: !hasFailures, + message: hasFailures ? 'Some jobs are failing' : 'All systems operational', + details: stats + }; + } + + return { + healthy: true, + message: `Running in ${recurringTasksConfig.mode} mode` + }; + + } catch (error) { + return { + healthy: false, + message: 'Health check failed', + details: { error: error.message } + }; + } + } +} \ No newline at end of file diff --git a/worklenz-backend/src/utils/recurring-tasks-audit-logger.ts b/worklenz-backend/src/utils/recurring-tasks-audit-logger.ts new file mode 100644 index 00000000..f85af66c --- /dev/null +++ b/worklenz-backend/src/utils/recurring-tasks-audit-logger.ts @@ -0,0 +1,189 @@ +import db from "../config/db"; +import { log_error } from "../shared/utils"; + +export enum RecurringTaskOperationType { + CRON_JOB_RUN = "cron_job_run", + CRON_JOB_ERROR = "cron_job_error", + TEMPLATE_CREATED = "template_created", + TEMPLATE_UPDATED = "template_updated", + TEMPLATE_DELETED = "template_deleted", + SCHEDULE_CREATED = "schedule_created", + SCHEDULE_UPDATED = "schedule_updated", + SCHEDULE_DELETED = "schedule_deleted", + TASKS_CREATED = "tasks_created", + TASKS_CREATION_FAILED = "tasks_creation_failed", + MANUAL_TRIGGER = "manual_trigger", + BULK_OPERATION = "bulk_operation" +} + +export interface AuditLogEntry { + operationType: RecurringTaskOperationType; + templateId?: string; + scheduleId?: string; + taskId?: string; + templateName?: string; + success?: boolean; + errorMessage?: string; + details?: any; + createdTasksCount?: number; + failedTasksCount?: number; + executionTimeMs?: number; + createdBy?: string; +} + +export class RecurringTasksAuditLogger { + private static startTime: number; + + /** + * Start timing an operation + */ + static startTimer(): void { + this.startTime = Date.now(); + } + + /** + * Get elapsed time since timer started + */ + static getElapsedTime(): number { + return this.startTime ? Date.now() - this.startTime : 0; + } + + /** + * Log a recurring task operation + */ + static async log(entry: AuditLogEntry): Promise { + try { + const query = `SELECT log_recurring_task_operation($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);`; + + await db.query(query, [ + entry.operationType, + entry.templateId || null, + entry.scheduleId || null, + entry.taskId || null, + entry.templateName || null, + entry.success !== false, // Default to true + entry.errorMessage || null, + entry.details ? JSON.stringify(entry.details) : null, + entry.createdTasksCount || 0, + entry.failedTasksCount || 0, + entry.executionTimeMs || this.getElapsedTime(), + entry.createdBy || null + ]); + } catch (error) { + // Don't let audit logging failures break the main flow + log_error("Failed to log recurring task audit entry:", error); + } + } + + /** + * Log cron job execution + */ + static async logCronJobRun( + totalTemplates: number, + createdTasksCount: number, + errors: any[] = [] + ): Promise { + await this.log({ + operationType: RecurringTaskOperationType.CRON_JOB_RUN, + success: errors.length === 0, + errorMessage: errors.length > 0 ? `${errors.length} errors occurred` : undefined, + details: { + totalTemplates, + errors: errors.map(e => e.message || e.toString()) + }, + createdTasksCount, + executionTimeMs: this.getElapsedTime() + }); + } + + /** + * Log template processing + */ + static async logTemplateProcessing( + templateId: string, + templateName: string, + scheduleId: string, + createdCount: number, + failedCount: number, + details?: any + ): Promise { + await this.log({ + operationType: RecurringTaskOperationType.TASKS_CREATED, + templateId, + scheduleId, + templateName, + success: failedCount === 0, + createdTasksCount: createdCount, + failedTasksCount: failedCount, + details + }); + } + + /** + * Log schedule changes + */ + static async logScheduleChange( + operationType: RecurringTaskOperationType, + scheduleId: string, + templateId?: string, + userId?: string, + details?: any + ): Promise { + await this.log({ + operationType, + scheduleId, + templateId, + createdBy: userId, + details + }); + } + + /** + * Get audit log summary + */ + static async getAuditSummary(days: number = 7): Promise { + try { + const query = ` + SELECT + operation_type, + COUNT(*) as count, + SUM(CASE WHEN success THEN 1 ELSE 0 END) as success_count, + SUM(CASE WHEN NOT success THEN 1 ELSE 0 END) as failure_count, + SUM(created_tasks_count) as total_tasks_created, + SUM(failed_tasks_count) as total_tasks_failed, + AVG(execution_time_ms) as avg_execution_time_ms + FROM recurring_tasks_audit_log + WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '${days} days' + GROUP BY operation_type + ORDER BY count DESC; + `; + + const result = await db.query(query); + return result.rows; + } catch (error) { + log_error("Failed to get audit summary:", error); + return []; + } + } + + /** + * Get recent errors + */ + static async getRecentErrors(limit: number = 10): Promise { + try { + const query = ` + SELECT * + FROM v_recent_recurring_tasks_audit + WHERE NOT success + ORDER BY created_at DESC + LIMIT $1; + `; + + const result = await db.query(query, [limit]); + return result.rows; + } catch (error) { + log_error("Failed to get recent errors:", error); + return []; + } + } +} \ No newline at end of file diff --git a/worklenz-backend/src/utils/recurring-tasks-notifications.ts b/worklenz-backend/src/utils/recurring-tasks-notifications.ts new file mode 100644 index 00000000..c6952168 --- /dev/null +++ b/worklenz-backend/src/utils/recurring-tasks-notifications.ts @@ -0,0 +1,260 @@ +import db from "../config/db"; +import { log_error } from "../shared/utils"; + +export interface NotificationData { + userId: string; + projectId: string; + taskId: string; + taskName: string; + templateName: string; + scheduleId: string; + createdBy?: string; +} + +export class RecurringTasksNotifications { + /** + * Send notification to user about a new recurring task + */ + static async notifyTaskCreated(data: NotificationData): Promise { + try { + // Create notification in the database + const notificationQuery = ` + INSERT INTO notifications ( + user_id, + message, + data, + created_at + ) VALUES ($1, $2, $3, NOW()) + `; + + const message = `New recurring task "${data.taskName}" has been created from template "${data.templateName}"`; + const notificationData = { + type: 'recurring_task_created', + task_id: data.taskId, + project_id: data.projectId, + schedule_id: data.scheduleId, + task_name: data.taskName, + template_name: data.templateName + }; + + await db.query(notificationQuery, [ + data.userId, + message, + JSON.stringify(notificationData) + ]); + + } catch (error) { + log_error("Failed to create notification:", error); + } + } + + /** + * Send notifications to all assignees of created tasks + */ + static async notifyAssignees( + taskIds: string[], + templateName: string, + projectId: string + ): Promise { + if (taskIds.length === 0) return; + + try { + // Get all assignees for the created tasks + const assigneesQuery = ` + SELECT DISTINCT ta.team_member_id, t.id as task_id, t.name as task_name + FROM tasks_assignees ta + JOIN tasks t ON ta.task_id = t.id + WHERE t.id = ANY($1) + `; + + const result = await db.query(assigneesQuery, [taskIds]); + + // Send notification to each assignee + for (const assignee of result.rows) { + await this.notifyTaskCreated({ + userId: assignee.team_member_id, + projectId, + taskId: assignee.task_id, + taskName: assignee.task_name, + templateName, + scheduleId: '' // Not needed for assignee notifications + }); + } + + } catch (error) { + log_error("Failed to notify assignees:", error); + } + } + + /** + * Send email notifications (if email system is configured) + */ + static async sendEmailNotifications( + userIds: string[], + subject: string, + message: string + ): Promise { + try { + // Get user email addresses + const usersQuery = ` + SELECT id, email, name, email_notifications + FROM users + WHERE id = ANY($1) AND email_notifications = true AND email IS NOT NULL + `; + + const result = await db.query(usersQuery, [userIds]); + + // TODO: Integrate with your email service (SendGrid, AWS SES, etc.) + // For now, just log the email notifications that would be sent + for (const user of result.rows) { + console.log(`Email notification would be sent to ${user.email}: ${subject}`); + + // Example: await emailService.send({ + // to: user.email, + // subject, + // html: message + // }); + } + + } catch (error) { + log_error("Failed to send email notifications:", error); + } + } + + /** + * Send push notifications (if push notification system is configured) + */ + static async sendPushNotifications( + userIds: string[], + title: string, + body: string, + data?: any + ): Promise { + try { + // Get user push tokens + const tokensQuery = ` + SELECT user_id, push_token + FROM user_push_tokens + WHERE user_id = ANY($1) AND push_token IS NOT NULL + `; + + const result = await db.query(tokensQuery, [userIds]); + + // TODO: Integrate with your push notification service (FCM, APNs, etc.) + // For now, just log the push notifications that would be sent + for (const token of result.rows) { + console.log(`Push notification would be sent to ${token.push_token}: ${title}`); + + // Example: await pushService.send({ + // token: token.push_token, + // title, + // body, + // data + // }); + } + + } catch (error) { + log_error("Failed to send push notifications:", error); + } + } + + /** + * Get notification preferences for users + */ + static async getNotificationPreferences(userIds: string[]): Promise { + try { + const query = ` + SELECT + id, + email_notifications, + push_notifications, + in_app_notifications + FROM users + WHERE id = ANY($1) + `; + + const result = await db.query(query, [userIds]); + return result.rows; + + } catch (error) { + log_error("Failed to get notification preferences:", error); + return []; + } + } + + /** + * Comprehensive notification for recurring task creation + */ + static async notifyRecurringTasksCreated( + templateName: string, + projectId: string, + createdTasks: Array<{ id: string; name: string }>, + assignees: string[] = [], + reporterId?: string + ): Promise { + try { + const taskIds = createdTasks.map(t => t.id); + const allUserIds = [...new Set([...assignees, reporterId].filter(Boolean))]; + + if (allUserIds.length === 0) return; + + // Get notification preferences + const preferences = await this.getNotificationPreferences(allUserIds); + + // Send in-app notifications + const inAppUsers = preferences.filter(p => p.in_app_notifications !== false); + for (const user of inAppUsers) { + for (const task of createdTasks) { + await this.notifyTaskCreated({ + userId: user.id, + projectId, + taskId: task.id, + taskName: task.name, + templateName, + scheduleId: '', + createdBy: 'system' + }); + } + } + + // Send email notifications + const emailUsers = preferences + .filter(p => p.email_notifications === true) + .map(p => p.id); + + if (emailUsers.length > 0) { + const subject = `New Recurring Tasks Created: ${templateName}`; + const message = ` +

Recurring Tasks Created

+

${createdTasks.length} new tasks have been created from template "${templateName}":

+
    + ${createdTasks.map(t => `
  • ${t.name}
  • `).join('')} +
+ `; + + await this.sendEmailNotifications(emailUsers, subject, message); + } + + // Send push notifications + const pushUsers = preferences + .filter(p => p.push_notifications !== false) + .map(p => p.id); + + if (pushUsers.length > 0) { + await this.sendPushNotifications( + pushUsers, + 'New Recurring Tasks', + `${createdTasks.length} tasks created from ${templateName}`, + { + type: 'recurring_tasks_created', + project_id: projectId, + task_count: createdTasks.length + } + ); + } + + } catch (error) { + log_error("Failed to send comprehensive notifications:", error); + } + } +} \ No newline at end of file diff --git a/worklenz-backend/src/utils/recurring-tasks-permissions.ts b/worklenz-backend/src/utils/recurring-tasks-permissions.ts new file mode 100644 index 00000000..0f52c262 --- /dev/null +++ b/worklenz-backend/src/utils/recurring-tasks-permissions.ts @@ -0,0 +1,187 @@ +import db from "../config/db"; +import { log_error } from "../shared/utils"; + +export interface PermissionCheckResult { + hasPermission: boolean; + reason?: string; + projectRole?: string; +} + +export class RecurringTasksPermissions { + /** + * Check if a user has permission to create tasks in a project + */ + static async canCreateTasksInProject( + userId: string, + projectId: string + ): Promise { + try { + // Check if user is a member of the project + const memberQuery = ` + SELECT pm.role_id, pr.name as role_name, pr.permissions + FROM project_members pm + JOIN project_member_roles pr ON pm.role_id = pr.id + WHERE pm.user_id = $1 AND pm.project_id = $2 + LIMIT 1; + `; + + const result = await db.query(memberQuery, [userId, projectId]); + + if (result.rows.length === 0) { + return { + hasPermission: false, + reason: "User is not a member of the project" + }; + } + + const member = result.rows[0]; + + // Check if role has task creation permission + if (member.permissions && member.permissions.create_tasks === false) { + return { + hasPermission: false, + reason: "User role does not have permission to create tasks", + projectRole: member.role_name + }; + } + + return { + hasPermission: true, + projectRole: member.role_name + }; + } catch (error) { + log_error("Error checking project permissions:", error); + return { + hasPermission: false, + reason: "Error checking permissions" + }; + } + } + + /** + * Check if a template has valid permissions + */ + static async validateTemplatePermissions(templateId: string): Promise { + try { + const query = ` + SELECT + t.reporter_id, + t.project_id, + p.is_active as project_active, + p.archived as project_archived, + u.is_active as user_active + FROM task_recurring_templates trt + JOIN tasks t ON trt.task_id = t.id + JOIN projects p ON t.project_id = p.id + JOIN users u ON t.reporter_id = u.id + WHERE trt.id = $1 + LIMIT 1; + `; + + const result = await db.query(query, [templateId]); + + if (result.rows.length === 0) { + return { + hasPermission: false, + reason: "Template not found" + }; + } + + const template = result.rows[0]; + + // Check if project is active + if (!template.project_active || template.project_archived) { + return { + hasPermission: false, + reason: "Project is not active or archived" + }; + } + + // Check if reporter is still active + if (!template.user_active) { + return { + hasPermission: false, + reason: "Original task reporter is no longer active" + }; + } + + // Check if reporter still has permissions in the project + const permissionCheck = await this.canCreateTasksInProject( + template.reporter_id, + template.project_id + ); + + return permissionCheck; + } catch (error) { + log_error("Error validating template permissions:", error); + return { + hasPermission: false, + reason: "Error validating template permissions" + }; + } + } + + /** + * Get all templates with permission issues + */ + static async getTemplatesWithPermissionIssues(): Promise { + try { + const query = ` + SELECT + trt.id as template_id, + trt.name as template_name, + t.reporter_id, + u.name as reporter_name, + t.project_id, + p.name as project_name, + CASE + WHEN NOT p.is_active THEN 'Project inactive' + WHEN p.archived THEN 'Project archived' + WHEN NOT u.is_active THEN 'User inactive' + WHEN NOT EXISTS ( + SELECT 1 FROM project_members + WHERE user_id = t.reporter_id AND project_id = t.project_id + ) THEN 'User not in project' + ELSE NULL + END as issue + FROM task_recurring_templates trt + JOIN tasks t ON trt.task_id = t.id + JOIN projects p ON t.project_id = p.id + JOIN users u ON t.reporter_id = u.id + WHERE + NOT p.is_active + OR p.archived + OR NOT u.is_active + OR NOT EXISTS ( + SELECT 1 FROM project_members + WHERE user_id = t.reporter_id AND project_id = t.project_id + ); + `; + + const result = await db.query(query); + return result.rows; + } catch (error) { + log_error("Error getting templates with permission issues:", error); + return []; + } + } + + /** + * Validate all assignees have permissions + */ + static async validateAssigneePermissions( + assignees: Array<{ team_member_id: string }>, + projectId: string + ): Promise { + const invalidAssignees: string[] = []; + + for (const assignee of assignees) { + const check = await this.canCreateTasksInProject(assignee.team_member_id, projectId); + if (!check.hasPermission) { + invalidAssignees.push(assignee.team_member_id); + } + } + + return invalidAssignees; + } +} \ No newline at end of file diff --git a/worklenz-backend/src/utils/retry-utils.ts b/worklenz-backend/src/utils/retry-utils.ts new file mode 100644 index 00000000..6036f44a --- /dev/null +++ b/worklenz-backend/src/utils/retry-utils.ts @@ -0,0 +1,134 @@ +import { log_error } from "../shared/utils"; + +export interface RetryOptions { + maxRetries: number; + delayMs: number; + backoffFactor?: number; + onRetry?: (error: any, attempt: number) => void; +} + +export class RetryUtils { + /** + * Execute a function with retry logic + */ + static async withRetry( + fn: () => Promise, + options: RetryOptions + ): Promise { + const { maxRetries, delayMs, backoffFactor = 1.5, onRetry } = options; + let lastError: any; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + + if (attempt === maxRetries) { + throw error; + } + + const delay = delayMs * Math.pow(backoffFactor, attempt - 1); + + if (onRetry) { + onRetry(error, attempt); + } + + log_error(`Attempt ${attempt} failed. Retrying in ${delay}ms...`, error); + + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + throw lastError; + } + + /** + * Execute database operations with retry logic + */ + static async withDatabaseRetry( + operation: () => Promise, + operationName: string + ): Promise { + return this.withRetry(operation, { + maxRetries: 3, + delayMs: 1000, + backoffFactor: 2, + onRetry: (error, attempt) => { + log_error(`Database operation '${operationName}' failed on attempt ${attempt}:`, error); + } + }); + } + + /** + * Check if an error is retryable + */ + static isRetryableError(error: any): boolean { + // PostgreSQL error codes that are retryable + const retryableErrorCodes = [ + '40001', // serialization_failure + '40P01', // deadlock_detected + '55P03', // lock_not_available + '57P01', // admin_shutdown + '57P02', // crash_shutdown + '57P03', // cannot_connect_now + '58000', // system_error + '58030', // io_error + '53000', // insufficient_resources + '53100', // disk_full + '53200', // out_of_memory + '53300', // too_many_connections + '53400', // configuration_limit_exceeded + ]; + + if (error.code && retryableErrorCodes.includes(error.code)) { + return true; + } + + // Network-related errors + if (error.message && ( + error.message.includes('ECONNRESET') || + error.message.includes('ETIMEDOUT') || + error.message.includes('ECONNREFUSED') + )) { + return true; + } + + return false; + } + + /** + * Execute with conditional retry based on error type + */ + static async withConditionalRetry( + fn: () => Promise, + options: RetryOptions + ): Promise { + const { maxRetries, delayMs, backoffFactor = 1.5, onRetry } = options; + let lastError: any; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + + if (!this.isRetryableError(error) || attempt === maxRetries) { + throw error; + } + + const delay = delayMs * Math.pow(backoffFactor, attempt - 1); + + if (onRetry) { + onRetry(error, attempt); + } + + log_error(`Retryable error on attempt ${attempt}. Retrying in ${delay}ms...`, error); + + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + throw lastError; + } +} \ No newline at end of file diff --git a/worklenz-backend/src/utils/timezone-utils.ts b/worklenz-backend/src/utils/timezone-utils.ts new file mode 100644 index 00000000..03bd0203 --- /dev/null +++ b/worklenz-backend/src/utils/timezone-utils.ts @@ -0,0 +1,156 @@ +import moment from "moment-timezone"; +import { IRecurringSchedule } from "../interfaces/recurring-tasks"; + +export class TimezoneUtils { + /** + * Convert a date from one timezone to another + */ + static convertTimezone(date: moment.Moment | Date | string, fromTz: string, toTz: string): moment.Moment { + return moment.tz(date, fromTz).tz(toTz); + } + + /** + * Get the current time in a specific timezone + */ + static nowInTimezone(timezone: string): moment.Moment { + return moment.tz(timezone); + } + + /** + * Check if a recurring task should run based on timezone + */ + static shouldRunInTimezone(schedule: IRecurringSchedule, timezone: string): boolean { + const now = this.nowInTimezone(timezone); + const scheduleTime = moment.tz(schedule.created_at, timezone); + + // Check if it's the right time of day (within a 1-hour window) + const hourDiff = Math.abs(now.hour() - scheduleTime.hour()); + return hourDiff < 1; + } + + /** + * Calculate next end date considering timezone + */ + static calculateNextEndDateWithTimezone( + schedule: IRecurringSchedule, + lastDate: moment.Moment | Date | string, + timezone: string + ): moment.Moment { + const lastMoment = moment.tz(lastDate, timezone); + + switch (schedule.schedule_type) { + case "daily": + return lastMoment.clone().add(1, "day"); + + case "weekly": + if (schedule.days_of_week && schedule.days_of_week.length > 0) { + // Find next occurrence based on selected days + let nextDate = lastMoment.clone(); + let daysChecked = 0; + + do { + nextDate.add(1, "day"); + daysChecked++; + if (schedule.days_of_week.includes(nextDate.day())) { + return nextDate; + } + } while (daysChecked < 7); + + // If no valid day found, return next week's first selected day + const sortedDays = [...schedule.days_of_week].sort((a, b) => a - b); + nextDate = lastMoment.clone().add(1, "week").day(sortedDays[0]); + return nextDate; + } + return lastMoment.clone().add(1, "week"); + + case "monthly": + if (schedule.date_of_month) { + // Specific date of month + let nextDate = lastMoment.clone().add(1, "month").date(schedule.date_of_month); + + // Handle months with fewer days + if (nextDate.date() !== schedule.date_of_month) { + nextDate = nextDate.endOf("month"); + } + + return nextDate; + } else if (schedule.week_of_month && schedule.day_of_month !== undefined) { + // Nth occurrence of a day in month + const nextMonth = lastMoment.clone().add(1, "month").startOf("month"); + const targetDay = schedule.day_of_month; + const targetWeek = schedule.week_of_month; + + // Find first occurrence of the target day + let firstOccurrence = nextMonth.clone(); + while (firstOccurrence.day() !== targetDay) { + firstOccurrence.add(1, "day"); + } + + // Calculate nth occurrence + if (targetWeek === 5) { + // Last occurrence + let lastOccurrence = firstOccurrence.clone(); + let temp = firstOccurrence.clone().add(7, "days"); + + while (temp.month() === nextMonth.month()) { + lastOccurrence = temp.clone(); + temp.add(7, "days"); + } + + return lastOccurrence; + } else { + // Specific week number + return firstOccurrence.add((targetWeek - 1) * 7, "days"); + } + } + return lastMoment.clone().add(1, "month"); + + case "every_x_days": + return lastMoment.clone().add(schedule.interval_days || 1, "days"); + + case "every_x_weeks": + return lastMoment.clone().add(schedule.interval_weeks || 1, "weeks"); + + case "every_x_months": + return lastMoment.clone().add(schedule.interval_months || 1, "months"); + + default: + return lastMoment.clone().add(1, "day"); + } + } + + /** + * Get all timezones that should be processed in the current hour + */ + static getActiveTimezones(): string[] { + const activeTimezones: string[] = []; + const allTimezones = moment.tz.names(); + + for (const tz of allTimezones) { + const tzTime = moment.tz(tz); + // Check if it's 11:00 AM in this timezone (matching the cron schedule) + if (tzTime.hour() === 11) { + activeTimezones.push(tz); + } + } + + return activeTimezones; + } + + /** + * Validate timezone string + */ + static isValidTimezone(timezone: string): boolean { + return moment.tz.zone(timezone) !== null; + } + + /** + * Get user's timezone or default to UTC + */ + static getUserTimezone(userTimezone?: string): string { + if (userTimezone && this.isValidTimezone(userTimezone)) { + return userTimezone; + } + return "UTC"; + } +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx index 1d12c480..04fd7789 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx @@ -11,7 +11,11 @@ import { Skeleton, Row, Col, + DatePicker, + Tag, + Space, } from 'antd'; +import { CloseOutlined } from '@ant-design/icons'; import { SettingOutlined } from '@ant-design/icons'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; @@ -29,6 +33,7 @@ import { updateTaskCounts } from '@/features/task-management/task-management.sli import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service'; import logger from '@/utils/errorLogger'; import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice'; +import moment from 'moment-timezone'; const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1); @@ -66,6 +71,21 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value })); + // Get common timezones + const timezoneOptions = [ + { label: 'UTC', value: 'UTC' }, + { label: 'US Eastern', value: 'America/New_York' }, + { label: 'US Central', value: 'America/Chicago' }, + { label: 'US Mountain', value: 'America/Denver' }, + { label: 'US Pacific', value: 'America/Los_Angeles' }, + { label: 'Europe/London', value: 'Europe/London' }, + { label: 'Europe/Paris', value: 'Europe/Paris' }, + { label: 'Asia/Tokyo', value: 'Asia/Tokyo' }, + { label: 'Asia/Shanghai', value: 'Asia/Shanghai' }, + { label: 'Asia/Kolkata', value: 'Asia/Kolkata' }, + { label: 'Australia/Sydney', value: 'Australia/Sydney' }, + ]; + const [recurring, setRecurring] = useState(false); const [showConfig, setShowConfig] = useState(false); const [repeatOption, setRepeatOption] = useState({}); @@ -80,6 +100,10 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const [loadingData, setLoadingData] = useState(false); const [updatingData, setUpdatingData] = useState(false); const [scheduleData, setScheduleData] = useState({}); + const [timezone, setTimezone] = useState('UTC'); + const [endDate, setEndDate] = useState(null); + const [excludedDates, setExcludedDates] = useState([]); + const [newExcludeDate, setNewExcludeDate] = useState(null); const handleChange = (checked: boolean) => { if (!task.id) return; @@ -140,6 +164,9 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const body: ITaskRecurringSchedule = { id: task.id, schedule_type: repeatOption.value, + timezone: timezone, + end_date: endDate ? endDate.format('YYYY-MM-DD') : null, + excluded_dates: excludedDates, }; switch (repeatOption.value) { @@ -213,13 +240,16 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const selected = repeatOptions.find(e => e.value == res.body.schedule_type); if (selected) { setRepeatOption(selected); - setSelectedMonthlyDate(scheduleData.date_of_month || 1); - setSelectedMonthlyDay(scheduleData.day_of_month || 0); - setSelectedMonthlyWeek(scheduleData.week_of_month || 0); - setIntervalDays(scheduleData.interval_days || 1); - setIntervalWeeks(scheduleData.interval_weeks || 1); - setIntervalMonths(scheduleData.interval_months || 1); - setMonthlyOption(selectedMonthlyDate ? 'date' : 'day'); + setSelectedMonthlyDate(res.body.date_of_month || 1); + setSelectedMonthlyDay(res.body.day_of_month || 0); + setSelectedMonthlyWeek(res.body.week_of_month || 0); + setIntervalDays(res.body.interval_days || 1); + setIntervalWeeks(res.body.interval_weeks || 1); + setIntervalMonths(res.body.interval_months || 1); + setTimezone(res.body.timezone || 'UTC'); + setEndDate(res.body.end_date ? moment(res.body.end_date) : null); + setExcludedDates(res.body.excluded_dates || []); + setMonthlyOption(res.body.date_of_month ? 'date' : 'day'); updateDaysOfWeek(); } } @@ -365,6 +395,69 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { /> )} + + {/* Timezone Selection */} + +