Compare commits
1 Commits
imp/recurr
...
fix/drag-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eb55b315a |
@@ -1,111 +0,0 @@
|
|||||||
# 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,385 +2,5 @@
|
|||||||
"name": "worklenz",
|
"name": "worklenz",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"bull": "^4.16.5",
|
|
||||||
"ioredis": "^5.6.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bull": "^3.15.9"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
-- 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';
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
-- 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,57 +0,0 @@
|
|||||||
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'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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,7 +6,6 @@ import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
|||||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||||
import { ServerResponse } from "../models/server-response";
|
import { ServerResponse } from "../models/server-response";
|
||||||
import { calculateNextEndDate, log_error } from "../shared/utils";
|
import { calculateNextEndDate, log_error } from "../shared/utils";
|
||||||
import { RecurringTasksAuditLogger, RecurringTaskOperationType } from "../utils/recurring-tasks-audit-logger";
|
|
||||||
|
|
||||||
export default class TaskRecurringController extends WorklenzControllerBase {
|
export default class TaskRecurringController extends WorklenzControllerBase {
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
@@ -35,7 +34,7 @@ export default class TaskRecurringController extends WorklenzControllerBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async createTaskSchedule(taskId: string, userId?: string) {
|
public static async createTaskSchedule(taskId: string) {
|
||||||
const q = `INSERT INTO task_recurring_schedules (schedule_type) VALUES ('daily') RETURNING id, schedule_type;`;
|
const q = `INSERT INTO task_recurring_schedules (schedule_type) VALUES ('daily') RETURNING id, schedule_type;`;
|
||||||
const result = await db.query(q, []);
|
const result = await db.query(q, []);
|
||||||
const [data] = result.rows;
|
const [data] = result.rows;
|
||||||
@@ -45,15 +44,6 @@ export default class TaskRecurringController extends WorklenzControllerBase {
|
|||||||
|
|
||||||
await TaskRecurringController.insertTaskRecurringTemplate(taskId, data.id);
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,9 +56,9 @@ export default class TaskRecurringController extends WorklenzControllerBase {
|
|||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async updateSchedule(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async updateSchedule(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const { id } = req.params;
|
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, timezone, end_date, excluded_dates } = req.body;
|
const { schedule_type, days_of_week, day_of_month, week_of_month, interval_days, interval_weeks, interval_months, date_of_month } = req.body;
|
||||||
|
|
||||||
const updateQ = `UPDATE task_recurring_schedules
|
const deleteQ = `UPDATE task_recurring_schedules
|
||||||
SET schedule_type = $1,
|
SET schedule_type = $1,
|
||||||
days_of_week = $2,
|
days_of_week = $2,
|
||||||
date_of_month = $3,
|
date_of_month = $3,
|
||||||
@@ -76,27 +66,9 @@ export default class TaskRecurringController extends WorklenzControllerBase {
|
|||||||
week_of_month = $5,
|
week_of_month = $5,
|
||||||
interval_days = $6,
|
interval_days = $6,
|
||||||
interval_weeks = $7,
|
interval_weeks = $7,
|
||||||
interval_months = $8,
|
interval_months = $8
|
||||||
timezone = COALESCE($9, timezone, 'UTC'),
|
WHERE id = $9;`;
|
||||||
end_date = $10,
|
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]);
|
||||||
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));
|
return res.status(200).send(new ServerResponse(true, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,12 @@ import { CronJob } from "cron";
|
|||||||
import { calculateNextEndDate, log_error } from "../shared/utils";
|
import { calculateNextEndDate, log_error } from "../shared/utils";
|
||||||
import db from "../config/db";
|
import db from "../config/db";
|
||||||
import { IRecurringSchedule, ITaskTemplate } from "../interfaces/recurring-tasks";
|
import { IRecurringSchedule, ITaskTemplate } from "../interfaces/recurring-tasks";
|
||||||
import moment from "moment-timezone";
|
import moment from "moment";
|
||||||
import TasksController from "../controllers/tasks-controller";
|
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";
|
|
||||||
|
|
||||||
// Run every hour to process tasks in different timezones
|
// 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 = process.env.RECURRING_JOBS_INTERVAL || "0 * * * *";
|
// const TIME = "0 11 */1 * 1-5";
|
||||||
|
const TIME = process.env.RECURRING_JOBS_INTERVAL || "0 11 */1 * 1-5";
|
||||||
const TIME_FORMAT = "YYYY-MM-DD";
|
const TIME_FORMAT = "YYYY-MM-DD";
|
||||||
// const TIME = "0 0 * * *"; // Runs at midnight every day
|
// const TIME = "0 0 * * *"; // Runs at midnight every day
|
||||||
|
|
||||||
@@ -48,129 +44,8 @@ function getFutureLimit(scheduleType: string, interval?: number): moment.Duratio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to batch create tasks using bulk operations
|
// Helper function to batch create tasks
|
||||||
async function createBatchTasks(template: ITaskTemplate & IRecurringSchedule, endDates: moment.Moment[]) {
|
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 = [];
|
const createdTasks = [];
|
||||||
|
|
||||||
for (const nextEndDate of endDates) {
|
for (const nextEndDate of endDates) {
|
||||||
@@ -217,162 +92,69 @@ async function createBatchTasksSequential(template: ITaskTemplate & IRecurringSc
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onRecurringTaskJobTick() {
|
async function onRecurringTaskJobTick() {
|
||||||
const errors: any[] = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log("(cron) Recurring tasks job started.");
|
log("(cron) Recurring tasks job started.");
|
||||||
RecurringTasksAuditLogger.startTimer();
|
|
||||||
|
|
||||||
// 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 = `
|
const templatesQuery = `
|
||||||
SELECT t.*, s.*,
|
SELECT t.*, s.*, (SELECT MAX(end_date) FROM tasks WHERE schedule_id = s.id) as last_task_end_date
|
||||||
(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
|
FROM task_recurring_templates t
|
||||||
JOIN task_recurring_schedules s ON t.schedule_id = s.id
|
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);
|
const templatesResult = await db.query(templatesQuery);
|
||||||
}, "fetch_recurring_templates");
|
const templates = templatesResult.rows as (ITaskTemplate & IRecurringSchedule)[];
|
||||||
|
|
||||||
const templates = templatesResult.rows as (ITaskTemplate & IRecurringSchedule & { user_timezone?: string })[];
|
|
||||||
|
|
||||||
|
const now = moment();
|
||||||
let createdTaskCount = 0;
|
let createdTaskCount = 0;
|
||||||
|
|
||||||
for (const template of templates) {
|
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
|
const lastTaskEndDate = template.last_task_end_date
|
||||||
? moment.tz(template.last_task_end_date, timezone)
|
? moment(template.last_task_end_date)
|
||||||
: moment.tz(template.created_at, timezone);
|
: moment(template.created_at);
|
||||||
|
|
||||||
// Calculate future limit based on schedule type
|
// Calculate future limit based on schedule type
|
||||||
const futureLimit = moment.tz(template.last_checked_at || template.created_at, timezone)
|
const futureLimit = moment(template.last_checked_at || template.created_at)
|
||||||
.add(getFutureLimit(
|
.add(getFutureLimit(
|
||||||
template.schedule_type,
|
template.schedule_type,
|
||||||
template.interval_days || template.interval_weeks || template.interval_months || 1
|
template.interval_days || template.interval_weeks || template.interval_months || 1
|
||||||
));
|
));
|
||||||
|
|
||||||
let nextEndDate = TimezoneUtils.calculateNextEndDateWithTimezone(template, lastTaskEndDate, timezone);
|
let nextEndDate = calculateNextEndDate(template, lastTaskEndDate);
|
||||||
const endDatesToCreate: moment.Moment[] = [];
|
const endDatesToCreate: moment.Moment[] = [];
|
||||||
|
|
||||||
// Find all future occurrences within the limit
|
// Find all future occurrences within the limit
|
||||||
while (nextEndDate.isSameOrBefore(futureLimit)) {
|
while (nextEndDate.isSameOrBefore(futureLimit)) {
|
||||||
if (nextEndDate.isAfter(now)) {
|
if (nextEndDate.isAfter(now)) {
|
||||||
// Check if date is not in excluded dates
|
|
||||||
if (!template.excluded_dates || !template.excluded_dates.includes(nextEndDate.format(TIME_FORMAT))) {
|
|
||||||
endDatesToCreate.push(moment(nextEndDate));
|
endDatesToCreate.push(moment(nextEndDate));
|
||||||
}
|
}
|
||||||
}
|
nextEndDate = calculateNextEndDate(template, nextEndDate);
|
||||||
nextEndDate = TimezoneUtils.calculateNextEndDateWithTimezone(template, nextEndDate, timezone);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch create tasks for all future dates
|
// Batch create tasks for all future dates
|
||||||
if (endDatesToCreate.length > 0) {
|
if (endDatesToCreate.length > 0) {
|
||||||
try {
|
|
||||||
const createdTasks = await createBatchTasks(template, endDatesToCreate);
|
const createdTasks = await createBatchTasks(template, endDatesToCreate);
|
||||||
createdTaskCount += createdTasks.length;
|
createdTaskCount += createdTasks.length;
|
||||||
|
|
||||||
// Log successful template processing
|
// Update the last_checked_at in the schedule
|
||||||
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 = `
|
const updateScheduleQuery = `
|
||||||
UPDATE task_recurring_schedules
|
UPDATE task_recurring_schedules
|
||||||
SET last_checked_at = $1,
|
SET last_checked_at = $1::DATE,
|
||||||
last_created_task_end_date = $2
|
last_created_task_end_date = $2
|
||||||
WHERE id = $3;
|
WHERE id = $3;
|
||||||
`;
|
`;
|
||||||
return await db.query(updateScheduleQuery, [
|
await db.query(updateScheduleQuery, [
|
||||||
now.toDate(),
|
moment().format(TIME_FORMAT),
|
||||||
endDatesToCreate[endDatesToCreate.length - 1].toDate(),
|
endDatesToCreate[endDatesToCreate.length - 1].format(TIME_FORMAT),
|
||||||
template.schedule_id
|
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 {
|
} else {
|
||||||
console.log(`No tasks created for template ${template.name} (${timezone}) - next occurrence is beyond the future limit or excluded`);
|
console.log(`No tasks created for template ${template.name} - next occurrence is beyond the future limit`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`);
|
log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`);
|
||||||
|
|
||||||
// Log cron job completion
|
|
||||||
await RecurringTasksAuditLogger.logCronJobRun(
|
|
||||||
templates.length,
|
|
||||||
createdTaskCount,
|
|
||||||
errors
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log_error(error);
|
log_error(error);
|
||||||
log("(cron) Recurring task job ended with errors.");
|
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,9 +12,6 @@ export interface IRecurringSchedule {
|
|||||||
last_checked_at: Date | null;
|
last_checked_at: Date | null;
|
||||||
last_task_end_date: Date | null;
|
last_task_end_date: Date | null;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
timezone?: string;
|
|
||||||
end_date?: Date | null;
|
|
||||||
excluded_dates?: string[] | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ITaskTemplateAssignee {
|
interface ITaskTemplateAssignee {
|
||||||
|
|||||||
@@ -1,322 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
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 }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// Core dependencies
|
// Core dependencies
|
||||||
import React, { Suspense, useEffect, memo, useMemo, useCallback } from 'react';
|
import React, { Suspense, useEffect, memo, useMemo, useCallback, useState } from 'react';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import ThemeWrapper from './features/theme/ThemeWrapper';
|
import ThemeWrapper from './features/theme/ThemeWrapper';
|
||||||
@@ -27,6 +28,24 @@ import { CSSPerformanceMonitor, LayoutStabilizer, CriticalCSSManager } from './u
|
|||||||
// Service Worker
|
// Service Worker
|
||||||
import { registerSW } from './utils/serviceWorkerRegistration';
|
import { registerSW } from './utils/serviceWorkerRegistration';
|
||||||
|
|
||||||
|
// i18n ready check component
|
||||||
|
const I18nReadyCheck: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { ready } = useTranslation();
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ready) {
|
||||||
|
setIsReady(true);
|
||||||
|
}
|
||||||
|
}, [ready]);
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return <SuspenseFallback />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main App Component - Performance Optimized
|
* Main App Component - Performance Optimized
|
||||||
*
|
*
|
||||||
@@ -200,18 +219,15 @@ const App: React.FC = memo(() => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<SuspenseFallback />}>
|
<I18nReadyCheck>
|
||||||
<ThemeWrapper>
|
|
||||||
<ModuleErrorBoundary>
|
<ModuleErrorBoundary>
|
||||||
<RouterProvider
|
<ThemeWrapper>
|
||||||
router={router}
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
future={{
|
<RouterProvider router={router} />
|
||||||
v7_startTransition: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ModuleErrorBoundary>
|
|
||||||
</ThemeWrapper>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</ThemeWrapper>
|
||||||
|
</ModuleErrorBoundary>
|
||||||
|
</I18nReadyCheck>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import {
|
|||||||
Skeleton,
|
Skeleton,
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
DatePicker,
|
|
||||||
Tag,
|
|
||||||
Space,
|
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { CloseOutlined } from '@ant-design/icons';
|
|
||||||
import { SettingOutlined } from '@ant-design/icons';
|
import { SettingOutlined } from '@ant-design/icons';
|
||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
@@ -33,7 +29,6 @@ import { updateTaskCounts } from '@/features/task-management/task-management.sli
|
|||||||
import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service';
|
import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice';
|
import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice';
|
||||||
import moment from 'moment-timezone';
|
|
||||||
|
|
||||||
const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1);
|
const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1);
|
||||||
|
|
||||||
@@ -71,21 +66,6 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
|
|
||||||
const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value }));
|
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 [recurring, setRecurring] = useState(false);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [repeatOption, setRepeatOption] = useState<IRepeatOption>({});
|
const [repeatOption, setRepeatOption] = useState<IRepeatOption>({});
|
||||||
@@ -100,10 +80,6 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
const [loadingData, setLoadingData] = useState(false);
|
const [loadingData, setLoadingData] = useState(false);
|
||||||
const [updatingData, setUpdatingData] = useState(false);
|
const [updatingData, setUpdatingData] = useState(false);
|
||||||
const [scheduleData, setScheduleData] = useState<ITaskRecurringSchedule>({});
|
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) => {
|
const handleChange = (checked: boolean) => {
|
||||||
if (!task.id) return;
|
if (!task.id) return;
|
||||||
@@ -164,9 +140,6 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
const body: ITaskRecurringSchedule = {
|
const body: ITaskRecurringSchedule = {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
schedule_type: repeatOption.value,
|
schedule_type: repeatOption.value,
|
||||||
timezone: timezone,
|
|
||||||
end_date: endDate ? endDate.format('YYYY-MM-DD') : null,
|
|
||||||
excluded_dates: excludedDates,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (repeatOption.value) {
|
switch (repeatOption.value) {
|
||||||
@@ -240,16 +213,13 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
const selected = repeatOptions.find(e => e.value == res.body.schedule_type);
|
const selected = repeatOptions.find(e => e.value == res.body.schedule_type);
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setRepeatOption(selected);
|
setRepeatOption(selected);
|
||||||
setSelectedMonthlyDate(res.body.date_of_month || 1);
|
setSelectedMonthlyDate(scheduleData.date_of_month || 1);
|
||||||
setSelectedMonthlyDay(res.body.day_of_month || 0);
|
setSelectedMonthlyDay(scheduleData.day_of_month || 0);
|
||||||
setSelectedMonthlyWeek(res.body.week_of_month || 0);
|
setSelectedMonthlyWeek(scheduleData.week_of_month || 0);
|
||||||
setIntervalDays(res.body.interval_days || 1);
|
setIntervalDays(scheduleData.interval_days || 1);
|
||||||
setIntervalWeeks(res.body.interval_weeks || 1);
|
setIntervalWeeks(scheduleData.interval_weeks || 1);
|
||||||
setIntervalMonths(res.body.interval_months || 1);
|
setIntervalMonths(scheduleData.interval_months || 1);
|
||||||
setTimezone(res.body.timezone || 'UTC');
|
setMonthlyOption(selectedMonthlyDate ? 'date' : 'day');
|
||||||
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();
|
updateDaysOfWeek();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,69 +365,6 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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' }}>
|
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
|||||||
@@ -250,6 +250,30 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
newPosition: insertIndex,
|
newPosition: insertIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Optimistically update task properties immediately for UI responsiveness
|
||||||
|
const updatedTask = {
|
||||||
|
...activeTask,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update task properties based on the grouping type
|
||||||
|
if (currentGrouping === 'priority') {
|
||||||
|
updatedTask.priority = targetGroup.id;
|
||||||
|
updatedTask.priority_id = targetGroup.id;
|
||||||
|
} else if (currentGrouping === 'phase') {
|
||||||
|
updatedTask.phase = targetGroup.id;
|
||||||
|
updatedTask.phase_id = targetGroup.id;
|
||||||
|
} else if (currentGrouping === 'status') {
|
||||||
|
updatedTask.status = targetGroup.id;
|
||||||
|
updatedTask.status_id = targetGroup.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import and dispatch updateTask for immediate UI update
|
||||||
|
import('@/features/task-management/task-management.slice').then(({ updateTask }) => {
|
||||||
|
dispatch(updateTask(updatedTask));
|
||||||
|
});
|
||||||
|
|
||||||
// reorderTasksInGroup handles both same-group and cross-group moves
|
// reorderTasksInGroup handles both same-group and cross-group moves
|
||||||
// No need for separate moveTaskBetweenGroups call
|
// No need for separate moveTaskBetweenGroups call
|
||||||
dispatch(
|
dispatch(
|
||||||
|
|||||||
@@ -657,9 +657,15 @@ const taskManagementSlice = createSlice({
|
|||||||
const group = state.groups.find(g => g.id === sourceGroupId);
|
const group = state.groups.find(g => g.id === sourceGroupId);
|
||||||
if (group) {
|
if (group) {
|
||||||
const newTasks = Array.from(group.taskIds);
|
const newTasks = Array.from(group.taskIds);
|
||||||
const [removed] = newTasks.splice(newTasks.indexOf(sourceTaskId), 1);
|
const sourceIndex = newTasks.indexOf(sourceTaskId);
|
||||||
newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed);
|
const destIndex = newTasks.indexOf(destinationTaskId);
|
||||||
|
|
||||||
|
// Only proceed if both tasks are found
|
||||||
|
if (sourceIndex !== -1 && destIndex !== -1) {
|
||||||
|
const [removed] = newTasks.splice(sourceIndex, 1);
|
||||||
|
newTasks.splice(destIndex, 0, removed);
|
||||||
group.taskIds = newTasks;
|
group.taskIds = newTasks;
|
||||||
|
}
|
||||||
|
|
||||||
// Update order for affected tasks using the appropriate sort field
|
// Update order for affected tasks using the appropriate sort field
|
||||||
const sortField = getSortOrderField(state.grouping?.id);
|
const sortField = getSortOrderField(state.grouping?.id);
|
||||||
@@ -681,13 +687,20 @@ const taskManagementSlice = createSlice({
|
|||||||
// Add to destination group at the correct position relative to destinationTask
|
// Add to destination group at the correct position relative to destinationTask
|
||||||
const destinationIndex = destinationGroup.taskIds.indexOf(destinationTaskId);
|
const destinationIndex = destinationGroup.taskIds.indexOf(destinationTaskId);
|
||||||
if (destinationIndex !== -1) {
|
if (destinationIndex !== -1) {
|
||||||
|
// Ensure we don't add duplicate task IDs
|
||||||
|
if (!destinationGroup.taskIds.includes(sourceTaskId)) {
|
||||||
destinationGroup.taskIds.splice(destinationIndex, 0, sourceTaskId);
|
destinationGroup.taskIds.splice(destinationIndex, 0, sourceTaskId);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
destinationGroup.taskIds.push(sourceTaskId); // Add to end if destination task not found
|
// Add to end if destination task not found, but only if not already present
|
||||||
|
if (!destinationGroup.taskIds.includes(sourceTaskId)) {
|
||||||
|
destinationGroup.taskIds.push(sourceTaskId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do NOT update the task's grouping field (priority, phase, status) here.
|
// Note: Task's grouping field (priority, phase, status) will also be updated
|
||||||
// This will be handled by the socket event handler after backend confirmation.
|
// by the socket event handler after backend confirmation, but for immediate UI
|
||||||
|
// feedback, we update it optimistically in the drag and drop handler.
|
||||||
|
|
||||||
// Update order for affected tasks in both groups using the appropriate sort field
|
// Update order for affected tasks in both groups using the appropriate sort field
|
||||||
const sortField = getSortOrderField(state.grouping?.id);
|
const sortField = getSortOrderField(state.grouping?.id);
|
||||||
|
|||||||
@@ -217,6 +217,17 @@ export const useTaskSocketHandlers = () => {
|
|||||||
const currentGrouping = state.taskManagement.grouping;
|
const currentGrouping = state.taskManagement.grouping;
|
||||||
|
|
||||||
if (currentTask) {
|
if (currentTask) {
|
||||||
|
// Check if current task has a more recent optimistic update
|
||||||
|
const currentUpdateTime = currentTask.updatedAt || currentTask.updated_at;
|
||||||
|
const serverUpdateTime = response.updated_at || response.updatedAt;
|
||||||
|
|
||||||
|
// If current task was updated more recently than server data, skip this update
|
||||||
|
if (currentUpdateTime && serverUpdateTime &&
|
||||||
|
new Date(currentUpdateTime).getTime() > new Date(serverUpdateTime).getTime()) {
|
||||||
|
console.log(`[TASK_STATUS_CHANGE] Skipping stale update for task ${response.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine the new status value based on status category
|
// Determine the new status value based on status category
|
||||||
let newStatusValue: 'todo' | 'doing' | 'done' = 'todo';
|
let newStatusValue: 'todo' | 'doing' | 'done' = 'todo';
|
||||||
if (response.statusCategory) {
|
if (response.statusCategory) {
|
||||||
@@ -393,6 +404,17 @@ export const useTaskSocketHandlers = () => {
|
|||||||
const currentGrouping = state.taskManagement.grouping;
|
const currentGrouping = state.taskManagement.grouping;
|
||||||
|
|
||||||
if (currentTask) {
|
if (currentTask) {
|
||||||
|
// Check if current task has a more recent optimistic update
|
||||||
|
const currentUpdateTime = currentTask.updatedAt || currentTask.updated_at;
|
||||||
|
const serverUpdateTime = response.updated_at || response.updatedAt;
|
||||||
|
|
||||||
|
// If current task was updated more recently than server data, skip this update
|
||||||
|
if (currentUpdateTime && serverUpdateTime &&
|
||||||
|
new Date(currentUpdateTime).getTime() > new Date(serverUpdateTime).getTime()) {
|
||||||
|
console.log(`[TASK_PRIORITY_CHANGE] Skipping stale update for task ${response.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get priority list to map priority_id to priority name
|
// Get priority list to map priority_id to priority name
|
||||||
const priorityList = state.priorityReducer?.priorities || [];
|
const priorityList = state.priorityReducer?.priorities || [];
|
||||||
let newPriorityValue: 'critical' | 'high' | 'medium' | 'low' = 'medium';
|
let newPriorityValue: 'critical' | 'high' | 'medium' | 'low' = 'medium';
|
||||||
@@ -549,6 +571,17 @@ export const useTaskSocketHandlers = () => {
|
|||||||
const currentTask = state.taskManagement.entities[taskId];
|
const currentTask = state.taskManagement.entities[taskId];
|
||||||
|
|
||||||
if (currentTask) {
|
if (currentTask) {
|
||||||
|
// Check if current task has a more recent optimistic update
|
||||||
|
const currentUpdateTime = currentTask.updatedAt || currentTask.updated_at;
|
||||||
|
const serverUpdateTime = data.updated_at || data.updatedAt;
|
||||||
|
|
||||||
|
// If current task was updated more recently than server data, skip this update
|
||||||
|
if (currentUpdateTime && serverUpdateTime &&
|
||||||
|
new Date(currentUpdateTime).getTime() > new Date(serverUpdateTime).getTime()) {
|
||||||
|
console.log(`[TASK_PHASE_CHANGE] Skipping stale update for task ${data.task_id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get phase list to map phase_id to phase name
|
// Get phase list to map phase_id to phase name
|
||||||
const phaseList = state.phaseReducer?.phaseList || [];
|
const phaseList = state.phaseReducer?.phaseList || [];
|
||||||
let newPhaseValue = '';
|
let newPhaseValue = '';
|
||||||
@@ -1007,6 +1040,18 @@ export const useTaskSocketHandlers = () => {
|
|||||||
data.forEach((taskData: any) => {
|
data.forEach((taskData: any) => {
|
||||||
const currentTask = state.taskManagement.entities[taskData.id];
|
const currentTask = state.taskManagement.entities[taskData.id];
|
||||||
if (currentTask) {
|
if (currentTask) {
|
||||||
|
// Check if current task has a more recent optimistic update
|
||||||
|
const currentUpdateTime = currentTask.updatedAt || currentTask.updated_at;
|
||||||
|
const serverUpdateTime = taskData.updated_at || taskData.updatedAt;
|
||||||
|
|
||||||
|
// If current task was updated more recently than server data, skip this update
|
||||||
|
// This prevents socket updates from overwriting newer optimistic updates
|
||||||
|
if (currentUpdateTime && serverUpdateTime &&
|
||||||
|
new Date(currentUpdateTime).getTime() > new Date(serverUpdateTime).getTime()) {
|
||||||
|
console.log(`[TASK_SORT_ORDER_CHANGE] Skipping stale update for task ${taskData.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let updatedTask: Task = {
|
let updatedTask: Task = {
|
||||||
...currentTask,
|
...currentTask,
|
||||||
order: taskData.sort_order || taskData.current_sort_order || currentTask.order,
|
order: taskData.sort_order || taskData.current_sort_order || currentTask.order,
|
||||||
|
|||||||
@@ -24,11 +24,33 @@ i18n
|
|||||||
|
|
||||||
backend: {
|
backend: {
|
||||||
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||||||
|
// Ensure translations are loaded synchronously
|
||||||
|
crossDomain: false,
|
||||||
|
withCredentials: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
react: {
|
react: {
|
||||||
useSuspense: false,
|
useSuspense: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Ensure all namespaces are loaded upfront
|
||||||
|
ns: ['common', 'home', 'task-management', 'task-list-table'],
|
||||||
|
|
||||||
|
// Add initialization promise to ensure translations are loaded
|
||||||
|
initImmediate: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure translations are loaded before the app starts
|
||||||
|
i18n.on('initialized', () => {
|
||||||
|
console.log('i18n initialized successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
i18n.on('loaded', (loaded) => {
|
||||||
|
console.log('i18n loaded:', loaded);
|
||||||
|
});
|
||||||
|
|
||||||
|
i18n.on('failedLoading', (lng, ns, msg) => {
|
||||||
|
console.error('i18n failed loading:', lng, ns, msg);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { IMyTask } from '@/types/home/my-tasks.types';
|
|||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
|
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Skeleton } from 'antd';
|
||||||
|
|
||||||
interface AddTaskInlineFormProps {
|
interface AddTaskInlineFormProps {
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
@@ -25,35 +27,41 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
|
|||||||
const [isDueDateFieldShowing, setIsDueDateFieldShowing] = useState(false);
|
const [isDueDateFieldShowing, setIsDueDateFieldShowing] = useState(false);
|
||||||
const [isProjectFieldShowing, setIsProjectFieldShowing] = useState(false);
|
const [isProjectFieldShowing, setIsProjectFieldShowing] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const { ready } = useTranslation('home');
|
||||||
|
|
||||||
|
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||||
|
const { data: projectListData, isFetching: projectListFetching } = useGetProjectsByTeamQuery();
|
||||||
|
const { refetch } = useGetMyTasksQuery(homeTasksConfig);
|
||||||
const currentSession = useAuthService().getCurrentSession();
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
const { socket } = useSocket();
|
const { socket } = useSocket();
|
||||||
|
|
||||||
const { data: projectListData, isFetching: projectListFetching } = useGetProjectsByTeamQuery();
|
|
||||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
|
||||||
const { refetch } = useGetMyTasksQuery(homeTasksConfig);
|
|
||||||
|
|
||||||
const taskInputRef = useRef<InputRef | null>(null);
|
const taskInputRef = useRef<InputRef | null>(null);
|
||||||
|
|
||||||
|
// Don't render until i18n is ready
|
||||||
|
if (!ready) {
|
||||||
|
return <Skeleton active />;
|
||||||
|
}
|
||||||
|
|
||||||
const dueDateOptions = [
|
const dueDateOptions = [
|
||||||
{
|
{
|
||||||
value: 'Today',
|
value: 'Today',
|
||||||
label: t('home:tasks.today'),
|
label: t('tasks.today'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'Tomorrow',
|
value: 'Tomorrow',
|
||||||
label: t('home:tasks.tomorrow'),
|
label: t('tasks.tomorrow'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'Next Week',
|
value: 'Next Week',
|
||||||
label: t('home:tasks.nextWeek'),
|
label: t('tasks.nextWeek'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'Next Month',
|
value: 'Next Month',
|
||||||
label: t('home:tasks.nextMonth'),
|
label: t('tasks.nextMonth'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'No Due Date',
|
value: 'No Due Date',
|
||||||
label: t('home:tasks.noDueDate'),
|
label: t('tasks.noDueDate'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -169,14 +177,14 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
|
|||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: t('home:tasks.taskRequired'),
|
message: t('tasks.taskRequired'),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Flex vertical gap={4}>
|
<Flex vertical gap={4}>
|
||||||
<Input
|
<Input
|
||||||
ref={taskInputRef}
|
ref={taskInputRef}
|
||||||
placeholder={t('home:tasks.addTask')}
|
placeholder={t('tasks.addTask')}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
const inputValue = e.currentTarget.value;
|
const inputValue = e.currentTarget.value;
|
||||||
@@ -200,7 +208,7 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
|
|||||||
<Alert
|
<Alert
|
||||||
message={
|
message={
|
||||||
<Typography.Text style={{ fontSize: 11 }}>
|
<Typography.Text style={{ fontSize: 11 }}>
|
||||||
{t('home:tasks.pressTabToSelectDueDateAndProject')}
|
{t('tasks.pressTabToSelectDueDateAndProject')}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
}
|
}
|
||||||
type="info"
|
type="info"
|
||||||
@@ -245,7 +253,7 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
|
|||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: t('home:tasks.projectRequired'),
|
message: t('tasks.projectRequired'),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import HomeCalendar from '../../../components/calendars/homeCalendar/HomeCalendar';
|
import HomeCalendar from '../../../components/calendars/homeCalendar/HomeCalendar';
|
||||||
import { Tag, Typography } from 'antd';
|
import { Tag, Typography, Skeleton } from 'antd';
|
||||||
import { ClockCircleOutlined } from '@ant-design/icons';
|
import { ClockCircleOutlined } from '@ant-design/icons';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import AddTaskInlineForm from './add-task-inline-form';
|
import AddTaskInlineForm from './add-task-inline-form';
|
||||||
@@ -10,7 +10,12 @@ import dayjs from 'dayjs';
|
|||||||
|
|
||||||
const CalendarView = () => {
|
const CalendarView = () => {
|
||||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||||
const { t } = useTranslation('home');
|
const { t, ready } = useTranslation('home');
|
||||||
|
|
||||||
|
// Don't render until i18n is ready
|
||||||
|
if (!ready) {
|
||||||
|
return <Skeleton active />;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!homeTasksConfig.selected_date) {
|
if (!homeTasksConfig.selected_date) {
|
||||||
@@ -36,7 +41,7 @@ const CalendarView = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography.Text>
|
<Typography.Text>
|
||||||
{t('home:tasks.dueOn')} {homeTasksConfig.selected_date?.format('MMM DD, YYYY')}
|
{t('tasks.dueOn')} {homeTasksConfig.selected_date?.format('MMM DD, YYYY')}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|
||||||
|
|||||||
@@ -61,18 +61,23 @@ const TasksList: React.FC = React.memo(() => {
|
|||||||
refetchOnFocus: false,
|
refetchOnFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation('home');
|
const { t, ready } = useTranslation('home');
|
||||||
const { model } = useAppSelector(state => state.homePageReducer);
|
const { model } = useAppSelector(state => state.homePageReducer);
|
||||||
|
|
||||||
|
// Don't render until i18n is ready
|
||||||
|
if (!ready) {
|
||||||
|
return <Skeleton active />;
|
||||||
|
}
|
||||||
|
|
||||||
const taskModes = useMemo(
|
const taskModes = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
value: 0,
|
value: 0,
|
||||||
label: t('home:tasks.assignedToMe'),
|
label: t('tasks.assignedToMe'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 1,
|
value: 1,
|
||||||
label: t('home:tasks.assignedByMe'),
|
label: t('tasks.assignedByMe'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t]
|
[t]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Tooltip from 'antd/es/tooltip';
|
|||||||
import Typography from 'antd/es/typography';
|
import Typography from 'antd/es/typography';
|
||||||
import Button from 'antd/es/button';
|
import Button from 'antd/es/button';
|
||||||
import Alert from 'antd/es/alert';
|
import Alert from 'antd/es/alert';
|
||||||
|
import Skeleton from 'antd/es/skeleton';
|
||||||
|
|
||||||
import EmptyListPlaceholder from '@components/EmptyListPlaceholder';
|
import EmptyListPlaceholder from '@components/EmptyListPlaceholder';
|
||||||
import { IMyTask } from '@/types/home/my-tasks.types';
|
import { IMyTask } from '@/types/home/my-tasks.types';
|
||||||
@@ -24,7 +25,7 @@ import { useCreatePersonalTaskMutation } from '@/api/home-page/home-page.api.ser
|
|||||||
const TodoList = () => {
|
const TodoList = () => {
|
||||||
const [isAlertShowing, setIsAlertShowing] = useState(false);
|
const [isAlertShowing, setIsAlertShowing] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { t } = useTranslation('home');
|
const { t, ready } = useTranslation('home');
|
||||||
|
|
||||||
const [createPersonalTask, { isLoading: isCreatingPersonalTask }] =
|
const [createPersonalTask, { isLoading: isCreatingPersonalTask }] =
|
||||||
useCreatePersonalTaskMutation();
|
useCreatePersonalTaskMutation();
|
||||||
@@ -35,6 +36,11 @@ const TodoList = () => {
|
|||||||
// ref for todo input field
|
// ref for todo input field
|
||||||
const todoInputRef = useRef<InputRef | null>(null);
|
const todoInputRef = useRef<InputRef | null>(null);
|
||||||
|
|
||||||
|
// Don't render until i18n is ready
|
||||||
|
if (!ready) {
|
||||||
|
return <Skeleton active />;
|
||||||
|
}
|
||||||
|
|
||||||
// function to handle todo submit
|
// function to handle todo submit
|
||||||
const handleTodoSubmit = async (values: any) => {
|
const handleTodoSubmit = async (values: any) => {
|
||||||
if (!values.name || values.name.trim() === '') return;
|
if (!values.name || values.name.trim() === '') return;
|
||||||
@@ -43,6 +49,7 @@ const TodoList = () => {
|
|||||||
done: false,
|
done: false,
|
||||||
is_task: false,
|
is_task: false,
|
||||||
color_code: '#000',
|
color_code: '#000',
|
||||||
|
manual_progress: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await createPersonalTask(newTodo);
|
const res = await createPersonalTask(newTodo);
|
||||||
@@ -69,7 +76,7 @@ const TodoList = () => {
|
|||||||
width: 32,
|
width: 32,
|
||||||
render: (record: IMyTask) => (
|
render: (record: IMyTask) => (
|
||||||
<ConfigProvider wave={{ disabled: true }}>
|
<ConfigProvider wave={{ disabled: true }}>
|
||||||
<Tooltip title={t('home:todoList.markAsDone')}>
|
<Tooltip title={t('todoList.markAsDone')}>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
className="borderless-icon-btn"
|
className="borderless-icon-btn"
|
||||||
@@ -100,11 +107,11 @@ const TodoList = () => {
|
|||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<Typography.Title level={5} style={{ marginBlockEnd: 0 }}>
|
<Typography.Title level={5} style={{ marginBlockEnd: 0 }}>
|
||||||
{t('home:todoList.title')} ({data?.body.length})
|
{t('todoList.title')} ({data?.body.length})
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
<Tooltip title={t('home:todoList.refreshTasks')}>
|
<Tooltip title={t('todoList.refreshTasks')}>
|
||||||
<Button shape="circle" icon={<SyncOutlined spin={isFetching} />} onClick={refetch} />
|
<Button shape="circle" icon={<SyncOutlined spin={isFetching} />} onClick={refetch} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
@@ -116,7 +123,7 @@ const TodoList = () => {
|
|||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
<Input
|
<Input
|
||||||
ref={todoInputRef}
|
ref={todoInputRef}
|
||||||
placeholder={t('home:todoList.addTask')}
|
placeholder={t('todoList.addTask')}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
const inputValue = e.currentTarget.value;
|
const inputValue = e.currentTarget.value;
|
||||||
|
|
||||||
@@ -128,8 +135,8 @@ const TodoList = () => {
|
|||||||
<Alert
|
<Alert
|
||||||
message={
|
message={
|
||||||
<Typography.Text style={{ fontSize: 11 }}>
|
<Typography.Text style={{ fontSize: 11 }}>
|
||||||
{t('home:todoList.pressEnter')} <strong>Enter</strong>{' '}
|
{t('todoList.pressEnter')} <strong>Enter</strong>{' '}
|
||||||
{t('home:todoList.toCreate')}
|
{t('todoList.toCreate')}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
}
|
}
|
||||||
type="info"
|
type="info"
|
||||||
@@ -148,7 +155,7 @@ const TodoList = () => {
|
|||||||
{data?.body.length === 0 ? (
|
{data?.body.length === 0 ? (
|
||||||
<EmptyListPlaceholder
|
<EmptyListPlaceholder
|
||||||
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||||
text={t('home:todoList.noTasks')}
|
text={t('todoList.noTasks')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ export interface ITaskRecurringSchedule {
|
|||||||
interval_weeks?: number | null;
|
interval_weeks?: number | null;
|
||||||
schedule_type?: ITaskRecurring;
|
schedule_type?: ITaskRecurring;
|
||||||
week_of_month?: number | null;
|
week_of_month?: number | null;
|
||||||
timezone?: string;
|
|
||||||
end_date?: string | null;
|
|
||||||
excluded_dates?: string[] | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IRepeatOption {
|
export interface IRepeatOption {
|
||||||
|
|||||||
Reference in New Issue
Block a user