Compare commits
2 Commits
chore/adde
...
imp/recurr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aeaaa1fee | ||
|
|
474f1afe66 |
111
docs/job-queue-dependencies.md
Normal file
111
docs/job-queue-dependencies.md
Normal file
@@ -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
|
||||
```
|
||||
382
package-lock.json
generated
382
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
package.json
Normal file
9
package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"bull": "^4.16.5",
|
||||
"ioredis": "^5.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bull": "^3.15.9"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"config-file": "migrate.json"
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = pgm => {
|
||||
// Composite index for main task filtering
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived_parent
|
||||
ON tasks(project_id, archived, parent_task_id)
|
||||
WHERE archived = FALSE
|
||||
`);
|
||||
|
||||
// Index for status joins
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project
|
||||
ON tasks(status_id, project_id)
|
||||
WHERE archived = FALSE
|
||||
`);
|
||||
|
||||
// Index for assignees lookup
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member
|
||||
ON tasks_assignees(task_id, team_member_id)
|
||||
`);
|
||||
|
||||
// Index for phase lookup
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase
|
||||
ON task_phase(task_id, phase_id)
|
||||
`);
|
||||
|
||||
// Index for subtask counting
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_archived
|
||||
ON tasks(parent_task_id, archived)
|
||||
WHERE parent_task_id IS NOT NULL AND archived = FALSE
|
||||
`);
|
||||
|
||||
// Index for labels
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label
|
||||
ON task_labels(task_id, label_id)
|
||||
`);
|
||||
|
||||
// Index for comments count
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task
|
||||
ON task_comments(task_id)
|
||||
`);
|
||||
|
||||
// Index for attachments count
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task
|
||||
ON task_attachments(task_id)
|
||||
`);
|
||||
|
||||
// Index for work log aggregation
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task
|
||||
ON task_work_log(task_id)
|
||||
`);
|
||||
|
||||
// Index for subscribers check
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task
|
||||
ON task_subscribers(task_id)
|
||||
`);
|
||||
|
||||
// Index for dependencies check
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task
|
||||
ON task_dependencies(task_id)
|
||||
`);
|
||||
|
||||
// Additional performance indexes
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_related
|
||||
ON task_dependencies(related_task_id)
|
||||
`);
|
||||
|
||||
// Index for custom column values
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task
|
||||
ON cc_column_values(task_id)
|
||||
`);
|
||||
|
||||
// Index for project members lookup
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_members_team_project
|
||||
ON project_members(team_member_id, project_id)
|
||||
`);
|
||||
|
||||
// Index for sorting
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort
|
||||
ON tasks(project_id, sort_order)
|
||||
WHERE archived = FALSE
|
||||
`);
|
||||
|
||||
// Index for roadmap sorting
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_roadmap_sort
|
||||
ON tasks(project_id, roadmap_sort_order)
|
||||
WHERE archived = FALSE
|
||||
`);
|
||||
|
||||
// Index for user lookup in team members
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_user_team
|
||||
ON team_members(user_id, team_id)
|
||||
WHERE active = TRUE
|
||||
`);
|
||||
|
||||
// Index for task statuses lookup
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_project_category
|
||||
ON task_statuses(project_id, category_id)
|
||||
`);
|
||||
|
||||
// Index for task priorities lookup
|
||||
pgm.sql(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_priority
|
||||
ON tasks(priority_id)
|
||||
WHERE archived = FALSE
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = pgm => {
|
||||
// Drop indexes in reverse order
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_tasks_priority');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_task_statuses_project_category');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_team_members_user_team');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_tasks_project_roadmap_sort');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_tasks_project_sort');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_project_members_team_project');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_cc_column_values_task');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_task_dependencies_related');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_task_dependencies_task');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_task_subscribers_task');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_task_work_log_task');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_task_attachments_task');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_task_comments_task');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_task_labels_task_label');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_tasks_parent_archived');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_task_phase_task_phase');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_tasks_assignees_task_member');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_tasks_status_project');
|
||||
pgm.sql('DROP INDEX IF EXISTS idx_tasks_project_archived_parent');
|
||||
};
|
||||
@@ -1,183 +0,0 @@
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = pgm => {
|
||||
// Replace the optimized sort functions to avoid CTE usage in UPDATE statements
|
||||
pgm.createFunction(
|
||||
'handle_task_list_sort_between_groups_optimized',
|
||||
[
|
||||
{ name: '_from_index', type: 'integer' },
|
||||
{ name: '_to_index', type: 'integer' },
|
||||
{ name: '_task_id', type: 'uuid' },
|
||||
{ name: '_project_id', type: 'uuid' },
|
||||
{ name: '_batch_size', type: 'integer', default: 100 }
|
||||
],
|
||||
{ returns: 'void', language: 'plpgsql', replace: true },
|
||||
`
|
||||
DECLARE
|
||||
_offset INT := 0;
|
||||
_affected_rows INT;
|
||||
BEGIN
|
||||
-- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE
|
||||
IF (_to_index = -1)
|
||||
THEN
|
||||
_to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0);
|
||||
END IF;
|
||||
|
||||
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets
|
||||
IF _to_index > _from_index
|
||||
THEN
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order - 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _from_index
|
||||
AND sort_order < _to_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index - 1 WHERE id = _task_id AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF _to_index < _from_index
|
||||
THEN
|
||||
_offset := 0;
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order + 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _to_index
|
||||
AND sort_order < _from_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index + 1 WHERE id = _task_id AND project_id = _project_id;
|
||||
END IF;
|
||||
END
|
||||
`
|
||||
);
|
||||
|
||||
// Replace the second optimized sort function
|
||||
pgm.createFunction(
|
||||
'handle_task_list_sort_inside_group_optimized',
|
||||
[
|
||||
{ name: '_from_index', type: 'integer' },
|
||||
{ name: '_to_index', type: 'integer' },
|
||||
{ name: '_task_id', type: 'uuid' },
|
||||
{ name: '_project_id', type: 'uuid' },
|
||||
{ name: '_batch_size', type: 'integer', default: 100 }
|
||||
],
|
||||
{ returns: 'void', language: 'plpgsql', replace: true },
|
||||
`
|
||||
DECLARE
|
||||
_offset INT := 0;
|
||||
_affected_rows INT;
|
||||
BEGIN
|
||||
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE
|
||||
IF _to_index > _from_index
|
||||
THEN
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order - 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _from_index
|
||||
AND sort_order <= _to_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
IF _to_index < _from_index
|
||||
THEN
|
||||
_offset := 0;
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order + 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order >= _to_index
|
||||
AND sort_order < _from_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id;
|
||||
END
|
||||
`
|
||||
);
|
||||
|
||||
// Add simple bulk update function as alternative
|
||||
pgm.createFunction(
|
||||
'update_task_sort_orders_bulk',
|
||||
[{ name: '_updates', type: 'json' }],
|
||||
{ returns: 'void', language: 'plpgsql', replace: true },
|
||||
`
|
||||
DECLARE
|
||||
_update_record RECORD;
|
||||
BEGIN
|
||||
-- Simple approach: update each task's sort_order from the provided array
|
||||
FOR _update_record IN
|
||||
SELECT
|
||||
(item->>'task_id')::uuid as task_id,
|
||||
(item->>'sort_order')::int as sort_order,
|
||||
(item->>'status_id')::uuid as status_id,
|
||||
(item->>'priority_id')::uuid as priority_id,
|
||||
(item->>'phase_id')::uuid as phase_id
|
||||
FROM json_array_elements(_updates) as item
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET
|
||||
sort_order = _update_record.sort_order,
|
||||
status_id = COALESCE(_update_record.status_id, status_id),
|
||||
priority_id = COALESCE(_update_record.priority_id, priority_id)
|
||||
WHERE id = _update_record.task_id;
|
||||
|
||||
-- Handle phase updates separately since it's in a different table
|
||||
IF _update_record.phase_id IS NOT NULL THEN
|
||||
INSERT INTO task_phase (task_id, phase_id)
|
||||
VALUES (_update_record.task_id, _update_record.phase_id)
|
||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = pgm => {
|
||||
// Drop the functions if needed to rollback
|
||||
pgm.dropFunction('update_task_sort_orders_bulk', [{ name: '_updates', type: 'json' }], { ifExists: true });
|
||||
pgm.dropFunction('handle_task_list_sort_inside_group_optimized', [
|
||||
{ name: '_from_index', type: 'integer' },
|
||||
{ name: '_to_index', type: 'integer' },
|
||||
{ name: '_task_id', type: 'uuid' },
|
||||
{ name: '_project_id', type: 'uuid' },
|
||||
{ name: '_batch_size', type: 'integer' }
|
||||
], { ifExists: true });
|
||||
pgm.dropFunction('handle_task_list_sort_between_groups_optimized', [
|
||||
{ name: '_from_index', type: 'integer' },
|
||||
{ name: '_to_index', type: 'integer' },
|
||||
{ name: '_task_id', type: 'uuid' },
|
||||
{ name: '_project_id', type: 'uuid' },
|
||||
{ name: '_batch_size', type: 'integer' }
|
||||
], { ifExists: true });
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = pgm => {
|
||||
// Add manual progress fields to tasks table
|
||||
pgm.addColumns('tasks', {
|
||||
manual_progress: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
notNull: false
|
||||
},
|
||||
progress_value: {
|
||||
type: 'integer',
|
||||
default: null,
|
||||
notNull: false
|
||||
},
|
||||
weight: {
|
||||
type: 'integer',
|
||||
default: null,
|
||||
notNull: false
|
||||
}
|
||||
}, { ifNotExists: true });
|
||||
|
||||
// Update function to consider manual progress
|
||||
pgm.createFunction(
|
||||
'get_task_complete_ratio',
|
||||
[{ name: '_task_id', type: 'uuid' }],
|
||||
{ returns: 'json', language: 'plpgsql', replace: true },
|
||||
`
|
||||
DECLARE
|
||||
_parent_task_done FLOAT = 0;
|
||||
_sub_tasks_done FLOAT = 0;
|
||||
_sub_tasks_count FLOAT = 0;
|
||||
_total_completed FLOAT = 0;
|
||||
_total_tasks FLOAT = 0;
|
||||
_ratio FLOAT = 0;
|
||||
_is_manual BOOLEAN = FALSE;
|
||||
_manual_value INTEGER = NULL;
|
||||
BEGIN
|
||||
-- Check if manual progress is set
|
||||
SELECT manual_progress, progress_value
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
INTO _is_manual, _manual_value;
|
||||
|
||||
-- If manual progress is enabled and has a value, use it directly
|
||||
IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _manual_value,
|
||||
'total_completed', 0,
|
||||
'total_tasks', 0,
|
||||
'is_manual', TRUE
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Otherwise calculate automatically as before
|
||||
SELECT (CASE
|
||||
WHEN EXISTS(SELECT 1
|
||||
FROM tasks_with_status_view
|
||||
WHERE tasks_with_status_view.task_id = _task_id
|
||||
AND is_done IS TRUE) THEN 1
|
||||
ELSE 0 END)
|
||||
INTO _parent_task_done;
|
||||
SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id AND archived IS FALSE INTO _sub_tasks_count;
|
||||
|
||||
SELECT COUNT(*)
|
||||
FROM tasks_with_status_view
|
||||
WHERE parent_task_id = _task_id
|
||||
AND is_done IS TRUE
|
||||
INTO _sub_tasks_done;
|
||||
|
||||
_total_completed = _parent_task_done + _sub_tasks_done;
|
||||
_total_tasks = _sub_tasks_count; -- +1 for the parent task
|
||||
|
||||
IF _total_tasks > 0 THEN
|
||||
_ratio = (_total_completed / _total_tasks) * 100;
|
||||
ELSE
|
||||
_ratio = _parent_task_done * 100;
|
||||
END IF;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'ratio', _ratio,
|
||||
'total_completed', _total_completed,
|
||||
'total_tasks', _total_tasks,
|
||||
'is_manual', FALSE
|
||||
);
|
||||
END
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = pgm => {
|
||||
// Drop the function first (it depends on the columns)
|
||||
pgm.dropFunction('get_task_complete_ratio', [{ name: '_task_id', type: 'uuid' }], { ifExists: true });
|
||||
|
||||
// Remove the added columns
|
||||
pgm.dropColumns('tasks', ['manual_progress', 'progress_value', 'weight'], { ifExists: true });
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = pgm => {
|
||||
// Add new sort order columns for different grouping types
|
||||
pgm.addColumns('tasks', {
|
||||
status_sort_order: {
|
||||
type: 'integer',
|
||||
default: 0,
|
||||
notNull: false
|
||||
},
|
||||
priority_sort_order: {
|
||||
type: 'integer',
|
||||
default: 0,
|
||||
notNull: false
|
||||
},
|
||||
phase_sort_order: {
|
||||
type: 'integer',
|
||||
default: 0,
|
||||
notNull: false
|
||||
},
|
||||
member_sort_order: {
|
||||
type: 'integer',
|
||||
default: 0,
|
||||
notNull: false
|
||||
}
|
||||
}, { ifNotExists: true });
|
||||
|
||||
// Initialize new columns with current sort_order values
|
||||
pgm.sql(`
|
||||
UPDATE tasks SET
|
||||
status_sort_order = sort_order,
|
||||
priority_sort_order = sort_order,
|
||||
phase_sort_order = sort_order,
|
||||
member_sort_order = sort_order
|
||||
WHERE status_sort_order = 0
|
||||
OR priority_sort_order = 0
|
||||
OR phase_sort_order = 0
|
||||
OR member_sort_order = 0
|
||||
`);
|
||||
|
||||
// Add constraints to ensure non-negative values
|
||||
pgm.addConstraint('tasks', 'tasks_status_sort_order_check', {
|
||||
check: 'status_sort_order >= 0'
|
||||
}, { ifNotExists: true });
|
||||
|
||||
pgm.addConstraint('tasks', 'tasks_priority_sort_order_check', {
|
||||
check: 'priority_sort_order >= 0'
|
||||
}, { ifNotExists: true });
|
||||
|
||||
pgm.addConstraint('tasks', 'tasks_phase_sort_order_check', {
|
||||
check: 'phase_sort_order >= 0'
|
||||
}, { ifNotExists: true });
|
||||
|
||||
pgm.addConstraint('tasks', 'tasks_member_sort_order_check', {
|
||||
check: 'member_sort_order >= 0'
|
||||
}, { ifNotExists: true });
|
||||
|
||||
// Add indexes for performance
|
||||
pgm.createIndex('tasks', ['project_id', 'status_sort_order'], {
|
||||
name: 'idx_tasks_status_sort_order',
|
||||
ifNotExists: true
|
||||
});
|
||||
|
||||
pgm.createIndex('tasks', ['project_id', 'priority_sort_order'], {
|
||||
name: 'idx_tasks_priority_sort_order',
|
||||
ifNotExists: true
|
||||
});
|
||||
|
||||
pgm.createIndex('tasks', ['project_id', 'phase_sort_order'], {
|
||||
name: 'idx_tasks_phase_sort_order',
|
||||
ifNotExists: true
|
||||
});
|
||||
|
||||
pgm.createIndex('tasks', ['project_id', 'member_sort_order'], {
|
||||
name: 'idx_tasks_member_sort_order',
|
||||
ifNotExists: true
|
||||
});
|
||||
|
||||
// Add column comments for documentation
|
||||
pgm.sql("COMMENT ON COLUMN tasks.status_sort_order IS 'Sort order when grouped by status'");
|
||||
pgm.sql("COMMENT ON COLUMN tasks.priority_sort_order IS 'Sort order when grouped by priority'");
|
||||
pgm.sql("COMMENT ON COLUMN tasks.phase_sort_order IS 'Sort order when grouped by phase'");
|
||||
pgm.sql("COMMENT ON COLUMN tasks.member_sort_order IS 'Sort order when grouped by members/assignees'");
|
||||
};
|
||||
|
||||
exports.down = pgm => {
|
||||
// Drop indexes
|
||||
pgm.dropIndex('tasks', ['project_id', 'member_sort_order'], { name: 'idx_tasks_member_sort_order', ifExists: true });
|
||||
pgm.dropIndex('tasks', ['project_id', 'phase_sort_order'], { name: 'idx_tasks_phase_sort_order', ifExists: true });
|
||||
pgm.dropIndex('tasks', ['project_id', 'priority_sort_order'], { name: 'idx_tasks_priority_sort_order', ifExists: true });
|
||||
pgm.dropIndex('tasks', ['project_id', 'status_sort_order'], { name: 'idx_tasks_status_sort_order', ifExists: true });
|
||||
|
||||
// Drop constraints
|
||||
pgm.dropConstraint('tasks', 'tasks_member_sort_order_check', { ifExists: true });
|
||||
pgm.dropConstraint('tasks', 'tasks_phase_sort_order_check', { ifExists: true });
|
||||
pgm.dropConstraint('tasks', 'tasks_priority_sort_order_check', { ifExists: true });
|
||||
pgm.dropConstraint('tasks', 'tasks_status_sort_order_check', { ifExists: true });
|
||||
|
||||
// Drop columns
|
||||
pgm.dropColumns('tasks', ['status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order'], { ifExists: true });
|
||||
};
|
||||
@@ -25,13 +25,6 @@ CREATE TABLE IF NOT EXISTS pg_sessions (
|
||||
expire TIMESTAMP(6) NOT NULL
|
||||
);
|
||||
|
||||
-- Create pgmigrations table for node-pg-migrate
|
||||
CREATE TABLE IF NOT EXISTS pgmigrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
run_on TIMESTAMP WITH TIME ZONE NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_access_levels (
|
||||
id UUID DEFAULT uuid_generate_v4() NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"migrations-dir": "database/pg-migrations",
|
||||
"migrations-schema": "public",
|
||||
"migrations-table": "pgmigrations",
|
||||
"db": {
|
||||
"user": {
|
||||
"ENV": "DB_USER"
|
||||
},
|
||||
"password": {
|
||||
"ENV": "DB_PASSWORD"
|
||||
},
|
||||
"host": {
|
||||
"ENV": "DB_HOST"
|
||||
},
|
||||
"port": {
|
||||
"ENV": "DB_PORT"
|
||||
},
|
||||
"database": {
|
||||
"ENV": "DB_NAME"
|
||||
}
|
||||
}
|
||||
}
|
||||
476
worklenz-backend/package-lock.json
generated
476
worklenz-backend/package-lock.json
generated
@@ -33,6 +33,7 @@
|
||||
"express-rate-limit": "^6.8.0",
|
||||
"express-session": "^1.17.3",
|
||||
"express-validator": "^6.15.0",
|
||||
"grunt-cli": "^1.5.0",
|
||||
"helmet": "^6.2.0",
|
||||
"hpp": "^0.2.3",
|
||||
"http-errors": "^2.0.0",
|
||||
@@ -115,7 +116,6 @@
|
||||
"jest": "^28.1.3",
|
||||
"jest-sonar-reporter": "^2.0.0",
|
||||
"ncp": "^2.0.0",
|
||||
"node-pg-migrate": "^8.0.3",
|
||||
"nodeman": "^1.1.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
@@ -126,7 +126,7 @@
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"node": ">=16.13.0",
|
||||
"npm": ">=8.11.0",
|
||||
"yarn": "WARNING: Please use npm package manager instead of yarn"
|
||||
}
|
||||
@@ -6455,12 +6455,30 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/array-each": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz",
|
||||
"integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-slice": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz",
|
||||
"integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/array-union": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
|
||||
@@ -6933,7 +6951,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
@@ -8039,6 +8056,15 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-file": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz",
|
||||
"integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||
@@ -8898,6 +8924,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-tilde": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
|
||||
"integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"homedir-polyfill": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect": {
|
||||
"version": "28.1.3",
|
||||
"resolved": "https://registry.npmjs.org/expect/-/expect-28.1.3.tgz",
|
||||
@@ -9050,6 +9088,12 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-csv": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz",
|
||||
@@ -9178,7 +9222,6 @@
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@@ -9244,6 +9287,46 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/findup-sync": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz",
|
||||
"integrity": "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-file": "^1.0.0",
|
||||
"is-glob": "^4.0.0",
|
||||
"micromatch": "^4.0.2",
|
||||
"resolve-dir": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/fined": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz",
|
||||
"integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expand-tilde": "^2.0.2",
|
||||
"is-plain-object": "^2.0.3",
|
||||
"object.defaults": "^1.1.0",
|
||||
"object.pick": "^1.2.0",
|
||||
"parse-filepath": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/flagged-respawn": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz",
|
||||
"integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
||||
@@ -9344,6 +9427,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-in": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
|
||||
"integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/for-own": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz",
|
||||
"integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"for-in": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
@@ -9741,6 +9845,48 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/global-modules": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz",
|
||||
"integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"global-prefix": "^1.0.1",
|
||||
"is-windows": "^1.0.1",
|
||||
"resolve-dir": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/global-prefix": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz",
|
||||
"integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expand-tilde": "^2.0.2",
|
||||
"homedir-polyfill": "^1.0.1",
|
||||
"ini": "^1.3.4",
|
||||
"is-windows": "^1.0.1",
|
||||
"which": "^1.2.14"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/global-prefix/node_modules/which": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
|
||||
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"which": "bin/which"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "11.12.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
|
||||
@@ -9797,6 +9943,34 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/grunt-cli": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.5.0.tgz",
|
||||
"integrity": "sha512-rILKAFoU0dzlf22SUfDtq2R1fosChXXlJM5j7wI6uoW8gwmXDXzbUvirlKZSYCdXl3LXFbR+8xyS+WFo+b6vlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"grunt-known-options": "~2.0.0",
|
||||
"interpret": "~1.1.0",
|
||||
"liftup": "~3.0.1",
|
||||
"nopt": "~5.0.0",
|
||||
"v8flags": "^4.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"grunt": "bin/grunt"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/grunt-known-options": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/grunt-known-options/-/grunt-known-options-2.0.0.tgz",
|
||||
"integrity": "sha512-GD7cTz0I4SAede1/+pAbmJRG44zFLPipVtdL9o3vqx9IEyb7b4/Y3s7r6ofI3CchR5GvYJ+8buCSioDv5dQLiA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -9868,6 +10042,18 @@
|
||||
"dev": true,
|
||||
"license": "https://www.highcharts.com/license"
|
||||
},
|
||||
"node_modules/homedir-polyfill": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",
|
||||
"integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse-passwd": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hpp": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/hpp/-/hpp-0.2.3.tgz",
|
||||
@@ -10077,6 +10263,12 @@
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/interpret": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz",
|
||||
"integrity": "sha512-CLM8SNMDu7C5psFCn6Wg/tgpj/bKAg7hc2gWqcuR9OD5Ft9PhBpIu8PLicPeis+xDd6YX2ncI8MCA64I9tftIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -10086,6 +10278,19 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-absolute": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz",
|
||||
"integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-relative": "^1.0.0",
|
||||
"is-windows": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
@@ -10147,7 +10352,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -10176,7 +10380,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
@@ -10189,7 +10392,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
@@ -10205,6 +10407,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
|
||||
"integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isobject": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
|
||||
@@ -10229,6 +10443,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-relative": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz",
|
||||
"integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-unc-path": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||
@@ -10241,6 +10467,27 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-unc-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz",
|
||||
"integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"unc-path-regex": "^0.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-windows": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
|
||||
"integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
@@ -10251,9 +10498,17 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isobject": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
|
||||
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
@@ -11271,6 +11526,15 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
@@ -11362,6 +11626,25 @@
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/liftup": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/liftup/-/liftup-3.0.1.tgz",
|
||||
"integrity": "sha512-yRHaiQDizWSzoXk3APcA71eOI/UuhEkNN9DiW2Tt44mhYzX4joFoCZlxsSOF7RyeLlfqzFLQI1ngFq3ggMPhOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"findup-sync": "^4.0.0",
|
||||
"fined": "^1.2.0",
|
||||
"flagged-respawn": "^1.0.1",
|
||||
"is-plain-object": "^2.0.4",
|
||||
"object.map": "^1.0.1",
|
||||
"rechoir": "^0.7.0",
|
||||
"resolve": "^1.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -11600,6 +11883,18 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/make-iterator": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz",
|
||||
"integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"kind-of": "^6.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/makeerror": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
|
||||
@@ -11610,6 +11905,15 @@
|
||||
"tmpl": "1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/map-cache": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
|
||||
"integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -11667,7 +11971,6 @@
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
@@ -12015,32 +12318,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-pg-migrate": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/node-pg-migrate/-/node-pg-migrate-8.0.3.tgz",
|
||||
"integrity": "sha512-oKzZyzTULTryO1jehX19VnyPCGf3G/3oWZg3gODphvID56T0WjPOShTVPVnxGdlcueaIW3uAVrr7M8xLZq5TcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"glob": "~11.0.0",
|
||||
"yargs": "~17.7.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-pg-migrate": "bin/node-pg-migrate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/pg": ">=6.0.0 <9.0.0",
|
||||
"pg": ">=4.3.0 <9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/pg": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.19",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||
@@ -12141,6 +12418,46 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object.defaults": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz",
|
||||
"integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-each": "^1.0.1",
|
||||
"array-slice": "^1.0.0",
|
||||
"for-own": "^1.0.0",
|
||||
"isobject": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object.map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz",
|
||||
"integrity": "sha512-3+mAJu2PLfnSVGHwIWubpOFLscJANBKuB/6A4CxBstc4aqwQY0FWcsppuy4jU5GSB95yES5JHSI+33AWuS4k6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"for-own": "^1.0.0",
|
||||
"make-iterator": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object.pick": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz",
|
||||
"integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isobject": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -12303,6 +12620,20 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-filepath": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz",
|
||||
"integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-absolute": "^1.0.0",
|
||||
"map-cache": "^0.2.0",
|
||||
"path-root": "^0.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
@@ -12322,6 +12653,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-passwd": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz",
|
||||
"integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
@@ -12460,6 +12800,27 @@
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-root": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz",
|
||||
"integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-root-regex": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-root-regex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz",
|
||||
"integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
|
||||
@@ -12607,7 +12968,6 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -13203,6 +13563,18 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rechoir": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz",
|
||||
"integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/redis": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
||||
@@ -13354,6 +13726,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-dir": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz",
|
||||
"integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expand-tilde": "^2.0.0",
|
||||
"global-modules": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -14589,7 +14974,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
@@ -15110,6 +15494,15 @@
|
||||
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unc-path-regex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz",
|
||||
"integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
@@ -15339,6 +15732,15 @@
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/v8flags": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8flags/-/v8flags-4.0.1.tgz",
|
||||
"integrity": "sha512-fcRLaS4H/hrZk9hYwbdRM35D0U8IYMfEClhXxCivOojl+yTRAZH3Zy2sSy6qVCiGbV9YAtPssP6jaChqC9vPCg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.15.15",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
|
||||
|
||||
@@ -35,12 +35,7 @@
|
||||
"inline-queries": "node ./cli/inline-queries",
|
||||
"sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties",
|
||||
"tsc": "tsc",
|
||||
"test:watch": "jest --watch --setupFiles dotenv/config",
|
||||
"migrate": "node-pg-migrate",
|
||||
"migrate:up": "npm run migrate up",
|
||||
"migrate:down": "npm run migrate down",
|
||||
"migrate:create": "npm run migrate create",
|
||||
"migrate:redo": "npm run migrate redo"
|
||||
"test:watch": "jest --watch --setupFiles dotenv/config"
|
||||
},
|
||||
"jestSonar": {
|
||||
"reportPath": "coverage",
|
||||
@@ -155,7 +150,6 @@
|
||||
"jest": "^28.1.3",
|
||||
"jest-sonar-reporter": "^2.0.0",
|
||||
"ncp": "^2.0.0",
|
||||
"node-pg-migrate": "^8.0.3",
|
||||
"nodeman": "^1.1.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
|
||||
57
worklenz-backend/src/config/recurring-tasks-config.ts
Normal file
57
worklenz-backend/src/config/recurring-tasks-config.ts
Normal file
@@ -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'),
|
||||
},
|
||||
};
|
||||
@@ -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<IWorkLenzResponse> {
|
||||
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<IWorkLenzResponse> {
|
||||
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<IWorkLenzResponse> {
|
||||
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<IWorkLenzResponse> {
|
||||
const { templateId } = req.params;
|
||||
const result = await RecurringTasksPermissions.validateTemplatePermissions(templateId);
|
||||
return res.status(200).send(new ServerResponse(true, result));
|
||||
}
|
||||
}
|
||||
@@ -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<IWorkLenzResponse> {
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface ITaskGroup {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
color_code: string;
|
||||
color_code_dark: string;
|
||||
category_id: string | null;
|
||||
old_category_id?: string;
|
||||
todo_progress?: number;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
322
worklenz-backend/src/jobs/recurring-tasks-queue.ts
Normal file
322
worklenz-backend/src/jobs/recurring-tasks-queue.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<any> {
|
||||
const [recurringStats, creationStats] = await Promise.all([
|
||||
recurringTasksQueue.getJobCounts(),
|
||||
taskCreationQueue.getJobCounts()
|
||||
]);
|
||||
|
||||
return {
|
||||
recurringTasks: recurringStats,
|
||||
taskCreation: creationStats
|
||||
};
|
||||
}
|
||||
}
|
||||
162
worklenz-backend/src/services/recurring-tasks-service.ts
Normal file
162
worklenz-backend/src/services/recurring-tasks-service.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<any> {
|
||||
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<void> {
|
||||
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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,24 +89,24 @@ export const NumbersColorMap: { [x: string]: string } = {
|
||||
};
|
||||
|
||||
export const PriorityColorCodes: { [x: number]: string; } = {
|
||||
0: "#2E8B57",
|
||||
1: "#DAA520",
|
||||
2: "#CD5C5C"
|
||||
0: "#75c997",
|
||||
1: "#fbc84c",
|
||||
2: "#f37070"
|
||||
};
|
||||
|
||||
export const PriorityColorCodesDark: { [x: number]: string; } = {
|
||||
0: "#3CB371",
|
||||
1: "#B8860B",
|
||||
2: "#F08080"
|
||||
0: "#46D980",
|
||||
1: "#FFC227",
|
||||
2: "#FF4141"
|
||||
};
|
||||
|
||||
export const TASK_STATUS_TODO_COLOR = "#a9a9a9";
|
||||
export const TASK_STATUS_DOING_COLOR = "#70a6f3";
|
||||
export const TASK_STATUS_DONE_COLOR = "#75c997";
|
||||
|
||||
export const TASK_PRIORITY_LOW_COLOR = "#2E8B57";
|
||||
export const TASK_PRIORITY_MEDIUM_COLOR = "#DAA520";
|
||||
export const TASK_PRIORITY_HIGH_COLOR = "#CD5C5C";
|
||||
export const TASK_PRIORITY_LOW_COLOR = "#75c997";
|
||||
export const TASK_PRIORITY_MEDIUM_COLOR = "#fbc84c";
|
||||
export const TASK_PRIORITY_HIGH_COLOR = "#f37070";
|
||||
|
||||
export const TASK_DUE_COMPLETED_COLOR = "#75c997";
|
||||
export const TASK_DUE_UPCOMING_COLOR = "#70a6f3";
|
||||
|
||||
189
worklenz-backend/src/utils/recurring-tasks-audit-logger.ts
Normal file
189
worklenz-backend/src/utils/recurring-tasks-audit-logger.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.log({
|
||||
operationType,
|
||||
scheduleId,
|
||||
templateId,
|
||||
createdBy: userId,
|
||||
details
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit log summary
|
||||
*/
|
||||
static async getAuditSummary(days: number = 7): Promise<any> {
|
||||
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<any[]> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
260
worklenz-backend/src/utils/recurring-tasks-notifications.ts
Normal file
260
worklenz-backend/src/utils/recurring-tasks-notifications.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<any[]> {
|
||||
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<void> {
|
||||
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 = `
|
||||
<h3>Recurring Tasks Created</h3>
|
||||
<p>${createdTasks.length} new tasks have been created from template "${templateName}":</p>
|
||||
<ul>
|
||||
${createdTasks.map(t => `<li>${t.name}</li>`).join('')}
|
||||
</ul>
|
||||
`;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
187
worklenz-backend/src/utils/recurring-tasks-permissions.ts
Normal file
187
worklenz-backend/src/utils/recurring-tasks-permissions.ts
Normal file
@@ -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<PermissionCheckResult> {
|
||||
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<PermissionCheckResult> {
|
||||
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<any[]> {
|
||||
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<string[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
134
worklenz-backend/src/utils/retry-utils.ts
Normal file
134
worklenz-backend/src/utils/retry-utils.ts
Normal file
@@ -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<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: RetryOptions
|
||||
): Promise<T> {
|
||||
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<T>(
|
||||
operation: () => Promise<T>,
|
||||
operationName: string
|
||||
): Promise<T> {
|
||||
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<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: RetryOptions
|
||||
): Promise<T> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
156
worklenz-backend/src/utils/timezone-utils.ts
Normal file
156
worklenz-backend/src/utils/timezone-utils.ts
Normal file
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,11 @@
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "Shkruani email-in tuaj",
|
||||
"emailRequired": "Ju lutemi shkruani Email-in tuaj!",
|
||||
"passwordLabel": "Password",
|
||||
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"passwordLabel": "Fjalëkalimi",
|
||||
"passwordPlaceholder": "Krijoni një fjalëkalim",
|
||||
"passwordRequired": "Ju lutemi krijoni një Fjalëkalim!",
|
||||
"passwordMinCharacterRequired": "Fjalëkalimi duhet të jetë së paku 8 karaktere!",
|
||||
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
|
||||
"passwordPatternRequired": "Fjalëkalimi nuk i plotëson kërkesat!",
|
||||
"passwordPatternRequired": "Fjalëkalimi nuk plotëson kërkesat!",
|
||||
"strongPasswordPlaceholder": "Vendosni një fjalëkalim më të fortë",
|
||||
"passwordValidationAltText": "Fjalëkalimi duhet të përmbajë së paku 8 karaktere me shkronja të mëdha dhe të vogla, një numër dhe një simbol.",
|
||||
"signupSuccessMessage": "Jeni regjistruar me sukses!",
|
||||
|
||||
@@ -7,13 +7,11 @@
|
||||
"emailLabel": "E-Mail",
|
||||
"emailPlaceholder": "Ihre E-Mail-Adresse eingeben",
|
||||
"emailRequired": "Bitte geben Sie Ihre E-Mail-Adresse ein!",
|
||||
"passwordLabel": "Password",
|
||||
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"passwordLabel": "Passwort",
|
||||
"passwordPlaceholder": "Ihr Passwort eingeben",
|
||||
"passwordRequired": "Bitte geben Sie Ihr Passwort ein!",
|
||||
"passwordMinCharacterRequired": "Das Passwort muss mindestens 8 Zeichen lang sein!",
|
||||
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
|
||||
"passwordPatternRequired": "Das Passwort entspricht nicht den Anforderungen!",
|
||||
"passwordPatternRequired": "Das Passwort erfüllt nicht die Anforderungen!",
|
||||
"strongPasswordPlaceholder": "Ein stärkeres Passwort eingeben",
|
||||
"passwordValidationAltText": "Das Passwort muss mindestens 8 Zeichen enthalten, mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.",
|
||||
"signupSuccessMessage": "Sie haben sich erfolgreich registriert!",
|
||||
|
||||
@@ -8,11 +8,9 @@
|
||||
"emailPlaceholder": "Enter your email",
|
||||
"emailRequired": "Please enter your Email!",
|
||||
"passwordLabel": "Password",
|
||||
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"passwordRequired": "Please enter your Password!",
|
||||
"passwordMinCharacterRequired": "Password must be at least 8 characters!",
|
||||
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
|
||||
"passwordPatternRequired": "Password does not meet the requirements!",
|
||||
"strongPasswordPlaceholder": "Enter a stronger password",
|
||||
"passwordValidationAltText": "Password must include at least 8 characters with upper and lower case letters, a number, and a symbol.",
|
||||
|
||||
@@ -7,12 +7,10 @@
|
||||
"emailLabel": "Correo electrónico",
|
||||
"emailPlaceholder": "Ingresa tu correo electrónico",
|
||||
"emailRequired": "¡Por favor ingresa tu correo electrónico!",
|
||||
"passwordLabel": "Password",
|
||||
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"passwordLabel": "Contraseña",
|
||||
"passwordPlaceholder": "Ingresa tu contraseña",
|
||||
"passwordRequired": "¡Por favor ingresa tu contraseña!",
|
||||
"passwordMinCharacterRequired": "¡La contraseña debe tener al menos 8 caracteres!",
|
||||
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
|
||||
"passwordPatternRequired": "¡La contraseña no cumple con los requisitos!",
|
||||
"strongPasswordPlaceholder": "Ingresa una contraseña más segura",
|
||||
"passwordValidationAltText": "La contraseña debe incluir al menos 8 caracteres con letras mayúsculas y minúsculas, un número y un símbolo.",
|
||||
|
||||
@@ -7,13 +7,11 @@
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "Insira seu email",
|
||||
"emailRequired": "Por favor, insira seu Email!",
|
||||
"passwordLabel": "Password",
|
||||
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"passwordLabel": "Senha",
|
||||
"passwordPlaceholder": "Insira sua senha",
|
||||
"passwordRequired": "Por favor, insira sua Senha!",
|
||||
"passwordMinCharacterRequired": "Senha deve ter pelo menos 8 caracteres!",
|
||||
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
|
||||
"passwordPatternRequired": "A senha não atende aos requisitos!",
|
||||
"passwordPatternRequired": "Senha não atende aos requisitos!",
|
||||
"strongPasswordPlaceholder": "Insira uma senha mais forte",
|
||||
"passwordValidationAltText": "Senha deve incluir pelo menos 8 caracteres com letras maiúsculas e minúsculas, um número e um símbolo.",
|
||||
"signupSuccessMessage": "Você se inscreveu com sucesso!",
|
||||
|
||||
@@ -7,12 +7,10 @@
|
||||
"emailLabel": "电子邮件",
|
||||
"emailPlaceholder": "输入您的电子邮件",
|
||||
"emailRequired": "请输入您的电子邮件!",
|
||||
"passwordLabel": "Password",
|
||||
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"passwordLabel": "密码",
|
||||
"passwordPlaceholder": "输入您的密码",
|
||||
"passwordRequired": "请输入您的密码!",
|
||||
"passwordMinCharacterRequired": "密码必须至少包含8个字符!",
|
||||
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
|
||||
"passwordPatternRequired": "密码不符合要求!",
|
||||
"strongPasswordPlaceholder": "输入更强的密码",
|
||||
"passwordValidationAltText": "密码必须至少包含8个字符,包括大小写字母、一个数字和一个符号。",
|
||||
|
||||
@@ -4,12 +4,12 @@ import { Navigate } from 'react-router-dom';
|
||||
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
|
||||
|
||||
// Lazy load auth page components for better code splitting
|
||||
const LoginPage = lazy(() => import('@/pages/auth/LoginPage'));
|
||||
const SignupPage = lazy(() => import('@/pages/auth/SignupPage'));
|
||||
const ForgotPasswordPage = lazy(() => import('@/pages/auth/ForgotPasswordPage'));
|
||||
const LoggingOutPage = lazy(() => import('@/pages/auth/LoggingOutPage'));
|
||||
const AuthenticatingPage = lazy(() => import('@/pages/auth/AuthenticatingPage'));
|
||||
const VerifyResetEmailPage = lazy(() => import('@/pages/auth/VerifyResetEmailPage'));
|
||||
const LoginPage = lazy(() => import('@/pages/auth/login-page'));
|
||||
const SignupPage = lazy(() => import('@/pages/auth/signup-page'));
|
||||
const ForgotPasswordPage = lazy(() => import('@/pages/auth/forgot-password-page'));
|
||||
const LoggingOutPage = lazy(() => import('@/pages/auth/logging-out'));
|
||||
const AuthenticatingPage = lazy(() => import('@/pages/auth/authenticating'));
|
||||
const VerifyResetEmailPage = lazy(() => import('@/pages/auth/verify-reset-email'));
|
||||
|
||||
const authRoutes = [
|
||||
{
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import { Empty, Typography } from 'antd';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type EmptyListPlaceholderProps = {
|
||||
imageSrc?: string;
|
||||
imageHeight?: number;
|
||||
text?: string;
|
||||
textKey?: string;
|
||||
i18nNs?: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const EmptyListPlaceholder = ({
|
||||
imageSrc = 'https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp',
|
||||
imageHeight = 60,
|
||||
text,
|
||||
textKey,
|
||||
i18nNs = 'task-list-table',
|
||||
}: EmptyListPlaceholderProps) => {
|
||||
const { t } = useTranslation(i18nNs);
|
||||
const description = textKey ? t(textKey) : text;
|
||||
return (
|
||||
<Empty
|
||||
image={imageSrc}
|
||||
@@ -29,7 +22,7 @@ const EmptyListPlaceholder = ({
|
||||
alignItems: 'center',
|
||||
marginBlockStart: 24,
|
||||
}}
|
||||
description={<Typography.Text type="secondary">{description}</Typography.Text>}
|
||||
description={<Typography.Text type="secondary">{text}</Typography.Text>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<IRepeatOption>({});
|
||||
@@ -80,6 +100,10 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
||||
const [loadingData, setLoadingData] = useState(false);
|
||||
const [updatingData, setUpdatingData] = useState(false);
|
||||
const [scheduleData, setScheduleData] = useState<ITaskRecurringSchedule>({});
|
||||
const [timezone, setTimezone] = useState('UTC');
|
||||
const [endDate, setEndDate] = useState<moment.Moment | null>(null);
|
||||
const [excludedDates, setExcludedDates] = useState<string[]>([]);
|
||||
const [newExcludeDate, setNewExcludeDate] = useState<moment.Moment | null>(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 }) => {
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* Timezone Selection */}
|
||||
<Form.Item label={t('timezone')}>
|
||||
<Select
|
||||
value={timezone}
|
||||
onChange={setTimezone}
|
||||
options={timezoneOptions}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* End Date */}
|
||||
<Form.Item label={t('endDate')}>
|
||||
<DatePicker
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('selectEndDate')}
|
||||
disabledDate={(current) => current && current < moment().endOf('day')}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Excluded Dates */}
|
||||
<Form.Item label={t('excludedDates')}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<DatePicker
|
||||
value={newExcludeDate}
|
||||
onChange={setNewExcludeDate}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('selectDateToExclude')}
|
||||
disabledDate={(current) => current && current < moment().endOf('day')}
|
||||
/>
|
||||
{newExcludeDate && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const dateStr = newExcludeDate.format('YYYY-MM-DD');
|
||||
if (!excludedDates.includes(dateStr)) {
|
||||
setExcludedDates([...excludedDates, dateStr]);
|
||||
setNewExcludeDate(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('addExcludedDate')}
|
||||
</Button>
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{excludedDates.map((date) => (
|
||||
<Tag
|
||||
key={date}
|
||||
closable
|
||||
onClose={() => {
|
||||
setExcludedDates(excludedDates.filter(d => d !== date));
|
||||
}}
|
||||
style={{ marginBottom: 4 }}
|
||||
>
|
||||
{date}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
interface GroupProgressBarProps {
|
||||
todoProgress: number;
|
||||
@@ -16,34 +15,24 @@ const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
|
||||
groupType
|
||||
}) => {
|
||||
const { t } = useTranslation('task-management');
|
||||
console.log(todoProgress, doingProgress, doneProgress);
|
||||
|
||||
// Only show for priority and phase grouping
|
||||
if (groupType !== 'priority' && groupType !== 'phase') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = (todoProgress || 0) + (doingProgress || 0) + (doneProgress || 0);
|
||||
const total = todoProgress + doingProgress + doneProgress;
|
||||
|
||||
// Don't show if no progress values exist
|
||||
if (total === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tooltip content with all values in rows
|
||||
const tooltipContent = (
|
||||
<div>
|
||||
<div>{t('todo')}: {todoProgress || 0}%</div>
|
||||
<div>{t('inProgress')}: {doingProgress || 0}%</div>
|
||||
<div>{t('done')}: {doneProgress || 0}%</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Compact progress text */}
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap font-medium">
|
||||
{doneProgress || 0}% {t('done')}
|
||||
{doneProgress}% {t('done')}
|
||||
</span>
|
||||
|
||||
{/* Compact progress bar */}
|
||||
@@ -51,30 +40,27 @@ const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
|
||||
<div className="h-full flex">
|
||||
{/* Todo section - light green */}
|
||||
{todoProgress > 0 && (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<div
|
||||
className="bg-green-200 dark:bg-green-800 transition-all duration-300"
|
||||
style={{ width: `${(todoProgress / total) * 100}%` }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div
|
||||
className="bg-green-200 dark:bg-green-800 transition-all duration-300"
|
||||
style={{ width: `${(todoProgress / total) * 100}%` }}
|
||||
title={`${t('todo')}: ${todoProgress}%`}
|
||||
/>
|
||||
)}
|
||||
{/* Doing section - medium green */}
|
||||
{doingProgress > 0 && (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<div
|
||||
className="bg-green-400 dark:bg-green-600 transition-all duration-300"
|
||||
style={{ width: `${(doingProgress / total) * 100}%` }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div
|
||||
className="bg-green-400 dark:bg-green-600 transition-all duration-300"
|
||||
style={{ width: `${(doingProgress / total) * 100}%` }}
|
||||
title={`${t('inProgress')}: ${doingProgress}%`}
|
||||
/>
|
||||
)}
|
||||
{/* Done section - dark green */}
|
||||
{doneProgress > 0 && (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<div
|
||||
className="bg-green-600 dark:bg-green-400 transition-all duration-300"
|
||||
style={{ width: `${(doneProgress / total) * 100}%` }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div
|
||||
className="bg-green-600 dark:bg-green-400 transition-all duration-300"
|
||||
style={{ width: `${(doneProgress / total) * 100}%` }}
|
||||
title={`${t('done')}: ${doneProgress}%`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,25 +68,22 @@ const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
|
||||
{/* Small legend dots with better spacing */}
|
||||
<div className="flex items-center gap-1">
|
||||
{todoProgress > 0 && (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-200 dark:bg-green-800 rounded-full"
|
||||
/>
|
||||
</Tooltip>
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-200 dark:bg-green-800 rounded-full"
|
||||
title={`${t('todo')}: ${todoProgress}%`}
|
||||
/>
|
||||
)}
|
||||
{doingProgress > 0 && (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-400 dark:bg-green-600 rounded-full"
|
||||
/>
|
||||
</Tooltip>
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-400 dark:bg-green-600 rounded-full"
|
||||
title={`${t('inProgress')}: ${doingProgress}%`}
|
||||
/>
|
||||
)}
|
||||
{doneProgress > 0 && (
|
||||
<Tooltip title={tooltipContent} placement="top">
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full"
|
||||
/>
|
||||
</Tooltip>
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full"
|
||||
title={`${t('done')}: ${doneProgress}%`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
import React, { useMemo, useCallback, useState } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
// @ts-ignore: Heroicons module types
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
PencilIcon,
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
|
||||
import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd';
|
||||
import GroupProgressBar from './GroupProgressBar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getContrastColor } from '@/utils/colorUtils';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
selectSelectedTaskIds,
|
||||
selectTask,
|
||||
deselectTask,
|
||||
} from '@/features/task-management/selection.slice';
|
||||
import {
|
||||
selectGroups,
|
||||
fetchTasksV3,
|
||||
selectAllTasksArray,
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
import { selectSelectedTaskIds, selectTask, deselectTask } from '@/features/task-management/selection.slice';
|
||||
import { selectGroups, fetchTasksV3, selectAllTasksArray } from '@/features/task-management/task-management.slice';
|
||||
import { selectCurrentGrouping } from '@/features/task-management/grouping.slice';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||
@@ -52,12 +38,7 @@ interface TaskGroupHeaderProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
group,
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
projectId,
|
||||
}) => {
|
||||
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, onToggle, projectId }) => {
|
||||
const { t } = useTranslation('task-management');
|
||||
const dispatch = useAppDispatch();
|
||||
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
|
||||
@@ -67,14 +48,14 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
const { statusCategories, status: statusList } = useAppSelector(state => state.taskStatusReducer);
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const { isOwnerOrAdmin } = useAuthService();
|
||||
|
||||
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [isChangingCategory, setIsChangingCategory] = useState(false);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editingName, setEditingName] = useState(group.name);
|
||||
|
||||
|
||||
const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color
|
||||
const headerTextColor = getContrastColor(headerBackgroundColor);
|
||||
|
||||
@@ -104,79 +85,53 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
// If we're grouping by status, show progress based on task completion
|
||||
if (currentGrouping === 'status') {
|
||||
// For status grouping, calculate based on task progress values
|
||||
const progressStats = tasksInCurrentGroup.reduce(
|
||||
(acc, task) => {
|
||||
const progress = task.progress || 0;
|
||||
if (progress === 0) {
|
||||
acc.todo += 1;
|
||||
} else if (progress === 100) {
|
||||
acc.done += 1;
|
||||
} else {
|
||||
acc.doing += 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ todo: 0, doing: 0, done: 0 }
|
||||
);
|
||||
const progressStats = tasksInCurrentGroup.reduce((acc, task) => {
|
||||
const progress = task.progress || 0;
|
||||
if (progress === 0) {
|
||||
acc.todo += 1;
|
||||
} else if (progress === 100) {
|
||||
acc.done += 1;
|
||||
} else {
|
||||
acc.doing += 1;
|
||||
}
|
||||
return acc;
|
||||
}, { todo: 0, doing: 0, done: 0 });
|
||||
|
||||
const totalTasks = tasksInCurrentGroup.length;
|
||||
|
||||
|
||||
return {
|
||||
todoProgress: totalTasks > 0 ? Math.round((progressStats.todo / totalTasks) * 100) || 0 : 0,
|
||||
doingProgress:
|
||||
totalTasks > 0 ? Math.round((progressStats.doing / totalTasks) * 100) || 0 : 0,
|
||||
doneProgress: totalTasks > 0 ? Math.round((progressStats.done / totalTasks) * 100) || 0 : 0,
|
||||
todoProgress: totalTasks > 0 ? Math.round((progressStats.todo / totalTasks) * 100) : 0,
|
||||
doingProgress: totalTasks > 0 ? Math.round((progressStats.doing / totalTasks) * 100) : 0,
|
||||
doneProgress: totalTasks > 0 ? Math.round((progressStats.done / totalTasks) * 100) : 0,
|
||||
};
|
||||
} else {
|
||||
// For priority/phase grouping, show progress based on status distribution
|
||||
// Use a simplified approach based on status names and common patterns
|
||||
const statusCounts = tasksInCurrentGroup.reduce(
|
||||
(acc, task) => {
|
||||
// Find the status by ID first
|
||||
const statusInfo = statusList.find(s => s.id === task.status);
|
||||
const statusName = statusInfo?.name?.toLowerCase() || task.status?.toLowerCase() || '';
|
||||
|
||||
// Categorize based on common status name patterns
|
||||
if (
|
||||
statusName.includes('todo') ||
|
||||
statusName.includes('to do') ||
|
||||
statusName.includes('pending') ||
|
||||
statusName.includes('open') ||
|
||||
statusName.includes('backlog')
|
||||
) {
|
||||
acc.todo += 1;
|
||||
} else if (
|
||||
statusName.includes('doing') ||
|
||||
statusName.includes('progress') ||
|
||||
statusName.includes('active') ||
|
||||
statusName.includes('working') ||
|
||||
statusName.includes('development')
|
||||
) {
|
||||
acc.doing += 1;
|
||||
} else if (
|
||||
statusName.includes('done') ||
|
||||
statusName.includes('completed') ||
|
||||
statusName.includes('finished') ||
|
||||
statusName.includes('closed') ||
|
||||
statusName.includes('resolved')
|
||||
) {
|
||||
acc.done += 1;
|
||||
} else {
|
||||
// Default unknown statuses to "doing" (in progress)
|
||||
acc.doing += 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ todo: 0, doing: 0, done: 0 }
|
||||
);
|
||||
const statusCounts = tasksInCurrentGroup.reduce((acc, task) => {
|
||||
// Find the status by ID first
|
||||
const statusInfo = statusList.find(s => s.id === task.status);
|
||||
const statusName = statusInfo?.name?.toLowerCase() || task.status?.toLowerCase() || '';
|
||||
|
||||
// Categorize based on common status name patterns
|
||||
if (statusName.includes('todo') || statusName.includes('to do') || statusName.includes('pending') || statusName.includes('open') || statusName.includes('backlog')) {
|
||||
acc.todo += 1;
|
||||
} else if (statusName.includes('doing') || statusName.includes('progress') || statusName.includes('active') || statusName.includes('working') || statusName.includes('development')) {
|
||||
acc.doing += 1;
|
||||
} else if (statusName.includes('done') || statusName.includes('completed') || statusName.includes('finished') || statusName.includes('closed') || statusName.includes('resolved')) {
|
||||
acc.done += 1;
|
||||
} else {
|
||||
// Default unknown statuses to "doing" (in progress)
|
||||
acc.doing += 1;
|
||||
}
|
||||
return acc;
|
||||
}, { todo: 0, doing: 0, done: 0 });
|
||||
|
||||
const totalTasks = tasksInCurrentGroup.length;
|
||||
|
||||
|
||||
return {
|
||||
todoProgress: totalTasks > 0 ? Math.round((statusCounts.todo / totalTasks) * 100) || 0 : 0,
|
||||
doingProgress:
|
||||
totalTasks > 0 ? Math.round((statusCounts.doing / totalTasks) * 100) || 0 : 0,
|
||||
doneProgress: totalTasks > 0 ? Math.round((statusCounts.done / totalTasks) * 100) || 0 : 0,
|
||||
todoProgress: totalTasks > 0 ? Math.round((statusCounts.todo / totalTasks) * 100) : 0,
|
||||
doingProgress: totalTasks > 0 ? Math.round((statusCounts.doing / totalTasks) * 100) : 0,
|
||||
doneProgress: totalTasks > 0 ? Math.round((statusCounts.done / totalTasks) * 100) : 0,
|
||||
};
|
||||
}
|
||||
}, [currentGroup, allTasks, statusList, currentGrouping]);
|
||||
@@ -186,34 +141,30 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
if (tasksInGroup.length === 0) {
|
||||
return { isAllSelected: false, isPartiallySelected: false };
|
||||
}
|
||||
|
||||
|
||||
const selectedTasksInGroup = tasksInGroup.filter(taskId => selectedTaskIds.includes(taskId));
|
||||
const allSelected = selectedTasksInGroup.length === tasksInGroup.length;
|
||||
const partiallySelected =
|
||||
selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < tasksInGroup.length;
|
||||
|
||||
const partiallySelected = selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < tasksInGroup.length;
|
||||
|
||||
return { isAllSelected: allSelected, isPartiallySelected: partiallySelected };
|
||||
}, [tasksInGroup, selectedTaskIds]);
|
||||
|
||||
// Handle select all checkbox change
|
||||
const handleSelectAllChange = useCallback(
|
||||
(e: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isAllSelected) {
|
||||
// Deselect all tasks in this group
|
||||
tasksInGroup.forEach(taskId => {
|
||||
dispatch(deselectTask(taskId));
|
||||
});
|
||||
} else {
|
||||
// Select all tasks in this group
|
||||
tasksInGroup.forEach(taskId => {
|
||||
dispatch(selectTask(taskId));
|
||||
});
|
||||
}
|
||||
},
|
||||
[dispatch, isAllSelected, tasksInGroup]
|
||||
);
|
||||
const handleSelectAllChange = useCallback((e: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isAllSelected) {
|
||||
// Deselect all tasks in this group
|
||||
tasksInGroup.forEach(taskId => {
|
||||
dispatch(deselectTask(taskId));
|
||||
});
|
||||
} else {
|
||||
// Select all tasks in this group
|
||||
tasksInGroup.forEach(taskId => {
|
||||
dispatch(selectTask(taskId));
|
||||
});
|
||||
}
|
||||
}, [dispatch, isAllSelected, tasksInGroup]);
|
||||
|
||||
// Handle inline name editing
|
||||
const handleNameSave = useCallback(async () => {
|
||||
@@ -233,22 +184,24 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
name: editingName.trim(),
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
|
||||
await statusApiService.updateNameOfStatus(statusId, body, projectId);
|
||||
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Status' });
|
||||
dispatch(fetchStatuses(projectId));
|
||||
|
||||
} else if (currentGrouping === 'phase') {
|
||||
// Extract phase ID from group ID (format: "phase-{phaseId}")
|
||||
const phaseId = group.id.replace('phase-', '');
|
||||
const body = { id: phaseId, name: editingName.trim() };
|
||||
|
||||
|
||||
await phasesApiService.updateNameOfPhase(phaseId, body as ITaskPhase, projectId);
|
||||
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
|
||||
dispatch(fetchPhasesByProjectId(projectId));
|
||||
}
|
||||
|
||||
|
||||
// Refresh task list to get updated group names
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error renaming group:', error);
|
||||
setEditingName(group.name);
|
||||
@@ -256,39 +209,24 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
setIsEditingName(false);
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}, [
|
||||
editingName,
|
||||
group.name,
|
||||
group.id,
|
||||
currentGrouping,
|
||||
projectId,
|
||||
dispatch,
|
||||
trackMixpanelEvent,
|
||||
isRenaming,
|
||||
]);
|
||||
}, [editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming]);
|
||||
|
||||
const handleNameClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!isOwnerOrAdmin) return;
|
||||
setIsEditingName(true);
|
||||
const handleNameClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!isOwnerOrAdmin) return;
|
||||
setIsEditingName(true);
|
||||
setEditingName(group.name);
|
||||
}, [group.name, isOwnerOrAdmin]);
|
||||
|
||||
const handleNameKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleNameSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsEditingName(false);
|
||||
setEditingName(group.name);
|
||||
},
|
||||
[group.name, isOwnerOrAdmin]
|
||||
);
|
||||
|
||||
const handleNameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleNameSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsEditingName(false);
|
||||
setEditingName(group.name);
|
||||
}
|
||||
e.stopPropagation();
|
||||
},
|
||||
[group.name, handleNameSave]
|
||||
);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}, [group.name, handleNameSave]);
|
||||
|
||||
const handleNameBlur = useCallback(() => {
|
||||
handleNameSave();
|
||||
@@ -301,31 +239,31 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
setEditingName(group.name);
|
||||
}, [group.name]);
|
||||
|
||||
|
||||
|
||||
// Handle category change
|
||||
const handleCategoryChange = useCallback(
|
||||
async (categoryId: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
if (isChangingCategory) return;
|
||||
const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
if (isChangingCategory) return;
|
||||
|
||||
setIsChangingCategory(true);
|
||||
try {
|
||||
// Extract status ID from group ID (format: "status-{statusId}")
|
||||
const statusId = group.id.replace('status-', '');
|
||||
|
||||
await statusApiService.updateStatusCategory(statusId, categoryId, projectId);
|
||||
trackMixpanelEvent(evt_project_board_column_setting_click, { 'Change category': 'Status' });
|
||||
|
||||
// Refresh status list and tasks
|
||||
dispatch(fetchStatuses(projectId));
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
} catch (error) {
|
||||
logger.error('Error changing category:', error);
|
||||
} finally {
|
||||
setIsChangingCategory(false);
|
||||
}
|
||||
},
|
||||
[group.id, projectId, dispatch, trackMixpanelEvent, isChangingCategory]
|
||||
);
|
||||
setIsChangingCategory(true);
|
||||
try {
|
||||
// Extract status ID from group ID (format: "status-{statusId}")
|
||||
const statusId = group.id.replace('status-', '');
|
||||
|
||||
await statusApiService.updateStatusCategory(statusId, categoryId, projectId);
|
||||
trackMixpanelEvent(evt_project_board_column_setting_click, { 'Change category': 'Status' });
|
||||
|
||||
// Refresh status list and tasks
|
||||
dispatch(fetchStatuses(projectId));
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error changing category:', error);
|
||||
} finally {
|
||||
setIsChangingCategory(false);
|
||||
}
|
||||
}, [group.id, projectId, dispatch, trackMixpanelEvent, isChangingCategory]);
|
||||
|
||||
// Create dropdown menu items
|
||||
const menuItems = useMemo(() => {
|
||||
@@ -335,12 +273,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
{
|
||||
key: 'rename',
|
||||
icon: <PencilIcon className="h-4 w-4" />,
|
||||
label:
|
||||
currentGrouping === 'status'
|
||||
? t('renameStatus')
|
||||
: currentGrouping === 'phase'
|
||||
? t('renamePhase')
|
||||
: t('renameGroup'),
|
||||
label: currentGrouping === 'status' ? t('renameStatus') : currentGrouping === 'phase' ? t('renamePhase') : t('renameGroup'),
|
||||
onClick: (e: any) => {
|
||||
e?.domEvent?.stopPropagation();
|
||||
handleRenameGroup();
|
||||
@@ -350,7 +283,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
|
||||
// Only show "Change Category" when grouped by status
|
||||
if (currentGrouping === 'status') {
|
||||
const categorySubMenuItems = statusCategories.map(category => ({
|
||||
const categorySubMenuItems = statusCategories.map((category) => ({
|
||||
key: `category-${category.id}`,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -364,23 +297,16 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
},
|
||||
}));
|
||||
|
||||
items.push({
|
||||
key: 'changeCategory',
|
||||
icon: <ArrowPathIcon className="h-4 w-4" />,
|
||||
label: t('changeCategory'),
|
||||
children: categorySubMenuItems,
|
||||
} as any);
|
||||
items.push({
|
||||
key: 'changeCategory',
|
||||
icon: <ArrowPathIcon className="h-4 w-4" />,
|
||||
label: t('changeCategory'),
|
||||
children: categorySubMenuItems,
|
||||
} as any);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
currentGrouping,
|
||||
handleRenameGroup,
|
||||
handleCategoryChange,
|
||||
isOwnerOrAdmin,
|
||||
statusCategories,
|
||||
t,
|
||||
]);
|
||||
}, [currentGrouping, handleRenameGroup, handleCategoryChange, isOwnerOrAdmin, statusCategories, t]);
|
||||
|
||||
// Make the group header droppable
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
@@ -391,7 +317,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -406,26 +332,26 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
|
||||
height: '36px',
|
||||
minHeight: '36px',
|
||||
maxHeight: '36px',
|
||||
maxHeight: '36px'
|
||||
}}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{/* Drag Handle Space - ultra minimal width */}
|
||||
<div style={{ width: '20px' }} className="flex items-center justify-center">
|
||||
{/* Chevron button */}
|
||||
<button
|
||||
<button
|
||||
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
|
||||
style={{ backgroundColor: 'transparent', color: headerTextColor }}
|
||||
onClick={e => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<div
|
||||
className="transition-transform duration-300 ease-out"
|
||||
style={{
|
||||
style={{
|
||||
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
||||
transformOrigin: 'center',
|
||||
transformOrigin: 'center'
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
|
||||
@@ -439,99 +365,100 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
|
||||
checked={isAllSelected}
|
||||
indeterminate={isPartiallySelected}
|
||||
onChange={handleSelectAllChange}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
color: headerTextColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group indicator and name - no gap at all */}
|
||||
<div className="flex items-center flex-1 ml-1">
|
||||
{/* Group name and count */}
|
||||
<div className="flex items-center">
|
||||
{isEditingName ? (
|
||||
<Input
|
||||
value={editingName}
|
||||
onChange={e => setEditingName(e.target.value)}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
onBlur={handleNameBlur}
|
||||
autoFocus
|
||||
size="small"
|
||||
className="text-sm font-semibold"
|
||||
style={{
|
||||
width: 'auto',
|
||||
minWidth: '100px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
color: headerTextColor,
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm font-semibold pr-2 cursor-pointer hover:underline"
|
||||
style={{ color: headerTextColor }}
|
||||
onClick={handleNameClick}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}>
|
||||
({group.count})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Three-dot menu - only show for status and phase grouping */}
|
||||
{menuItems.length > 0 && (currentGrouping === 'status' || currentGrouping === 'phase') && (
|
||||
<div className="flex items-center ml-2">
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
trigger={['click']}
|
||||
open={dropdownVisible}
|
||||
onOpenChange={setDropdownVisible}
|
||||
placement="bottomRight"
|
||||
overlayStyle={{ zIndex: 1000 }}
|
||||
{/* Group indicator and name - no gap at all */}
|
||||
<div className="flex items-center flex-1 ml-1">
|
||||
{/* Group name and count */}
|
||||
<div className="flex items-center">
|
||||
{isEditingName ? (
|
||||
<Input
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
onBlur={handleNameBlur}
|
||||
autoFocus
|
||||
size="small"
|
||||
className="text-sm font-semibold"
|
||||
style={{
|
||||
width: 'auto',
|
||||
minWidth: '100px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
color: headerTextColor,
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm font-semibold pr-2 cursor-pointer hover:underline"
|
||||
style={{ color: headerTextColor }}
|
||||
onClick={handleNameClick}
|
||||
>
|
||||
<button
|
||||
className="p-1 rounded-sm hover:bg-black hover:bg-opacity-10 transition-colors duration-200"
|
||||
style={{ color: headerTextColor }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setDropdownVisible(!dropdownVisible);
|
||||
}}
|
||||
>
|
||||
<EllipsisHorizontalIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
{group.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}>
|
||||
({group.count})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar - sticky to the right edge during horizontal scroll */}
|
||||
{(currentGrouping === 'priority' || currentGrouping === 'phase') &&
|
||||
!(groupProgressValues.todoProgress === 0 && groupProgressValues.doingProgress === 0 && groupProgressValues.doneProgress === 0) && (
|
||||
<div
|
||||
className="flex items-center bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm px-3 py-1.5 ml-auto"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
right: '16px',
|
||||
zIndex: 35, // Higher than header
|
||||
minWidth: '160px',
|
||||
height: '30px',
|
||||
}}
|
||||
{/* Three-dot menu - only show for status and phase grouping */}
|
||||
{menuItems.length > 0 && (currentGrouping === 'status' || currentGrouping === 'phase') && (
|
||||
<div className="flex items-center ml-2">
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
trigger={['click']}
|
||||
open={dropdownVisible}
|
||||
onOpenChange={setDropdownVisible}
|
||||
placement="bottomRight"
|
||||
overlayStyle={{ zIndex: 1000 }}
|
||||
>
|
||||
<GroupProgressBar
|
||||
todoProgress={groupProgressValues.todoProgress}
|
||||
doingProgress={groupProgressValues.doingProgress}
|
||||
doneProgress={groupProgressValues.doneProgress}
|
||||
groupType={group.groupType || currentGrouping || ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="p-1 rounded-sm hover:bg-black hover:bg-opacity-10 transition-colors duration-200"
|
||||
style={{ color: headerTextColor }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDropdownVisible(!dropdownVisible);
|
||||
}}
|
||||
>
|
||||
<EllipsisHorizontalIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Progress Bar - sticky to the right edge during horizontal scroll */}
|
||||
{(currentGrouping === 'priority' || currentGrouping === 'phase') &&
|
||||
(groupProgressValues.todoProgress || groupProgressValues.doingProgress || groupProgressValues.doneProgress) && (
|
||||
<div
|
||||
className="flex items-center bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm px-3 py-1.5 ml-auto"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
right: '16px',
|
||||
zIndex: 35, // Higher than header
|
||||
minWidth: '160px',
|
||||
height: '30px'
|
||||
}}
|
||||
>
|
||||
<GroupProgressBar
|
||||
todoProgress={groupProgressValues.todoProgress}
|
||||
doingProgress={groupProgressValues.doingProgress}
|
||||
doneProgress={groupProgressValues.doneProgress}
|
||||
groupType={group.groupType || currentGrouping || ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskGroupHeader;
|
||||
export default TaskGroupHeader;
|
||||
@@ -60,13 +60,13 @@ import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/
|
||||
// Components
|
||||
import TaskRowWithSubtasks from './TaskRowWithSubtasks';
|
||||
import TaskGroupHeader from './TaskGroupHeader';
|
||||
import ImprovedTaskFilters from '@/components/task-management/improved-task-filters';
|
||||
import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar';
|
||||
import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal';
|
||||
import AddTaskRow from './components/AddTaskRow';
|
||||
import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomColumnComponents';
|
||||
import TaskListSkeleton from './components/TaskListSkeleton';
|
||||
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
|
||||
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
|
||||
|
||||
// Empty Group Drop Zone Component
|
||||
const EmptyGroupDropZone: React.FC<{
|
||||
@@ -90,28 +90,12 @@ const EmptyGroupDropZone: React.FC<{
|
||||
isOver && active ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center min-w-max px-1 border-t border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
|
||||
<div className="flex items-center min-w-max px-1 py-6">
|
||||
{visibleColumns.map((column, index) => {
|
||||
const emptyColumnStyle = {
|
||||
width: column.width,
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
// Show text in the title column
|
||||
if (column.id === 'title') {
|
||||
return (
|
||||
<div
|
||||
key={`empty-${column.id}`}
|
||||
className="flex items-center pl-1"
|
||||
style={emptyColumnStyle}
|
||||
>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
No tasks in this group
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`empty-${column.id}`}
|
||||
@@ -121,6 +105,17 @@ const EmptyGroupDropZone: React.FC<{
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="absolute left-4 top-1/2 transform -translate-y-1/2 flex items-center">
|
||||
<div
|
||||
className={`text-sm px-4 py-3 rounded-md border shadow-sm transition-colors duration-200 ${
|
||||
isOver && active
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 border-blue-300 dark:border-blue-600'
|
||||
: 'text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
{isOver && active ? t('dropTaskHere') || 'Drop task here' : t('noTasksInGroup')}
|
||||
</div>
|
||||
</div>
|
||||
{isOver && active && (
|
||||
<div className="absolute inset-0 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-md pointer-events-none" />
|
||||
)}
|
||||
@@ -184,8 +179,6 @@ const TaskListV2Section: React.FC = () => {
|
||||
const { projectId: urlProjectId } = useParams();
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const { socket, connected } = useSocket();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const isDarkMode = themeMode === 'dark';
|
||||
|
||||
// Redux state selectors
|
||||
const allTasks = useAppSelector(selectAllTasksArray);
|
||||
@@ -499,7 +492,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
isAddTaskRow: true,
|
||||
groupId: group.id,
|
||||
groupType: currentGrouping || 'status',
|
||||
groupValue: group.id, // Send the UUID that backend expects
|
||||
groupValue: group.id, // Use the actual database ID from backend
|
||||
projectId: urlProjectId,
|
||||
rowId: `add-task-${group.id}-0`,
|
||||
autoFocus: false,
|
||||
@@ -510,7 +503,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
isAddTaskRow: true,
|
||||
groupId: group.id,
|
||||
groupType: currentGrouping || 'status',
|
||||
groupValue: group.id, // Send the UUID that backend expects
|
||||
groupValue: group.id,
|
||||
projectId: urlProjectId,
|
||||
rowId: rowId,
|
||||
autoFocus: index === groupAddRows.length - 1, // Auto-focus the latest row
|
||||
@@ -543,7 +536,6 @@ const TaskListV2Section: React.FC = () => {
|
||||
return virtuosoGroups.flatMap(group => group.tasks);
|
||||
}, [virtuosoGroups]);
|
||||
|
||||
|
||||
// Render functions
|
||||
const renderGroup = useCallback(
|
||||
(groupIndex: number) => {
|
||||
@@ -558,7 +550,11 @@ const TaskListV2Section: React.FC = () => {
|
||||
id: group.id,
|
||||
name: group.title,
|
||||
count: group.actualCount,
|
||||
color: isDarkMode ? group.color_code_dark : group.color,
|
||||
color: group.color,
|
||||
todo_progress: group.todo_progress,
|
||||
doing_progress: group.doing_progress,
|
||||
done_progress: group.done_progress,
|
||||
groupType: group.groupType,
|
||||
}}
|
||||
isCollapsed={isGroupCollapsed}
|
||||
onToggle={() => handleGroupCollapse(group.id)}
|
||||
@@ -689,94 +685,13 @@ const TaskListV2Section: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// Show message when no data - but for phase grouping, create an unmapped group
|
||||
// Show message when no data
|
||||
if (groups.length === 0 && !loading) {
|
||||
// If grouped by phase, show an unmapped group to allow task creation
|
||||
if (currentGrouping === 'phase') {
|
||||
const unmappedGroup = {
|
||||
id: 'Unmapped',
|
||||
title: 'Unmapped',
|
||||
groupType: 'phase',
|
||||
groupValue: 'Unmapped', // Use same ID as groupValue for consistency
|
||||
collapsed: false,
|
||||
tasks: [],
|
||||
taskIds: [],
|
||||
color: '#fbc84c69',
|
||||
actualCount: 0,
|
||||
count: 1, // For the add task row
|
||||
startIndex: 0
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
|
||||
<div
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg"
|
||||
style={{
|
||||
height: 'calc(100vh - 240px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={contentScrollRef}
|
||||
className="flex-1 bg-white dark:bg-gray-900 relative"
|
||||
style={{
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{/* Sticky Column Headers */}
|
||||
<div
|
||||
className="sticky top-0 z-30 bg-gray-50 dark:bg-gray-800"
|
||||
style={{ width: '100%', minWidth: 'max-content' }}
|
||||
>
|
||||
{renderColumnHeaders()}
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: 'max-content' }}>
|
||||
<div className="mt-2">
|
||||
<TaskGroupHeader
|
||||
group={{
|
||||
id: 'Unmapped',
|
||||
name: 'Unmapped',
|
||||
count: 0,
|
||||
color: '#fbc84c69',
|
||||
}}
|
||||
isCollapsed={false}
|
||||
onToggle={() => {}}
|
||||
projectId={urlProjectId || ''}
|
||||
/>
|
||||
<AddTaskRow
|
||||
groupId="Unmapped"
|
||||
groupType="phase"
|
||||
groupValue="Unmapped"
|
||||
projectId={urlProjectId || ''}
|
||||
visibleColumns={visibleColumns}
|
||||
onTaskAdded={handleTaskAdded}
|
||||
rowId="add-task-Unmapped-0"
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
// For other groupings, show the empty state message
|
||||
return (
|
||||
<div className="flex flex-col bg-white dark:bg-gray-900 h-full">
|
||||
<div className="flex-none" style={{ height: '74px', flexShrink: 0 }}>
|
||||
<ImprovedTaskFilters position="list" />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
@@ -897,17 +812,19 @@ const TaskListV2Section: React.FC = () => {
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease-in-out' }}>
|
||||
{activeId ? (
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 shadow-lg rounded-md border border-blue-400 dark:border-blue-500 opacity-90"
|
||||
style={{ width: visibleColumns.find(col => col.id === 'title')?.width || '300px' }}
|
||||
>
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<HolderOutlined className="text-gray-400 dark:text-gray-500 text-xs" />
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1">
|
||||
{allTasks.find(task => task.id === activeId)?.name ||
|
||||
allTasks.find(task => task.id === activeId)?.title ||
|
||||
t('emptyStates.dragTaskFallback')}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-2xl rounded-lg border-2 border-blue-500 dark:border-blue-400 scale-105">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<HolderOutlined className="text-blue-500 dark:text-blue-400" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{allTasks.find(task => task.id === activeId)?.name ||
|
||||
allTasks.find(task => task.id === activeId)?.title ||
|
||||
t('emptyStates.dragTaskFallback')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{allTasks.find(task => task.id === activeId)?.task_key}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,9 +125,9 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
||||
return <div className="border-r border-gray-200 dark:border-gray-700" style={labelsStyle} />;
|
||||
case 'title':
|
||||
return (
|
||||
<div className="flex items-center h-full" style={baseStyle}>
|
||||
<div className="flex items-center h-full border-r border-gray-200 dark:border-gray-700" style={baseStyle}>
|
||||
<div className="flex items-center w-full h-full">
|
||||
<div className="w-1 mr-1" />
|
||||
<div className="w-4 mr-1" />
|
||||
|
||||
{!isAdding ? (
|
||||
<button
|
||||
@@ -165,7 +165,7 @@ const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
||||
}, [isAdding, taskName, handleAddTask, handleCancel, handleKeyDown, t]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px]">
|
||||
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px] border-b border-gray-200 dark:border-gray-700">
|
||||
{visibleColumns.map((column, index) => (
|
||||
<React.Fragment key={column.id}>
|
||||
{renderColumn(column.id, column.width)}
|
||||
|
||||
@@ -526,25 +526,9 @@ const taskManagementSlice = createSlice({
|
||||
},
|
||||
addTaskToGroup: (state, action: PayloadAction<{ task: Task; groupId: string }>) => {
|
||||
const { task, groupId } = action.payload;
|
||||
|
||||
state.ids.push(task.id);
|
||||
state.entities[task.id] = task;
|
||||
let group = state.groups.find(g => g.id === groupId);
|
||||
|
||||
// If group doesn't exist and it's "Unmapped", create it dynamically
|
||||
if (!group && groupId === 'Unmapped') {
|
||||
const unmappedGroup = {
|
||||
id: 'Unmapped',
|
||||
title: 'Unmapped',
|
||||
taskIds: [],
|
||||
type: 'phase' as const,
|
||||
color: '#fbc84c69',
|
||||
groupValue: 'Unmapped'
|
||||
};
|
||||
state.groups.push(unmappedGroup);
|
||||
group = unmappedGroup;
|
||||
}
|
||||
|
||||
const group = state.groups.find(g => g.id === groupId);
|
||||
if (group) {
|
||||
group.taskIds.push(task.id);
|
||||
}
|
||||
@@ -1186,7 +1170,7 @@ export default taskManagementSlice.reducer;
|
||||
|
||||
// V3 API selectors - no processing needed, data is pre-processed by backend
|
||||
export const selectTaskGroupsV3 = (state: RootState) => state.taskManagement.groups;
|
||||
export const selectCurrentGroupingV3 = (state: RootState) => state.grouping.currentGrouping;
|
||||
export const selectCurrentGroupingV3 = (state: RootState) => state.taskManagement.grouping;
|
||||
|
||||
// Column-related selectors
|
||||
export const selectColumns = (state: RootState) => state.taskManagement.columns;
|
||||
|
||||
@@ -855,11 +855,10 @@ export const useTaskSocketHandlers = () => {
|
||||
// For priority grouping, use priority field (which contains the priority UUID)
|
||||
groupId = data.priority;
|
||||
} else if (grouping === 'phase') {
|
||||
// For phase grouping, use phase_id, or 'Unmapped' if no phase_id
|
||||
groupId = data.phase_id || 'Unmapped';
|
||||
// For phase grouping, use phase_id
|
||||
groupId = data.phase_id;
|
||||
}
|
||||
|
||||
|
||||
// Use addTaskToGroup with the actual group UUID
|
||||
dispatch(addTaskToGroup({ task, groupId: groupId || '' }));
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ const ForgotPasswordPage = () => {
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder={t('emailPlaceholder', {defaultValue: 'Enter your email'})}
|
||||
placeholder={t('emailPlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
@@ -134,7 +134,7 @@ const ForgotPasswordPage = () => {
|
||||
loading={isLoading}
|
||||
style={{ borderRadius: 4 }}
|
||||
>
|
||||
{t('resetPasswordButton', {defaultValue: 'Reset Password'})}
|
||||
{t('resetPasswordButton')}
|
||||
</Button>
|
||||
<Typography.Text style={{ textAlign: 'center' }}>{t('orText')}</Typography.Text>
|
||||
<Link to="/auth/login">
|
||||
@@ -146,7 +146,7 @@ const ForgotPasswordPage = () => {
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{t('returnToLoginButton', {defaultValue: 'Return to Login'})}
|
||||
{t('returnToLoginButton')}
|
||||
</Button>
|
||||
</Link>
|
||||
</Flex>
|
||||
@@ -5,8 +5,6 @@ import { useMediaQuery } from 'react-responsive';
|
||||
import { LockOutlined, MailOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { Form, Card, Input, Flex, Button, Typography, Space, message } from 'antd/es';
|
||||
import { Rule } from 'antd/es/form';
|
||||
import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
import googleIcon from '@/assets/images/google-icon.png';
|
||||
import PageHeader from '@components/AuthPageHeader';
|
||||
@@ -299,10 +297,6 @@ const SignupPage = () => {
|
||||
min: 8,
|
||||
message: t('passwordMinCharacterRequired'),
|
||||
},
|
||||
{
|
||||
max: 32,
|
||||
message: t('passwordMaxCharacterRequired'),
|
||||
},
|
||||
{
|
||||
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])/,
|
||||
message: t('passwordPatternRequired'),
|
||||
@@ -310,38 +304,6 @@ const SignupPage = () => {
|
||||
],
|
||||
};
|
||||
|
||||
const passwordChecklistItems = [
|
||||
{
|
||||
key: 'minLength',
|
||||
test: (v: string) => v.length >= 8,
|
||||
label: t('passwordChecklist.minLength', { defaultValue: 'At least 8 characters' }),
|
||||
},
|
||||
{
|
||||
key: 'uppercase',
|
||||
test: (v: string) => /[A-Z]/.test(v),
|
||||
label: t('passwordChecklist.uppercase', { defaultValue: 'One uppercase letter' }),
|
||||
},
|
||||
{
|
||||
key: 'lowercase',
|
||||
test: (v: string) => /[a-z]/.test(v),
|
||||
label: t('passwordChecklist.lowercase', { defaultValue: 'One lowercase letter' }),
|
||||
},
|
||||
{
|
||||
key: 'number',
|
||||
test: (v: string) => /\d/.test(v),
|
||||
label: t('passwordChecklist.number', { defaultValue: 'One number' }),
|
||||
},
|
||||
{
|
||||
key: 'special',
|
||||
test: (v: string) => /[@$!%*?&#]/.test(v),
|
||||
label: t('passwordChecklist.special', { defaultValue: 'One special character' }),
|
||||
},
|
||||
];
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const [passwordValue, setPasswordValue] = useState('');
|
||||
const [passwordActive, setPasswordActive] = useState(false);
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
@@ -355,7 +317,7 @@ const SignupPage = () => {
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
<PageHeader description={t('headerDescription', {defaultValue: 'Sign up to get started'})} />
|
||||
<PageHeader description={t('headerDescription')} />
|
||||
<Form
|
||||
form={form}
|
||||
name="signup"
|
||||
@@ -369,72 +331,35 @@ const SignupPage = () => {
|
||||
name: urlParams.name,
|
||||
}}
|
||||
>
|
||||
<Form.Item name="name" label={t('nameLabel', {defaultValue: 'Full Name'})} rules={formRules.name}>
|
||||
<Form.Item name="name" label={t('nameLabel')} rules={formRules.name}>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder={t('namePlaceholder', {defaultValue: 'Enter your full name'})}
|
||||
placeholder={t('namePlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="email" label={t('emailLabel', {defaultValue: 'Email'})} rules={formRules.email as Rule[]}>
|
||||
<Form.Item name="email" label={t('emailLabel')} rules={formRules.email as Rule[]}>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder={t('emailPlaceholder', {defaultValue: 'Enter your email'})}
|
||||
placeholder={t('emailPlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={t('passwordLabel', {defaultValue: 'Password'})}
|
||||
rules={formRules.password}
|
||||
validateTrigger={['onBlur', 'onSubmit']}
|
||||
>
|
||||
<Form.Item name="password" label={t('passwordLabel')} rules={formRules.password}>
|
||||
<div>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder={t('strongPasswordPlaceholder', {defaultValue: 'Enter a strong password'})}
|
||||
placeholder={t('strongPasswordPlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
value={passwordValue}
|
||||
onFocus={() => setPasswordActive(true)}
|
||||
onChange={e => {
|
||||
setPasswordValue(e.target.value);
|
||||
setPasswordActive(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!passwordValue) setPasswordActive(false);
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginTop: 4, marginBottom: 0, display: 'block' }}>
|
||||
{t('passwordGuideline', {
|
||||
defaultValue: 'Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.'
|
||||
})}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('passwordValidationAltText')}
|
||||
</Typography.Text>
|
||||
{passwordActive && (
|
||||
<div style={{ marginTop: 8, marginBottom: 4 }}>
|
||||
{passwordChecklistItems.map(item => {
|
||||
const passed = item.test(passwordValue);
|
||||
// Only green if passed, otherwise neutral (never red)
|
||||
let color = passed
|
||||
? (themeMode === 'dark' ? '#52c41a' : '#389e0d')
|
||||
: (themeMode === 'dark' ? '#b0b3b8' : '#bfbfbf');
|
||||
return (
|
||||
<Flex key={item.key} align="center" gap={8} style={{ color, fontSize: 13 }}>
|
||||
{passed ? (
|
||||
<CheckCircleTwoTone twoToneColor={themeMode === 'dark' ? '#52c41a' : '#52c41a'} />
|
||||
) : (
|
||||
<CloseCircleTwoTone twoToneColor={themeMode === 'dark' ? '#b0b3b8' : '#bfbfbf'} />
|
||||
)}
|
||||
<span>{item.label}</span>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
@@ -491,7 +416,7 @@ const SignupPage = () => {
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Typography.Text style={{ fontSize: 14 }}>
|
||||
{t('alreadyHaveAccountText', {defaultValue: 'Already have an account?'})}
|
||||
{t('alreadyHaveAccountText')}
|
||||
</Typography.Text>
|
||||
|
||||
<Link
|
||||
@@ -4,8 +4,6 @@ import { Form, Card, Input, Flex, Button, Typography, Result } from 'antd/es';
|
||||
import { LockOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
import PageHeader from '@components/AuthPageHeader';
|
||||
|
||||
@@ -38,36 +36,6 @@ const VerifyResetEmailPage = () => {
|
||||
const { t } = useTranslation('auth/verify-reset-email');
|
||||
|
||||
const isMobile = useMediaQuery({ query: '(max-width: 576px)' });
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const [passwordValue, setPasswordValue] = useState('');
|
||||
const [passwordTouched, setPasswordTouched] = useState(false);
|
||||
const passwordChecklistItems = [
|
||||
{
|
||||
key: 'minLength',
|
||||
test: (v: string) => v.length >= 8,
|
||||
label: t('passwordChecklist.minLength', { defaultValue: 'At least 8 characters' }),
|
||||
},
|
||||
{
|
||||
key: 'uppercase',
|
||||
test: (v: string) => /[A-Z]/.test(v),
|
||||
label: t('passwordChecklist.uppercase', { defaultValue: 'One uppercase letter' }),
|
||||
},
|
||||
{
|
||||
key: 'lowercase',
|
||||
test: (v: string) => /[a-z]/.test(v),
|
||||
label: t('passwordChecklist.lowercase', { defaultValue: 'One lowercase letter' }),
|
||||
},
|
||||
{
|
||||
key: 'number',
|
||||
test: (v: string) => /\d/.test(v),
|
||||
label: t('passwordChecklist.number', { defaultValue: 'One number' }),
|
||||
},
|
||||
{
|
||||
key: 'special',
|
||||
test: (v: string) => /[@$!%*?&#]/.test(v),
|
||||
label: t('passwordChecklist.special', { defaultValue: 'One special character' }),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_verify_reset_email_page_visit);
|
||||
@@ -136,38 +104,12 @@ const VerifyResetEmailPage = () => {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder={t('placeholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
value={passwordValue}
|
||||
onChange={e => {
|
||||
setPasswordValue(e.target.value);
|
||||
if (!passwordTouched) setPasswordTouched(true);
|
||||
}}
|
||||
onBlur={() => setPasswordTouched(true)}
|
||||
/>
|
||||
<div style={{ marginTop: 8, marginBottom: 4 }}>
|
||||
{passwordChecklistItems.map(item => {
|
||||
const passed = item.test(passwordValue);
|
||||
let color = passed
|
||||
? (themeMode === 'dark' ? '#52c41a' : '#389e0d')
|
||||
: (themeMode === 'dark' ? '#b0b3b8' : '#bfbfbf');
|
||||
return (
|
||||
<Flex key={item.key} align="center" gap={8} style={{ color, fontSize: 13 }}>
|
||||
{passed ? (
|
||||
<CheckCircleTwoTone twoToneColor={themeMode === 'dark' ? '#52c41a' : '#52c41a'} />
|
||||
) : (
|
||||
<CloseCircleTwoTone twoToneColor={themeMode === 'dark' ? '#b0b3b8' : '#bfbfbf'} />
|
||||
)}
|
||||
<span>{item.label}</span>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder={t('placeholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
@@ -194,8 +136,6 @@ const VerifyResetEmailPage = () => {
|
||||
placeholder={t('confirmPasswordPlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
value={form.getFieldValue('confirmPassword') || ''}
|
||||
onChange={e => form.setFieldsValue({ confirmPassword: e.target.value })}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -62,7 +62,6 @@ export interface TaskGroup {
|
||||
taskIds: string[];
|
||||
type?: 'status' | 'priority' | 'phase' | 'members';
|
||||
color?: string;
|
||||
color_code_dark?: string;
|
||||
collapsed?: boolean;
|
||||
groupValue?: string;
|
||||
// Add any other group properties as needed
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface ITaskRecurringSchedule {
|
||||
interval_weeks?: number | null;
|
||||
schedule_type?: ITaskRecurring;
|
||||
week_of_month?: number | null;
|
||||
timezone?: string;
|
||||
end_date?: string | null;
|
||||
excluded_dates?: string[] | null;
|
||||
}
|
||||
|
||||
export interface IRepeatOption {
|
||||
|
||||
Reference in New Issue
Block a user