feat: comprehensive update with Signal, Copilot, themes, and PDF features
## Signal Messaging Integration - Added SignalService for sending messages to drivers via Signal - SignalMessage model for tracking message history - Driver chat modal for real-time messaging - Send schedule via Signal (ICS + PDF attachments) ## AI Copilot - Natural language interface for VIP Coordinator - Capabilities: create VIPs, schedule events, assign drivers - Help and guidance for users - Floating copilot button in UI ## Theme System - Dark/light/system theme support - Color scheme selection (blue, green, purple, orange, red) - ThemeContext for global state - AppearanceMenu in header ## PDF Schedule Export - VIPSchedulePDF component for schedule generation - PDF settings (header, footer, branding) - Preview PDF in browser - Settings stored in database ## Database Migrations - add_signal_messages: SignalMessage model - add_pdf_settings: Settings model for PDF config - add_reminder_tracking: lastReminderSent for events - make_driver_phone_optional: phone field nullable ## Event Management - Event status service for automated updates - IN_PROGRESS/COMPLETED status tracking - Reminder tracking for notifications ## UI/UX Improvements - Driver schedule modal - Improved My Schedule page - Better error handling and loading states - Responsive design improvements ## Other Changes - AGENT_TEAM.md documentation - Seed data improvements - Ability factory updates - Driver profile page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
289
backend/package-lock.json
generated
289
backend/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.72.1",
|
||||
"@casl/ability": "^6.8.0",
|
||||
"@casl/prisma": "^1.6.1",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
@@ -20,13 +21,16 @@
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@prisma/client": "^5.8.1",
|
||||
"@types/pdfkit": "^0.17.4",
|
||||
"axios": "^1.6.5",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"ics": "^3.8.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"jwks-rsa": "^3.1.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"reflect-metadata": "^0.1.14",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -36,6 +40,7 @@
|
||||
"@nestjs/testing": "^10.3.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
@@ -216,6 +221,26 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.72.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.72.1.tgz",
|
||||
"integrity": "sha512-MiUnue7qN7DvLIoYHgkedN2z05mRf2CutBzjXXY2krzOhG2r/rIfISS2uVkNLikgToB5hYIzw+xp2jdOtRkqYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
|
||||
@@ -678,6 +703,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -2175,6 +2209,15 @@
|
||||
"@sinonjs/commons": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
||||
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/inflate": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
|
||||
@@ -2441,6 +2484,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.30",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
|
||||
@@ -2483,6 +2536,15 @@
|
||||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pdfkit": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.4.tgz",
|
||||
"integrity": "sha512-odAmVuuguRxKh1X4pbMrJMp8ecwNqHRw6lweupvzK+wuyNmi6wzlUlGVZ9EqMvp3Bs2+L9Ty0sRlrvKL+gsQZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
@@ -3415,7 +3477,6 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3529,6 +3590,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
@@ -4201,6 +4271,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -4339,6 +4415,12 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
@@ -5017,7 +5099,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
@@ -5252,6 +5333,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit/node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
@@ -5777,6 +5884,17 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ics": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/ics/-/ics-3.8.1.tgz",
|
||||
"integrity": "sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.1.23",
|
||||
"runes2": "^1.1.2",
|
||||
"yup": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -6899,6 +7017,13 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-exif": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -6946,6 +7071,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -7107,6 +7245,25 @@
|
||||
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
||||
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -7539,6 +7696,24 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
@@ -7796,6 +7971,12 @@
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -7956,6 +8137,19 @@
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.17.2",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
|
||||
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"jpeg-exif": "^1.1.4",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -8065,6 +8259,11 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
|
||||
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -8168,6 +8367,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/property-expr": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
|
||||
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -8474,6 +8679,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -8582,6 +8793,12 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/runes2": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz",
|
||||
"integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
@@ -9414,6 +9631,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-case": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
|
||||
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
||||
@@ -9474,6 +9703,12 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/toposort": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
|
||||
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
@@ -9490,6 +9725,12 @@
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
||||
@@ -9796,6 +10037,26 @@
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
@@ -10247,6 +10508,30 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yup": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
|
||||
"integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"property-expr": "^2.0.5",
|
||||
"tiny-case": "^1.0.3",
|
||||
"toposort": "^2.0.2",
|
||||
"type-fest": "^2.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yup/node_modules/type-fest": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"prisma:seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.72.1",
|
||||
"@casl/ability": "^6.8.0",
|
||||
"@casl/prisma": "^1.6.1",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
@@ -35,13 +36,16 @@
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@prisma/client": "^5.8.1",
|
||||
"@types/pdfkit": "^0.17.4",
|
||||
"axios": "^1.6.5",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"ics": "^3.8.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"jwks-rsa": "^3.1.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"reflect-metadata": "^0.1.14",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -51,6 +55,7 @@
|
||||
"@nestjs/testing": "^10.3.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MessageDirection" AS ENUM ('INBOUND', 'OUTBOUND');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "signal_messages" (
|
||||
"id" TEXT NOT NULL,
|
||||
"driverId" TEXT NOT NULL,
|
||||
"direction" "MessageDirection" NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"isRead" BOOLEAN NOT NULL DEFAULT false,
|
||||
"signalTimestamp" TEXT,
|
||||
|
||||
CONSTRAINT "signal_messages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "signal_messages_driverId_idx" ON "signal_messages"("driverId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "signal_messages_driverId_isRead_idx" ON "signal_messages"("driverId", "isRead");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "signal_messages_timestamp_idx" ON "signal_messages"("timestamp");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "signal_messages" ADD CONSTRAINT "signal_messages_driverId_fkey" FOREIGN KEY ("driverId") REFERENCES "drivers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PageSize" AS ENUM ('LETTER', 'A4');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pdf_settings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationName" TEXT NOT NULL DEFAULT 'VIP Coordinator',
|
||||
"logoUrl" TEXT,
|
||||
"accentColor" TEXT NOT NULL DEFAULT '#2c3e50',
|
||||
"tagline" TEXT,
|
||||
"contactEmail" TEXT NOT NULL DEFAULT 'contact@example.com',
|
||||
"contactPhone" TEXT NOT NULL DEFAULT '555-0100',
|
||||
"secondaryContactName" TEXT,
|
||||
"secondaryContactPhone" TEXT,
|
||||
"contactLabel" TEXT NOT NULL DEFAULT 'Questions or Changes?',
|
||||
"showDraftWatermark" BOOLEAN NOT NULL DEFAULT false,
|
||||
"showConfidentialWatermark" BOOLEAN NOT NULL DEFAULT false,
|
||||
"showTimestamp" BOOLEAN NOT NULL DEFAULT true,
|
||||
"showAppUrl" BOOLEAN NOT NULL DEFAULT false,
|
||||
"pageSize" "PageSize" NOT NULL DEFAULT 'LETTER',
|
||||
"showFlightInfo" BOOLEAN NOT NULL DEFAULT true,
|
||||
"showDriverNames" BOOLEAN NOT NULL DEFAULT true,
|
||||
"showVehicleNames" BOOLEAN NOT NULL DEFAULT true,
|
||||
"showVipNotes" BOOLEAN NOT NULL DEFAULT true,
|
||||
"showEventDescriptions" BOOLEAN NOT NULL DEFAULT true,
|
||||
"headerMessage" TEXT,
|
||||
"footerMessage" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "pdf_settings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "schedule_events" ADD COLUMN "reminder20MinSent" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "reminder5MinSent" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "drivers" ALTER COLUMN "phone" DROP NOT NULL;
|
||||
@@ -102,7 +102,7 @@ model Flight {
|
||||
model Driver {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
phone String
|
||||
phone String? // Optional - driver should add via profile
|
||||
department Department?
|
||||
userId String? @unique
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
@@ -114,6 +114,7 @@ model Driver {
|
||||
|
||||
events ScheduleEvent[]
|
||||
assignedVehicle Vehicle? @relation("AssignedDriver")
|
||||
messages SignalMessage[] // Signal chat messages
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -198,6 +199,10 @@ model ScheduleEvent {
|
||||
// Metadata
|
||||
notes String? @db.Text
|
||||
|
||||
// Reminder tracking
|
||||
reminder20MinSent Boolean @default(false)
|
||||
reminder5MinSent Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime? // Soft delete
|
||||
@@ -224,3 +229,77 @@ enum EventStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Signal Messaging
|
||||
// ============================================
|
||||
|
||||
model SignalMessage {
|
||||
id String @id @default(uuid())
|
||||
driverId String
|
||||
driver Driver @relation(fields: [driverId], references: [id], onDelete: Cascade)
|
||||
direction MessageDirection
|
||||
content String @db.Text
|
||||
timestamp DateTime @default(now())
|
||||
isRead Boolean @default(false)
|
||||
signalTimestamp String? // Signal's message timestamp for deduplication
|
||||
|
||||
@@map("signal_messages")
|
||||
@@index([driverId])
|
||||
@@index([driverId, isRead])
|
||||
@@index([timestamp])
|
||||
}
|
||||
|
||||
enum MessageDirection {
|
||||
INBOUND // Message from driver
|
||||
OUTBOUND // Message to driver
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PDF Settings (Singleton)
|
||||
// ============================================
|
||||
|
||||
model PdfSettings {
|
||||
id String @id @default(uuid())
|
||||
|
||||
// Branding
|
||||
organizationName String @default("VIP Coordinator")
|
||||
logoUrl String? @db.Text // Base64 data URL or external URL
|
||||
accentColor String @default("#2c3e50") // Hex color
|
||||
tagline String?
|
||||
|
||||
// Contact Info
|
||||
contactEmail String @default("contact@example.com")
|
||||
contactPhone String @default("555-0100")
|
||||
secondaryContactName String?
|
||||
secondaryContactPhone String?
|
||||
contactLabel String @default("Questions or Changes?")
|
||||
|
||||
// Document Options
|
||||
showDraftWatermark Boolean @default(false)
|
||||
showConfidentialWatermark Boolean @default(false)
|
||||
showTimestamp Boolean @default(true)
|
||||
showAppUrl Boolean @default(false)
|
||||
pageSize PageSize @default(LETTER)
|
||||
|
||||
// Content Toggles
|
||||
showFlightInfo Boolean @default(true)
|
||||
showDriverNames Boolean @default(true)
|
||||
showVehicleNames Boolean @default(true)
|
||||
showVipNotes Boolean @default(true)
|
||||
showEventDescriptions Boolean @default(true)
|
||||
|
||||
// Custom Text
|
||||
headerMessage String? @db.Text
|
||||
footerMessage String? @db.Text
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("pdf_settings")
|
||||
}
|
||||
|
||||
enum PageSize {
|
||||
LETTER
|
||||
A4
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ import { DriversModule } from './drivers/drivers.module';
|
||||
import { VehiclesModule } from './vehicles/vehicles.module';
|
||||
import { EventsModule } from './events/events.module';
|
||||
import { FlightsModule } from './flights/flights.module';
|
||||
import { CopilotModule } from './copilot/copilot.module';
|
||||
import { SignalModule } from './signal/signal.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { SeedModule } from './seed/seed.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
|
||||
@Module({
|
||||
@@ -32,6 +36,10 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
VehiclesModule,
|
||||
EventsModule,
|
||||
FlightsModule,
|
||||
CopilotModule,
|
||||
SignalModule,
|
||||
SettingsModule,
|
||||
SeedModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -25,6 +25,7 @@ export type Subjects =
|
||||
| 'ScheduleEvent'
|
||||
| 'Flight'
|
||||
| 'Vehicle'
|
||||
| 'Settings'
|
||||
| 'all';
|
||||
|
||||
/**
|
||||
|
||||
59
backend/src/copilot/copilot.controller.ts
Normal file
59
backend/src/copilot/copilot.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { Role } from '@prisma/client';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { CopilotService } from './copilot.service';
|
||||
|
||||
interface ChatMessageDto {
|
||||
role: 'user' | 'assistant';
|
||||
content: string | any[];
|
||||
}
|
||||
|
||||
interface ChatRequestDto {
|
||||
messages: ChatMessageDto[];
|
||||
}
|
||||
|
||||
@Controller('copilot')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class CopilotController {
|
||||
private readonly logger = new Logger(CopilotController.name);
|
||||
|
||||
constructor(private readonly copilotService: CopilotService) {}
|
||||
|
||||
@Post('chat')
|
||||
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||
async chat(
|
||||
@Body() body: ChatRequestDto,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
this.logger.log(`Copilot chat request from user: ${user.email}`);
|
||||
|
||||
try {
|
||||
const result = await this.copilotService.chat(
|
||||
body.messages,
|
||||
user.id,
|
||||
user.role,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...result,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Copilot chat error:', error);
|
||||
return {
|
||||
success: false,
|
||||
response: 'I encountered an error processing your request. Please try again.',
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
13
backend/src/copilot/copilot.module.ts
Normal file
13
backend/src/copilot/copilot.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CopilotController } from './copilot.controller';
|
||||
import { CopilotService } from './copilot.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SignalModule } from '../signal/signal.module';
|
||||
import { DriversModule } from '../drivers/drivers.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, SignalModule, DriversModule],
|
||||
controllers: [CopilotController],
|
||||
providers: [CopilotService],
|
||||
})
|
||||
export class CopilotModule {}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DriversController } from './drivers.controller';
|
||||
import { DriversService } from './drivers.service';
|
||||
import { ScheduleExportService } from './schedule-export.service';
|
||||
import { SignalModule } from '../signal/signal.module';
|
||||
|
||||
@Module({
|
||||
imports: [SignalModule],
|
||||
controllers: [DriversController],
|
||||
providers: [DriversService],
|
||||
exports: [DriversService],
|
||||
providers: [DriversService, ScheduleExportService],
|
||||
exports: [DriversService, ScheduleExportService],
|
||||
})
|
||||
export class DriversModule {}
|
||||
|
||||
@@ -52,6 +52,20 @@ export class DriversService {
|
||||
return driver;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string) {
|
||||
return this.prisma.driver.findFirst({
|
||||
where: { userId, deletedAt: null },
|
||||
include: {
|
||||
user: true,
|
||||
events: {
|
||||
where: { deletedAt: null },
|
||||
include: { vehicle: true, driver: true },
|
||||
orderBy: { startTime: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, updateDriverDto: UpdateDriverDto) {
|
||||
const driver = await this.findOne(id);
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ export class CreateDriverDto {
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
phone: string;
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@IsEnum(Department)
|
||||
@IsOptional()
|
||||
|
||||
423
backend/src/events/event-status.service.ts
Normal file
423
backend/src/events/event-status.service.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SignalService } from '../signal/signal.service';
|
||||
import { EventStatus } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Automatic event status management service
|
||||
* - Transitions SCHEDULED → IN_PROGRESS when startTime arrives
|
||||
* - Sends Signal confirmation requests to drivers
|
||||
* - Handles driver responses (1=Confirmed, 2=Delayed, 3=Issue)
|
||||
* - Transitions IN_PROGRESS → COMPLETED when endTime passes (with grace period)
|
||||
*/
|
||||
@Injectable()
|
||||
export class EventStatusService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(EventStatusService.name);
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
private readonly CHECK_INTERVAL = 60 * 1000; // Check every minute
|
||||
private readonly COMPLETION_GRACE_PERIOD = 15 * 60 * 1000; // 15 min after endTime before auto-complete
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private signalService: SignalService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.logger.log('Starting event status monitoring...');
|
||||
this.startMonitoring();
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.stopMonitoring();
|
||||
}
|
||||
|
||||
private startMonitoring() {
|
||||
// Run immediately on start
|
||||
this.checkAndUpdateStatuses();
|
||||
|
||||
// Then run every minute
|
||||
this.intervalId = setInterval(() => {
|
||||
this.checkAndUpdateStatuses();
|
||||
}, this.CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
private stopMonitoring() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
this.logger.log('Stopped event status monitoring');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main check loop - finds events that need status updates
|
||||
*/
|
||||
private async checkAndUpdateStatuses() {
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
// 1. Send reminders for upcoming events (20 min and 5 min before)
|
||||
await this.sendUpcomingReminders(now);
|
||||
|
||||
// 2. Find SCHEDULED events that should now be IN_PROGRESS
|
||||
await this.transitionToInProgress(now);
|
||||
|
||||
// 3. Find IN_PROGRESS events that are past their end time (with grace period)
|
||||
await this.transitionToCompleted(now);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('Error checking event statuses:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send 20-minute and 5-minute reminders to drivers
|
||||
*/
|
||||
private async sendUpcomingReminders(now: Date) {
|
||||
const twentyMinutesFromNow = new Date(now.getTime() + 20 * 60 * 1000);
|
||||
const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000);
|
||||
|
||||
// Find events needing 20-minute reminder
|
||||
// Events starting within 20 minutes that haven't had reminder sent
|
||||
const eventsFor20MinReminder = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
status: EventStatus.SCHEDULED,
|
||||
type: 'TRANSPORT',
|
||||
startTime: { lte: twentyMinutesFromNow, gt: now },
|
||||
reminder20MinSent: false,
|
||||
driverId: { not: null },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const event of eventsFor20MinReminder) {
|
||||
// Only send if actually ~20 min away (between 15-25 min)
|
||||
const minutesUntil = Math.floor((new Date(event.startTime).getTime() - now.getTime()) / 60000);
|
||||
if (minutesUntil <= 25 && minutesUntil >= 15) {
|
||||
await this.send20MinReminder(event, minutesUntil);
|
||||
}
|
||||
}
|
||||
|
||||
// Find events needing 5-minute reminder
|
||||
const eventsFor5MinReminder = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
status: EventStatus.SCHEDULED,
|
||||
type: 'TRANSPORT',
|
||||
startTime: { lte: fiveMinutesFromNow, gt: now },
|
||||
reminder5MinSent: false,
|
||||
driverId: { not: null },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const event of eventsFor5MinReminder) {
|
||||
// Only send if actually ~5 min away (between 3-10 min)
|
||||
const minutesUntil = Math.floor((new Date(event.startTime).getTime() - now.getTime()) / 60000);
|
||||
if (minutesUntil <= 10 && minutesUntil >= 3) {
|
||||
await this.send5MinReminder(event, minutesUntil);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send 20-minute reminder to driver
|
||||
*/
|
||||
private async send20MinReminder(event: any, minutesUntil: number) {
|
||||
try {
|
||||
const linkedNumber = await this.signalService.getLinkedNumber();
|
||||
if (!linkedNumber || !event.driver?.phone) return;
|
||||
|
||||
// Get VIP names
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: event.vipIds || [] } },
|
||||
select: { name: true },
|
||||
});
|
||||
const vipNames = vips.map(v => v.name).join(', ') || 'VIP';
|
||||
|
||||
const message = `📢 UPCOMING TRIP in ~${minutesUntil} minutes
|
||||
|
||||
📍 Pickup: ${event.pickupLocation || 'See schedule'}
|
||||
📍 Dropoff: ${event.dropoffLocation || 'See schedule'}
|
||||
👤 VIP: ${vipNames}
|
||||
🚐 Vehicle: ${event.vehicle?.name || 'Check assignment'}
|
||||
⏰ Start Time: ${new Date(event.startTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
|
||||
Please head to the pickup location.`;
|
||||
|
||||
const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone);
|
||||
await this.signalService.sendMessage(linkedNumber, formattedPhone, message);
|
||||
|
||||
// Mark reminder as sent
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { reminder20MinSent: true },
|
||||
});
|
||||
|
||||
this.logger.log(`Sent 20-min reminder to ${event.driver.name} for event ${event.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send 20-min reminder for event ${event.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send 5-minute reminder to driver (more urgent)
|
||||
*/
|
||||
private async send5MinReminder(event: any, minutesUntil: number) {
|
||||
try {
|
||||
const linkedNumber = await this.signalService.getLinkedNumber();
|
||||
if (!linkedNumber || !event.driver?.phone) return;
|
||||
|
||||
// Get VIP names
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: event.vipIds || [] } },
|
||||
select: { name: true },
|
||||
});
|
||||
const vipNames = vips.map(v => v.name).join(', ') || 'VIP';
|
||||
|
||||
const message = `⚠️ TRIP STARTING in ${minutesUntil} MINUTES!
|
||||
|
||||
📍 Pickup: ${event.pickupLocation || 'See schedule'}
|
||||
👤 VIP: ${vipNames}
|
||||
🚐 Vehicle: ${event.vehicle?.name || 'Check assignment'}
|
||||
|
||||
You should be at the pickup location NOW.
|
||||
|
||||
Reply:
|
||||
1️⃣ = Ready and waiting
|
||||
2️⃣ = Running late
|
||||
3️⃣ = Issue / Need help`;
|
||||
|
||||
const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone);
|
||||
await this.signalService.sendMessage(linkedNumber, formattedPhone, message);
|
||||
|
||||
// Mark reminder as sent
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { reminder5MinSent: true },
|
||||
});
|
||||
|
||||
this.logger.log(`Sent 5-min reminder to ${event.driver.name} for event ${event.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send 5-min reminder for event ${event.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition SCHEDULED → IN_PROGRESS for events whose startTime has passed
|
||||
*/
|
||||
private async transitionToInProgress(now: Date) {
|
||||
const eventsToStart = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
status: EventStatus.SCHEDULED,
|
||||
startTime: { lte: now },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
vehicle: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const event of eventsToStart) {
|
||||
try {
|
||||
// Update status to IN_PROGRESS
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
status: EventStatus.IN_PROGRESS,
|
||||
actualStartTime: now,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Event ${event.id} (${event.title}) auto-started`);
|
||||
|
||||
// Send Signal confirmation request to driver if assigned
|
||||
if (event.driver?.phone) {
|
||||
await this.sendDriverConfirmationRequest(event);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transition event ${event.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventsToStart.length > 0) {
|
||||
this.logger.log(`Auto-started ${eventsToStart.length} events`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition IN_PROGRESS → COMPLETED for events past their endTime + grace period
|
||||
* Only auto-complete if no driver confirmation is pending
|
||||
*/
|
||||
private async transitionToCompleted(now: Date) {
|
||||
const gracePeriodAgo = new Date(now.getTime() - this.COMPLETION_GRACE_PERIOD);
|
||||
|
||||
const eventsToComplete = await this.prisma.scheduleEvent.findMany({
|
||||
where: {
|
||||
status: EventStatus.IN_PROGRESS,
|
||||
endTime: { lte: gracePeriodAgo },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
driver: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const event of eventsToComplete) {
|
||||
try {
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
status: EventStatus.COMPLETED,
|
||||
actualEndTime: now,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Event ${event.id} (${event.title}) auto-completed`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to complete event ${event.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventsToComplete.length > 0) {
|
||||
this.logger.log(`Auto-completed ${eventsToComplete.length} events`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Signal message to the driver asking for confirmation
|
||||
*/
|
||||
private async sendDriverConfirmationRequest(event: any) {
|
||||
try {
|
||||
const linkedNumber = await this.signalService.getLinkedNumber();
|
||||
if (!linkedNumber) {
|
||||
this.logger.warn('No Signal account linked, skipping driver notification');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get VIP names for the message
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
where: { id: { in: event.vipIds || [] } },
|
||||
select: { name: true },
|
||||
});
|
||||
const vipNames = vips.map(v => v.name).join(', ') || 'VIP';
|
||||
|
||||
const message = `🚗 TRIP STARTED: ${event.title}
|
||||
|
||||
📍 Pickup: ${event.pickupLocation || 'See schedule'}
|
||||
📍 Dropoff: ${event.dropoffLocation || 'See schedule'}
|
||||
👤 VIP: ${vipNames}
|
||||
🚐 Vehicle: ${event.vehicle?.name || 'Not assigned'}
|
||||
|
||||
Please confirm status:
|
||||
1️⃣ = En route / Confirmed
|
||||
2️⃣ = Delayed (explain in next message)
|
||||
3️⃣ = Issue / Need help
|
||||
|
||||
Reply with 1, 2, or 3`;
|
||||
|
||||
const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone);
|
||||
await this.signalService.sendMessage(linkedNumber, formattedPhone, message);
|
||||
|
||||
this.logger.log(`Sent confirmation request to driver ${event.driver.name} for event ${event.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send Signal confirmation for event ${event.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a driver's response to a confirmation request
|
||||
* Called by the Signal message handler when a driver replies with 1, 2, or 3
|
||||
*/
|
||||
async processDriverResponse(driverPhone: string, response: string): Promise<string | null> {
|
||||
const responseNum = parseInt(response.trim(), 10);
|
||||
if (![1, 2, 3].includes(responseNum)) {
|
||||
return null; // Not a status response
|
||||
}
|
||||
|
||||
// Find the driver
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: {
|
||||
phone: { contains: driverPhone.replace(/\D/g, '').slice(-10) },
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!driver) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find their current IN_PROGRESS event
|
||||
const activeEvent = await this.prisma.scheduleEvent.findFirst({
|
||||
where: {
|
||||
driverId: driver.id,
|
||||
status: EventStatus.IN_PROGRESS,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: { vehicle: true },
|
||||
});
|
||||
|
||||
if (!activeEvent) {
|
||||
return 'No active trip found. Reply ignored.';
|
||||
}
|
||||
|
||||
let replyMessage: string;
|
||||
|
||||
switch (responseNum) {
|
||||
case 1: // Confirmed
|
||||
// Event is already IN_PROGRESS, this just confirms it
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: activeEvent.id },
|
||||
data: {
|
||||
notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver confirmed en route`.trim(),
|
||||
},
|
||||
});
|
||||
replyMessage = `✅ Confirmed! Safe travels. Reply when completed or if you need assistance.`;
|
||||
break;
|
||||
|
||||
case 2: // Delayed
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: activeEvent.id },
|
||||
data: {
|
||||
notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver reported DELAY`.trim(),
|
||||
},
|
||||
});
|
||||
replyMessage = `⏰ Delay noted. Please reply with details about the delay. Coordinator has been alerted.`;
|
||||
break;
|
||||
|
||||
case 3: // Issue
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: activeEvent.id },
|
||||
data: {
|
||||
notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver reported ISSUE - needs help`.trim(),
|
||||
},
|
||||
});
|
||||
replyMessage = `🚨 Issue reported! A coordinator will contact you shortly. Please describe the issue in your next message.`;
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
// Send the reply
|
||||
try {
|
||||
const linkedNumber = await this.signalService.getLinkedNumber();
|
||||
if (linkedNumber && driver.phone) {
|
||||
const formattedPhone = this.signalService.formatPhoneNumber(driver.phone);
|
||||
await this.signalService.sendMessage(linkedNumber, formattedPhone, replyMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to send reply to driver:', error);
|
||||
}
|
||||
|
||||
return replyMessage;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { EventsController } from './events.controller';
|
||||
import { EventsService } from './events.service';
|
||||
import { EventStatusService } from './event-status.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SignalModule } from '../signal/signal.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
forwardRef(() => SignalModule), // forwardRef to avoid circular dependency
|
||||
],
|
||||
controllers: [
|
||||
EventsController,
|
||||
],
|
||||
providers: [
|
||||
EventsService,
|
||||
EventStatusService,
|
||||
],
|
||||
exports: [
|
||||
EventsService,
|
||||
EventStatusService,
|
||||
],
|
||||
})
|
||||
export class EventsModule {}
|
||||
|
||||
@@ -300,10 +300,11 @@ export class EventsService {
|
||||
|
||||
/**
|
||||
* Enrich event with VIP details fetched separately
|
||||
* Returns both `vips` array and `vip` (first VIP) for backwards compatibility
|
||||
*/
|
||||
private async enrichEventWithVips(event: any) {
|
||||
if (!event.vipIds || event.vipIds.length === 0) {
|
||||
return { ...event, vips: [] };
|
||||
return { ...event, vips: [], vip: null };
|
||||
}
|
||||
|
||||
const vips = await this.prisma.vIP.findMany({
|
||||
@@ -313,6 +314,7 @@ export class EventsService {
|
||||
},
|
||||
});
|
||||
|
||||
return { ...event, vips };
|
||||
// Return both vips array and vip (first one) for backwards compatibility
|
||||
return { ...event, vips, vip: vips[0] || null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { json, urlencoded } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
import { AllExceptionsFilter, HttpExceptionFilter } from './common/filters';
|
||||
|
||||
@@ -8,6 +9,10 @@ async function bootstrap() {
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Increase body size limit for PDF attachments (base64 encoded)
|
||||
app.use(json({ limit: '5mb' }));
|
||||
app.use(urlencoded({ extended: true, limit: '5mb' }));
|
||||
|
||||
// Global prefix for all routes
|
||||
// In production (App Platform), the ingress routes /api to this service
|
||||
// So we only need /v1 prefix here
|
||||
|
||||
3
backend/src/seed/index.ts
Normal file
3
backend/src/seed/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './seed.module';
|
||||
export * from './seed.service';
|
||||
export * from './seed.controller';
|
||||
36
backend/src/seed/seed.controller.ts
Normal file
36
backend/src/seed/seed.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Controller, Post, Delete, UseGuards, Body } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { SeedService } from './seed.service';
|
||||
|
||||
@Controller('seed')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMINISTRATOR')
|
||||
export class SeedController {
|
||||
constructor(private readonly seedService: SeedService) {}
|
||||
|
||||
/**
|
||||
* Generate all test data in a single fast transaction
|
||||
*/
|
||||
@Post('generate')
|
||||
async generateTestData(@Body() options?: { clearFirst?: boolean }) {
|
||||
return this.seedService.generateAllTestData(options?.clearFirst ?? true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all test data instantly
|
||||
*/
|
||||
@Delete('clear')
|
||||
async clearAllData() {
|
||||
return this.seedService.clearAllData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate only events with dynamic times (keeps existing VIPs/drivers/vehicles)
|
||||
*/
|
||||
@Post('generate-events')
|
||||
async generateDynamicEvents() {
|
||||
return this.seedService.generateDynamicEvents();
|
||||
}
|
||||
}
|
||||
12
backend/src/seed/seed.module.ts
Normal file
12
backend/src/seed/seed.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SeedController } from './seed.controller';
|
||||
import { SeedService } from './seed.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [SeedController],
|
||||
providers: [SeedService],
|
||||
})
|
||||
export class SeedModule {}
|
||||
626
backend/src/seed/seed.service.ts
Normal file
626
backend/src/seed/seed.service.ts
Normal file
@@ -0,0 +1,626 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Department, ArrivalMode, EventType, EventStatus, VehicleType, VehicleStatus } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class SeedService {
|
||||
private readonly logger = new Logger(SeedService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Clear all data using fast deleteMany operations
|
||||
*/
|
||||
async clearAllData() {
|
||||
const start = Date.now();
|
||||
|
||||
// Delete in order to respect foreign key constraints
|
||||
const results = await this.prisma.$transaction([
|
||||
this.prisma.signalMessage.deleteMany(),
|
||||
this.prisma.scheduleEvent.deleteMany(),
|
||||
this.prisma.flight.deleteMany(),
|
||||
this.prisma.vehicle.deleteMany(),
|
||||
this.prisma.driver.deleteMany(),
|
||||
this.prisma.vIP.deleteMany(),
|
||||
]);
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
this.logger.log(`Cleared all data in ${elapsed}ms`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
elapsed: `${elapsed}ms`,
|
||||
deleted: {
|
||||
messages: results[0].count,
|
||||
events: results[1].count,
|
||||
flights: results[2].count,
|
||||
vehicles: results[3].count,
|
||||
drivers: results[4].count,
|
||||
vips: results[5].count,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all test data in a single fast transaction
|
||||
*/
|
||||
async generateAllTestData(clearFirst: boolean = true) {
|
||||
const start = Date.now();
|
||||
|
||||
if (clearFirst) {
|
||||
await this.clearAllData();
|
||||
}
|
||||
|
||||
// Create all entities in a transaction
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
// 1. Create VIPs
|
||||
const vipData = this.getVIPData();
|
||||
await tx.vIP.createMany({ data: vipData });
|
||||
const vips = await tx.vIP.findMany({ orderBy: { createdAt: 'asc' } });
|
||||
this.logger.log(`Created ${vips.length} VIPs`);
|
||||
|
||||
// 2. Create Drivers with shifts
|
||||
const driverData = this.getDriverData();
|
||||
await tx.driver.createMany({ data: driverData });
|
||||
const drivers = await tx.driver.findMany({ orderBy: { createdAt: 'asc' } });
|
||||
this.logger.log(`Created ${drivers.length} drivers`);
|
||||
|
||||
// 3. Create Vehicles
|
||||
const vehicleData = this.getVehicleData();
|
||||
await tx.vehicle.createMany({ data: vehicleData });
|
||||
const vehicles = await tx.vehicle.findMany({ orderBy: { createdAt: 'asc' } });
|
||||
this.logger.log(`Created ${vehicles.length} vehicles`);
|
||||
|
||||
// 4. Create Flights for VIPs arriving by flight
|
||||
const flightVips = vips.filter(v => v.arrivalMode === 'FLIGHT');
|
||||
const flightData = this.getFlightData(flightVips);
|
||||
await tx.flight.createMany({ data: flightData });
|
||||
const flights = await tx.flight.findMany();
|
||||
this.logger.log(`Created ${flights.length} flights`);
|
||||
|
||||
// 5. Create Events with dynamic times relative to NOW
|
||||
const eventData = this.getEventData(vips, drivers, vehicles);
|
||||
await tx.scheduleEvent.createMany({ data: eventData });
|
||||
const events = await tx.scheduleEvent.findMany();
|
||||
this.logger.log(`Created ${events.length} events`);
|
||||
|
||||
return { vips, drivers, vehicles, flights, events };
|
||||
});
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
this.logger.log(`Generated all test data in ${elapsed}ms`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
elapsed: `${elapsed}ms`,
|
||||
created: {
|
||||
vips: result.vips.length,
|
||||
drivers: result.drivers.length,
|
||||
vehicles: result.vehicles.length,
|
||||
flights: result.flights.length,
|
||||
events: result.events.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate only dynamic events (uses existing VIPs/drivers/vehicles)
|
||||
*/
|
||||
async generateDynamicEvents() {
|
||||
const start = Date.now();
|
||||
|
||||
// Clear existing events
|
||||
await this.prisma.scheduleEvent.deleteMany();
|
||||
|
||||
// Get existing entities
|
||||
const [vips, drivers, vehicles] = await Promise.all([
|
||||
this.prisma.vIP.findMany({ where: { deletedAt: null } }),
|
||||
this.prisma.driver.findMany({ where: { deletedAt: null } }),
|
||||
this.prisma.vehicle.findMany({ where: { deletedAt: null } }),
|
||||
]);
|
||||
|
||||
if (vips.length === 0) {
|
||||
return { success: false, error: 'No VIPs found. Generate full test data first.' };
|
||||
}
|
||||
|
||||
// Create events
|
||||
const eventData = this.getEventData(vips, drivers, vehicles);
|
||||
await this.prisma.scheduleEvent.createMany({ data: eventData });
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
return {
|
||||
success: true,
|
||||
elapsed: `${elapsed}ms`,
|
||||
created: { events: eventData.length },
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DATA GENERATORS
|
||||
// ============================================================
|
||||
|
||||
private getVIPData() {
|
||||
return [
|
||||
// OFFICE_OF_DEVELOPMENT (10 VIPs) - Corporate sponsors, foundations, major donors
|
||||
{ name: 'Sarah Chen', organization: 'Microsoft Corporation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Executive VP - prefers quiet vehicles. Allergic to peanuts.' },
|
||||
{ name: 'Marcus Johnson', organization: 'The Coca-Cola Company', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Bringing spouse. Needs wheelchair accessible transport.' },
|
||||
{ name: 'Jennifer Wu', organization: 'JPMorgan Chase Foundation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Major donor - $500K pledge. VIP treatment essential.' },
|
||||
{ name: 'Roberto Gonzalez', organization: 'AT&T Inc', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'First time visitor. Interested in STEM programs.' },
|
||||
{ name: 'Priya Sharma', organization: 'Google LLC', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Vegetarian meals required. Interested in technology merit badges.' },
|
||||
{ name: 'David Okonkwo', organization: 'Bank of America', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: false, venueTransport: true, notes: 'Has rental car for airport. Needs venue transport only.' },
|
||||
{ name: 'Maria Rodriguez', organization: 'Walmart Foundation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(120), notes: 'Driving from nearby hotel. Call when 30 min out.' },
|
||||
{ name: 'Yuki Tanaka', organization: 'Honda Motor Company', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Japanese executive - interpreter may be needed.' },
|
||||
{ name: 'Thomas Anderson', organization: 'Verizon Communications', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: false, notes: 'Will use personal driver after airport pickup.' },
|
||||
{ name: 'Isabella Costa', organization: 'Target Corporation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: false, venueTransport: true, notes: 'Taking rideshare from airport. Venue transport needed.' },
|
||||
|
||||
// ADMIN (10 VIPs) - BSA Leadership and Staff
|
||||
{ name: 'Roger A. Krone', organization: 'BSA National President', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'HIGHEST PRIORITY VIP. Security detail traveling with him.' },
|
||||
{ name: 'Emily Richardson', organization: 'BSA Chief Scout Executive', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: false, expectedArrival: this.relativeTime(60), notes: 'Has assigned BSA vehicle. No transport needed.' },
|
||||
{ name: 'Dr. Maya Krishnan', organization: 'BSA National Director of Program', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: false, expectedArrival: this.relativeTime(180), notes: 'Carpooling with regional directors.' },
|
||||
{ name: "James O'Brien", organization: 'BSA Northeast Regional Director', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Traveling with 2 staff members.' },
|
||||
{ name: 'Fatima Al-Rahman', organization: 'BSA Western Region Executive', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Halal meals required. Prayer room access needed.' },
|
||||
{ name: 'William Zhang', organization: 'BSA Southern Region Council', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Bringing presentation equipment - need vehicle with cargo space.' },
|
||||
{ name: 'Sophie Laurent', organization: 'BSA National Volunteer Training', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(240), notes: 'Training materials in personal vehicle.' },
|
||||
{ name: 'Alexander Volkov', organization: 'BSA High Adventure Director', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: false, notes: 'Outdoor enthusiast - prefers walking when possible.' },
|
||||
{ name: 'Dr. Aisha Patel', organization: 'BSA STEM & Innovation Programs', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(90), notes: 'Demo equipment for STEM showcase. Fragile items!' },
|
||||
{ name: 'Henrik Larsson', organization: 'BSA International Commissioner', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(150), notes: 'Visiting from Sweden. International guest protocols apply.' },
|
||||
];
|
||||
}
|
||||
|
||||
private getDriverData() {
|
||||
const now = new Date();
|
||||
const shiftStart = new Date(now);
|
||||
shiftStart.setHours(6, 0, 0, 0);
|
||||
const shiftEnd = new Date(now);
|
||||
shiftEnd.setHours(22, 0, 0, 0);
|
||||
|
||||
const lateShiftStart = new Date(now);
|
||||
lateShiftStart.setHours(14, 0, 0, 0);
|
||||
const lateShiftEnd = new Date(now);
|
||||
lateShiftEnd.setHours(23, 59, 0, 0);
|
||||
|
||||
return [
|
||||
{ name: 'Michael Thompson', phone: '555-0101', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
|
||||
{ name: 'Lisa Martinez', phone: '555-0102', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
|
||||
{ name: 'David Kim', phone: '555-0103', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
|
||||
{ name: 'Amanda Washington', phone: '555-0104', department: Department.ADMIN, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
|
||||
{ name: 'Carlos Hernandez', phone: '555-0105', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: false, shiftStartTime: lateShiftStart, shiftEndTime: lateShiftEnd }, // Off duty until 2pm
|
||||
{ name: 'Jessica Lee', phone: '555-0106', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
|
||||
{ name: 'Brandon Jackson', phone: '555-0107', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: lateShiftStart, shiftEndTime: lateShiftEnd },
|
||||
{ name: 'Nicole Brown', phone: '555-0108', department: Department.ADMIN, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
|
||||
];
|
||||
}
|
||||
|
||||
private getVehicleData() {
|
||||
return [
|
||||
{ name: 'Blue Van', type: VehicleType.VAN, licensePlate: 'VAN-001', seatCapacity: 12, status: VehicleStatus.AVAILABLE, notes: 'Primary transport van with wheelchair accessibility' },
|
||||
{ name: 'Suburban #1', type: VehicleType.SUV, licensePlate: 'SUV-101', seatCapacity: 7, status: VehicleStatus.AVAILABLE, notes: 'Leather interior, ideal for VIP comfort' },
|
||||
{ name: 'Golf Cart Alpha', type: VehicleType.GOLF_CART, licensePlate: 'GC-A', seatCapacity: 6, status: VehicleStatus.AVAILABLE, notes: 'Quick campus transport, good for short distances' },
|
||||
{ name: 'Red Van', type: VehicleType.VAN, licensePlate: 'VAN-002', seatCapacity: 8, status: VehicleStatus.AVAILABLE, notes: 'Standard transport van' },
|
||||
{ name: 'Scout Bus', type: VehicleType.BUS, licensePlate: 'BUS-001', seatCapacity: 25, status: VehicleStatus.AVAILABLE, notes: 'Large group transport, AC equipped' },
|
||||
{ name: 'Suburban #2', type: VehicleType.SUV, licensePlate: 'SUV-102', seatCapacity: 7, status: VehicleStatus.AVAILABLE, notes: 'Backup VIP transport' },
|
||||
{ name: 'Golf Cart Bravo', type: VehicleType.GOLF_CART, licensePlate: 'GC-B', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Quick on-site transport' },
|
||||
{ name: 'Equipment Truck', type: VehicleType.TRUCK, licensePlate: 'TRK-001', seatCapacity: 3, status: VehicleStatus.MAINTENANCE, notes: 'For equipment and supply runs - currently in maintenance' },
|
||||
{ name: 'Executive Sedan', type: VehicleType.SEDAN, licensePlate: 'SED-001', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Premium sedan for executive VIPs' },
|
||||
{ name: 'Golf Cart Charlie', type: VehicleType.GOLF_CART, licensePlate: 'GC-C', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Backup golf cart' },
|
||||
];
|
||||
}
|
||||
|
||||
private getFlightData(vips: any[]) {
|
||||
const flights: any[] = [];
|
||||
const airlines = ['AA', 'UA', 'DL', 'SW', 'AS', 'JB'];
|
||||
const origins = ['JFK', 'LAX', 'ORD', 'DFW', 'ATL', 'SFO', 'SEA', 'BOS', 'DEN', 'MIA'];
|
||||
const destination = 'SLC'; // Assuming Salt Lake City for the Jamboree
|
||||
|
||||
vips.forEach((vip, index) => {
|
||||
const airline = airlines[index % airlines.length];
|
||||
const flightNum = `${airline}${1000 + index * 123}`;
|
||||
const origin = origins[index % origins.length];
|
||||
|
||||
// Arrival flight - times relative to now
|
||||
const arrivalOffset = (index % 8) * 30 - 60; // -60 to +150 minutes from now
|
||||
const scheduledArrival = this.relativeTime(arrivalOffset);
|
||||
const scheduledDeparture = new Date(scheduledArrival.getTime() - 3 * 60 * 60 * 1000); // 3 hours before
|
||||
|
||||
// Some flights are delayed, some landed, some on time
|
||||
let status = 'scheduled';
|
||||
let actualArrival = null;
|
||||
if (arrivalOffset < -30) {
|
||||
status = 'landed';
|
||||
actualArrival = new Date(scheduledArrival.getTime() + (Math.random() * 20 - 10) * 60000);
|
||||
} else if (arrivalOffset < 0) {
|
||||
status = 'landing';
|
||||
} else if (index % 5 === 0) {
|
||||
status = 'delayed';
|
||||
}
|
||||
|
||||
flights.push({
|
||||
vipId: vip.id,
|
||||
flightNumber: flightNum,
|
||||
flightDate: new Date(),
|
||||
segment: 1,
|
||||
departureAirport: origin,
|
||||
arrivalAirport: destination,
|
||||
scheduledDeparture,
|
||||
scheduledArrival,
|
||||
actualArrival,
|
||||
status,
|
||||
});
|
||||
|
||||
// Some VIPs have connecting flights (segment 2)
|
||||
if (index % 4 === 0) {
|
||||
const connectOrigin = origins[(index + 3) % origins.length];
|
||||
flights.push({
|
||||
vipId: vip.id,
|
||||
flightNumber: `${airline}${500 + index}`,
|
||||
flightDate: new Date(),
|
||||
segment: 2,
|
||||
departureAirport: connectOrigin,
|
||||
arrivalAirport: origin,
|
||||
scheduledDeparture: new Date(scheduledDeparture.getTime() - 4 * 60 * 60 * 1000),
|
||||
scheduledArrival: new Date(scheduledDeparture.getTime() - 1 * 60 * 60 * 1000),
|
||||
status: 'landed',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return flights;
|
||||
}
|
||||
|
||||
private getEventData(vips: any[], drivers: any[], vehicles: any[]) {
|
||||
const events: any[] = [];
|
||||
const now = new Date();
|
||||
|
||||
// Track vehicle assignments to avoid conflicts
|
||||
// Map of vehicleId -> array of { start: Date, end: Date }
|
||||
const vehicleSchedule: Map<string, Array<{ start: Date; end: Date }>> = new Map();
|
||||
const driverSchedule: Map<string, Array<{ start: Date; end: Date }>> = new Map();
|
||||
|
||||
// Initialize schedules
|
||||
vehicles.forEach(v => vehicleSchedule.set(v.id, []));
|
||||
drivers.forEach(d => driverSchedule.set(d.id, []));
|
||||
|
||||
// Check if a time slot conflicts with existing assignments
|
||||
const hasConflict = (schedule: Array<{ start: Date; end: Date }>, start: Date, end: Date): boolean => {
|
||||
return schedule.some(slot =>
|
||||
(start < slot.end && end > slot.start) // Overlapping
|
||||
);
|
||||
};
|
||||
|
||||
// Find an available vehicle for a time slot
|
||||
const findAvailableVehicle = (start: Date, end: Date, preferredIndex: number): any | null => {
|
||||
if (vehicles.length === 0) return null;
|
||||
|
||||
// Try preferred vehicle first
|
||||
const preferred = vehicles[preferredIndex % vehicles.length];
|
||||
const preferredSchedule = vehicleSchedule.get(preferred.id) || [];
|
||||
if (!hasConflict(preferredSchedule, start, end)) {
|
||||
preferredSchedule.push({ start, end });
|
||||
return preferred;
|
||||
}
|
||||
|
||||
// Try other vehicles
|
||||
for (const vehicle of vehicles) {
|
||||
const schedule = vehicleSchedule.get(vehicle.id) || [];
|
||||
if (!hasConflict(schedule, start, end)) {
|
||||
schedule.push({ start, end });
|
||||
return vehicle;
|
||||
}
|
||||
}
|
||||
return null; // No available vehicle
|
||||
};
|
||||
|
||||
// Find an available driver for a time slot
|
||||
const findAvailableDriver = (start: Date, end: Date, preferredIndex: number): any | null => {
|
||||
if (drivers.length === 0) return null;
|
||||
|
||||
// Try preferred driver first
|
||||
const preferred = drivers[preferredIndex % drivers.length];
|
||||
const preferredSchedule = driverSchedule.get(preferred.id) || [];
|
||||
if (!hasConflict(preferredSchedule, start, end)) {
|
||||
preferredSchedule.push({ start, end });
|
||||
return preferred;
|
||||
}
|
||||
|
||||
// Try other drivers
|
||||
for (const driver of drivers) {
|
||||
const schedule = driverSchedule.get(driver.id) || [];
|
||||
if (!hasConflict(schedule, start, end)) {
|
||||
schedule.push({ start, end });
|
||||
return driver;
|
||||
}
|
||||
}
|
||||
return null; // No available driver
|
||||
};
|
||||
|
||||
vips.forEach((vip, vipIndex) => {
|
||||
// ============================================================
|
||||
// CREATE VARIED EVENTS RELATIVE TO NOW
|
||||
// ============================================================
|
||||
|
||||
// Event pattern based on VIP index to create variety:
|
||||
// - Some VIPs have events IN_PROGRESS
|
||||
// - Some have events starting VERY soon (5-15 min)
|
||||
// - Some have events starting soon (30-60 min)
|
||||
// - Some have just-completed events
|
||||
// - All have future events throughout the day
|
||||
|
||||
const eventPattern = vipIndex % 5;
|
||||
|
||||
switch (eventPattern) {
|
||||
case 0: { // IN_PROGRESS airport pickup
|
||||
const start = this.relativeTime(-25);
|
||||
const end = this.relativeTime(15);
|
||||
const driver = findAvailableDriver(start, end, vipIndex);
|
||||
const vehicle = findAvailableVehicle(start, end, vipIndex);
|
||||
events.push({
|
||||
vipIds: [vip.id],
|
||||
driverId: driver?.id,
|
||||
vehicleId: vehicle?.id,
|
||||
title: `Airport Pickup - ${vip.name}`,
|
||||
type: EventType.TRANSPORT,
|
||||
status: EventStatus.IN_PROGRESS,
|
||||
pickupLocation: 'Airport - Terminal B',
|
||||
dropoffLocation: 'Main Gate Registration',
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
actualStartTime: this.relativeTime(-23),
|
||||
description: `ACTIVE: Driver en route with ${vip.name} from airport`,
|
||||
notes: 'VIP collected from arrivals. ETA 15 minutes.',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 1: { // COMPLETED event
|
||||
const start = this.relativeTime(-90);
|
||||
const end = this.relativeTime(-45);
|
||||
const driver = findAvailableDriver(start, end, vipIndex);
|
||||
const vehicle = findAvailableVehicle(start, end, vipIndex);
|
||||
events.push({
|
||||
vipIds: [vip.id],
|
||||
driverId: driver?.id,
|
||||
vehicleId: vehicle?.id,
|
||||
title: `Airport Pickup - ${vip.name}`,
|
||||
type: EventType.TRANSPORT,
|
||||
status: EventStatus.COMPLETED,
|
||||
pickupLocation: 'Airport - Terminal A',
|
||||
dropoffLocation: 'VIP Lodge',
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
actualStartTime: this.relativeTime(-88),
|
||||
actualEndTime: this.relativeTime(-42),
|
||||
description: `Completed pickup for ${vip.name}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 2: { // Starting in 5-10 minutes (URGENT)
|
||||
const start = this.relativeTime(7);
|
||||
const end = this.relativeTime(22);
|
||||
const driver = findAvailableDriver(start, end, vipIndex);
|
||||
const vehicle = findAvailableVehicle(start, end, vipIndex);
|
||||
events.push({
|
||||
vipIds: [vip.id],
|
||||
driverId: driver?.id,
|
||||
vehicleId: vehicle?.id,
|
||||
title: `URGENT: Transport to Opening Ceremony - ${vip.name}`,
|
||||
type: EventType.TRANSPORT,
|
||||
status: EventStatus.SCHEDULED,
|
||||
pickupLocation: 'VIP Lodge',
|
||||
dropoffLocation: 'Main Arena - VIP Entrance',
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
description: `Pick up ${vip.name} for Opening Ceremony - STARTS SOON!`,
|
||||
notes: 'Driver should be at pickup location NOW',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 3: { // Starting in 30-45 min
|
||||
const start = this.relativeTime(35);
|
||||
const end = this.relativeTime(50);
|
||||
const driver = findAvailableDriver(start, end, vipIndex);
|
||||
const vehicle = findAvailableVehicle(start, end, vipIndex);
|
||||
events.push({
|
||||
vipIds: [vip.id],
|
||||
driverId: driver?.id,
|
||||
vehicleId: vehicle?.id,
|
||||
title: `VIP Lodge Transfer - ${vip.name}`,
|
||||
type: EventType.TRANSPORT,
|
||||
status: EventStatus.SCHEDULED,
|
||||
pickupLocation: 'Registration Tent',
|
||||
dropoffLocation: 'VIP Lodge - Building A',
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
description: `Transfer ${vip.name} to VIP accommodation after registration`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 4: // In-progress MEETING (no driver/vehicle needed)
|
||||
events.push({
|
||||
vipIds: [vip.id],
|
||||
title: `Donor Briefing Meeting`,
|
||||
type: EventType.MEETING,
|
||||
status: EventStatus.IN_PROGRESS,
|
||||
location: 'Conference Center - Room 101',
|
||||
startTime: this.relativeTime(-20),
|
||||
endTime: this.relativeTime(25),
|
||||
actualStartTime: this.relativeTime(-18),
|
||||
description: `${vip.name} in donor briefing with development team`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ADD STANDARD DAY EVENTS FOR ALL VIPS
|
||||
// ============================================================
|
||||
|
||||
// Upcoming meal (1-2 hours out) - no driver/vehicle needed
|
||||
events.push({
|
||||
vipIds: [vip.id],
|
||||
title: vipIndex % 2 === 0 ? 'VIP Luncheon' : 'VIP Breakfast',
|
||||
type: EventType.MEAL,
|
||||
status: EventStatus.SCHEDULED,
|
||||
location: 'VIP Dining Pavilion',
|
||||
startTime: this.relativeTime(60 + (vipIndex % 4) * 15),
|
||||
endTime: this.relativeTime(120 + (vipIndex % 4) * 15),
|
||||
description: `Catered meal for ${vip.name} with other VIP guests`,
|
||||
});
|
||||
|
||||
// Transport to main event (2-3 hours out)
|
||||
{
|
||||
const start = this.relativeTime(150 + vipIndex * 5);
|
||||
const end = this.relativeTime(165 + vipIndex * 5);
|
||||
const driver = findAvailableDriver(start, end, vipIndex + 3);
|
||||
const vehicle = findAvailableVehicle(start, end, vipIndex + 2);
|
||||
events.push({
|
||||
vipIds: [vip.id],
|
||||
driverId: driver?.id,
|
||||
vehicleId: vehicle?.id,
|
||||
title: `Transport to Scout Exhibition`,
|
||||
type: EventType.TRANSPORT,
|
||||
status: EventStatus.SCHEDULED,
|
||||
pickupLocation: 'VIP Lodge',
|
||||
dropoffLocation: 'Exhibition Grounds',
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
description: `Transport ${vip.name} to Scout Exhibition area`,
|
||||
});
|
||||
}
|
||||
|
||||
// Main event (3-4 hours out) - no driver/vehicle needed
|
||||
events.push({
|
||||
vipIds: [vip.id],
|
||||
title: 'Scout Skills Exhibition',
|
||||
type: EventType.EVENT,
|
||||
status: EventStatus.SCHEDULED,
|
||||
location: 'Exhibition Grounds - Zone A',
|
||||
startTime: this.relativeTime(180 + vipIndex * 3),
|
||||
endTime: this.relativeTime(270 + vipIndex * 3),
|
||||
description: `${vip.name} tours Scout exhibitions and demonstrations`,
|
||||
});
|
||||
|
||||
// Evening dinner (5-6 hours out) - no driver/vehicle needed
|
||||
events.push({
|
||||
vipIds: [vip.id],
|
||||
title: 'Gala Dinner',
|
||||
type: EventType.MEAL,
|
||||
status: EventStatus.SCHEDULED,
|
||||
location: 'Grand Ballroom',
|
||||
startTime: this.relativeTime(360),
|
||||
endTime: this.relativeTime(480),
|
||||
description: `Black-tie dinner event with ${vip.name} and other distinguished guests`,
|
||||
});
|
||||
|
||||
// Next day departure (tomorrow morning)
|
||||
if (vip.arrivalMode === 'FLIGHT') {
|
||||
const start = this.relativeTime(60 * 24 + 120 + vipIndex * 20);
|
||||
const end = this.relativeTime(60 * 24 + 165 + vipIndex * 20);
|
||||
const driver = findAvailableDriver(start, end, vipIndex + 1);
|
||||
const vehicle = findAvailableVehicle(start, end, vipIndex + 1);
|
||||
events.push({
|
||||
vipIds: [vip.id],
|
||||
driverId: driver?.id,
|
||||
vehicleId: vehicle?.id,
|
||||
title: `Airport Departure - ${vip.name}`,
|
||||
type: EventType.TRANSPORT,
|
||||
status: EventStatus.SCHEDULED,
|
||||
pickupLocation: 'VIP Lodge',
|
||||
dropoffLocation: 'Airport - Departures',
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
description: `Transport ${vip.name} to airport for departure flight`,
|
||||
notes: 'Confirm flight status before pickup',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// ADD MULTI-VIP GROUP EVENTS
|
||||
// ============================================================
|
||||
|
||||
if (vips.length >= 4) {
|
||||
// Group transport with multiple VIPs
|
||||
{
|
||||
const start = this.relativeTime(45);
|
||||
const end = this.relativeTime(60);
|
||||
const driver = findAvailableDriver(start, end, 0);
|
||||
// Find a large vehicle (bus or van with capacity >= 8)
|
||||
const largeVehicle = vehicles.find(v =>
|
||||
v.seatCapacity >= 8 && !hasConflict(vehicleSchedule.get(v.id) || [], start, end)
|
||||
);
|
||||
if (largeVehicle) {
|
||||
vehicleSchedule.get(largeVehicle.id)?.push({ start, end });
|
||||
}
|
||||
events.push({
|
||||
vipIds: [vips[0].id, vips[1].id, vips[2].id],
|
||||
driverId: driver?.id,
|
||||
vehicleId: largeVehicle?.id,
|
||||
title: 'Group Transport - Leadership Briefing',
|
||||
type: EventType.TRANSPORT,
|
||||
status: EventStatus.SCHEDULED,
|
||||
pickupLocation: 'VIP Lodge - Main Entrance',
|
||||
dropoffLocation: 'National HQ Building',
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
description: 'Multi-VIP transport for leadership briefing session',
|
||||
notes: 'IMPORTANT: Picking up 3 VIPs - use large vehicle',
|
||||
});
|
||||
}
|
||||
|
||||
// Another group event
|
||||
if (vips.length >= 5) {
|
||||
const start = this.relativeTime(90);
|
||||
const end = this.relativeTime(110);
|
||||
const driver = findAvailableDriver(start, end, 1);
|
||||
const vehicle = findAvailableVehicle(start, end, 1);
|
||||
events.push({
|
||||
vipIds: [vips[3].id, vips[4].id],
|
||||
driverId: driver?.id,
|
||||
vehicleId: vehicle?.id,
|
||||
title: 'Group Transport - Media Tour',
|
||||
type: EventType.TRANSPORT,
|
||||
status: EventStatus.SCHEDULED,
|
||||
pickupLocation: 'Media Center',
|
||||
dropoffLocation: 'Historical Site',
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
description: 'VIP media tour with photo opportunities',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ADD SOME CANCELLED EVENTS FOR REALISM
|
||||
// ============================================================
|
||||
|
||||
if (vips.length >= 6) {
|
||||
events.push({
|
||||
vipIds: [vips[5].id],
|
||||
title: 'Private Meeting - CANCELLED',
|
||||
type: EventType.MEETING,
|
||||
status: EventStatus.CANCELLED,
|
||||
location: 'Conference Room B',
|
||||
startTime: this.relativeTime(200),
|
||||
endTime: this.relativeTime(260),
|
||||
description: 'Meeting cancelled due to schedule conflict',
|
||||
notes: 'VIP requested reschedule for tomorrow',
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HELPER METHODS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get a date relative to now
|
||||
* @param minutesOffset - Minutes from now (negative = past, positive = future)
|
||||
*/
|
||||
private relativeTime(minutesOffset: number): Date {
|
||||
return new Date(Date.now() + minutesOffset * 60 * 1000);
|
||||
}
|
||||
}
|
||||
105
backend/src/settings/dto/update-pdf-settings.dto.ts
Normal file
105
backend/src/settings/dto/update-pdf-settings.dto.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
IsString,
|
||||
IsEmail,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsHexColor,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { PageSize } from '@prisma/client';
|
||||
|
||||
export class UpdatePdfSettingsDto {
|
||||
// Branding
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
organizationName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsHexColor()
|
||||
accentColor?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
tagline?: string;
|
||||
|
||||
// Contact Info
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
contactEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
contactPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
secondaryContactName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
secondaryContactPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
contactLabel?: string;
|
||||
|
||||
// Document Options
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showDraftWatermark?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showConfidentialWatermark?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showTimestamp?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showAppUrl?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(PageSize)
|
||||
pageSize?: PageSize;
|
||||
|
||||
// Content Toggles
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showFlightInfo?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showDriverNames?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showVehicleNames?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showVipNotes?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showEventDescriptions?: boolean;
|
||||
|
||||
// Custom Text
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
headerMessage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
footerMessage?: string;
|
||||
}
|
||||
61
backend/src/settings/settings.controller.ts
Normal file
61
backend/src/settings/settings.controller.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
ParseFilePipe,
|
||||
MaxFileSizeValidator,
|
||||
FileTypeValidator,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { UpdatePdfSettingsDto } from './dto/update-pdf-settings.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AbilitiesGuard } from '../auth/guards/abilities.guard';
|
||||
import { CanUpdate } from '../auth/decorators/check-ability.decorator';
|
||||
|
||||
@Controller('settings')
|
||||
@UseGuards(JwtAuthGuard, AbilitiesGuard)
|
||||
export class SettingsController {
|
||||
constructor(private readonly settingsService: SettingsService) {}
|
||||
|
||||
@Get('pdf')
|
||||
@CanUpdate('Settings') // Admin-only (Settings subject is admin-only)
|
||||
getPdfSettings() {
|
||||
return this.settingsService.getPdfSettings();
|
||||
}
|
||||
|
||||
@Patch('pdf')
|
||||
@CanUpdate('Settings')
|
||||
updatePdfSettings(@Body() dto: UpdatePdfSettingsDto) {
|
||||
return this.settingsService.updatePdfSettings(dto);
|
||||
}
|
||||
|
||||
@Post('pdf/logo')
|
||||
@CanUpdate('Settings')
|
||||
@UseInterceptors(FileInterceptor('logo'))
|
||||
uploadLogo(
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [
|
||||
new MaxFileSizeValidator({ maxSize: 2 * 1024 * 1024 }), // 2MB
|
||||
new FileTypeValidator({ fileType: /(png|jpeg|jpg|svg\+xml)/ }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
) {
|
||||
return this.settingsService.uploadLogo(file);
|
||||
}
|
||||
|
||||
@Delete('pdf/logo')
|
||||
@CanUpdate('Settings')
|
||||
deleteLogo() {
|
||||
return this.settingsService.deleteLogo();
|
||||
}
|
||||
}
|
||||
13
backend/src/settings/settings.module.ts
Normal file
13
backend/src/settings/settings.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SettingsController } from './settings.controller';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [SettingsController],
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
139
backend/src/settings/settings.service.ts
Normal file
139
backend/src/settings/settings.service.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UpdatePdfSettingsDto } from './dto/update-pdf-settings.dto';
|
||||
import { PdfSettings } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
private readonly logger = new Logger(SettingsService.name);
|
||||
private readonly MAX_LOGO_SIZE = 2 * 1024 * 1024; // 2MB in bytes
|
||||
private readonly ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml'];
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Get PDF settings - creates default if none exist (singleton pattern)
|
||||
*/
|
||||
async getPdfSettings(): Promise<PdfSettings> {
|
||||
this.logger.log('Fetching PDF settings');
|
||||
|
||||
let settings = await this.prisma.pdfSettings.findFirst();
|
||||
|
||||
if (!settings) {
|
||||
this.logger.log('No settings found, creating defaults');
|
||||
settings = await this.prisma.pdfSettings.create({
|
||||
data: {
|
||||
organizationName: 'VIP Coordinator',
|
||||
accentColor: '#2c3e50',
|
||||
contactEmail: 'contact@example.com',
|
||||
contactPhone: '555-0100',
|
||||
contactLabel: 'Questions or Changes?',
|
||||
pageSize: 'LETTER',
|
||||
showDraftWatermark: false,
|
||||
showConfidentialWatermark: false,
|
||||
showTimestamp: true,
|
||||
showAppUrl: false,
|
||||
showFlightInfo: true,
|
||||
showDriverNames: true,
|
||||
showVehicleNames: true,
|
||||
showVipNotes: true,
|
||||
showEventDescriptions: true,
|
||||
},
|
||||
});
|
||||
this.logger.log(`Created default settings: ${settings.id}`);
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update PDF settings
|
||||
*/
|
||||
async updatePdfSettings(dto: UpdatePdfSettingsDto): Promise<PdfSettings> {
|
||||
this.logger.log('Updating PDF settings');
|
||||
|
||||
// Get existing settings (or create if none exist)
|
||||
const existing = await this.getPdfSettings();
|
||||
|
||||
try {
|
||||
const updated = await this.prisma.pdfSettings.update({
|
||||
where: { id: existing.id },
|
||||
data: dto,
|
||||
});
|
||||
|
||||
this.logger.log(`Settings updated: ${updated.id}`);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update settings: ${error.message}`);
|
||||
throw new InternalServerErrorException('Failed to update PDF settings');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload logo as base64 data URL
|
||||
*/
|
||||
async uploadLogo(file: Express.Multer.File): Promise<PdfSettings> {
|
||||
this.logger.log(`Uploading logo: ${file.originalname} (${file.size} bytes)`);
|
||||
|
||||
// Validate file size
|
||||
if (file.size > this.MAX_LOGO_SIZE) {
|
||||
throw new BadRequestException(
|
||||
`Logo file too large. Maximum size is ${this.MAX_LOGO_SIZE / 1024 / 1024}MB`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate MIME type
|
||||
if (!this.ALLOWED_MIME_TYPES.includes(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
`Invalid file type. Allowed types: PNG, JPG, SVG`,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert to base64 data URL
|
||||
const base64 = file.buffer.toString('base64');
|
||||
const dataUrl = `data:${file.mimetype};base64,${base64}`;
|
||||
|
||||
// Get existing settings
|
||||
const existing = await this.getPdfSettings();
|
||||
|
||||
try {
|
||||
const updated = await this.prisma.pdfSettings.update({
|
||||
where: { id: existing.id },
|
||||
data: { logoUrl: dataUrl },
|
||||
});
|
||||
|
||||
this.logger.log(`Logo uploaded: ${file.originalname}`);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to upload logo: ${error.message}`);
|
||||
throw new InternalServerErrorException('Failed to upload logo');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete logo
|
||||
*/
|
||||
async deleteLogo(): Promise<PdfSettings> {
|
||||
this.logger.log('Deleting logo');
|
||||
|
||||
const existing = await this.getPdfSettings();
|
||||
|
||||
try {
|
||||
const updated = await this.prisma.pdfSettings.update({
|
||||
where: { id: existing.id },
|
||||
data: { logoUrl: null },
|
||||
});
|
||||
|
||||
this.logger.log('Logo deleted');
|
||||
return updated;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to delete logo: ${error.message}`);
|
||||
throw new InternalServerErrorException('Failed to delete logo');
|
||||
}
|
||||
}
|
||||
}
|
||||
200
backend/src/signal/messages.controller.ts
Normal file
200
backend/src/signal/messages.controller.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Logger,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { Public } from '../auth/decorators/public.decorator';
|
||||
import { MessagesService, SendMessageDto } from './messages.service';
|
||||
|
||||
// DTO for incoming Signal webhook
|
||||
interface SignalWebhookPayload {
|
||||
envelope: {
|
||||
source: string;
|
||||
sourceNumber?: string;
|
||||
sourceName?: string;
|
||||
timestamp: number;
|
||||
dataMessage?: {
|
||||
timestamp: number;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
account: string;
|
||||
}
|
||||
|
||||
@Controller('signal/messages')
|
||||
export class MessagesController {
|
||||
private readonly logger = new Logger(MessagesController.name);
|
||||
|
||||
constructor(private readonly messagesService: MessagesService) {}
|
||||
|
||||
/**
|
||||
* Get messages for a specific driver
|
||||
*/
|
||||
@Get('driver/:driverId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||
async getMessagesForDriver(
|
||||
@Param('driverId') driverId: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
const messages = await this.messagesService.getMessagesForDriver(
|
||||
driverId,
|
||||
limit ? parseInt(limit, 10) : 50,
|
||||
);
|
||||
// Return in chronological order for display
|
||||
return messages.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a driver
|
||||
*/
|
||||
@Post('send')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||
async sendMessage(@Body() dto: SendMessageDto) {
|
||||
return this.messagesService.sendMessage(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark messages as read for a driver
|
||||
*/
|
||||
@Post('driver/:driverId/read')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||
async markAsRead(@Param('driverId') driverId: string) {
|
||||
const result = await this.messagesService.markMessagesAsRead(driverId);
|
||||
return { success: true, count: result.count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread message counts for all drivers
|
||||
*/
|
||||
@Get('unread')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||
async getUnreadCounts() {
|
||||
return this.messagesService.getUnreadCounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count for a specific driver
|
||||
*/
|
||||
@Get('driver/:driverId/unread')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||
async getUnreadCountForDriver(@Param('driverId') driverId: string) {
|
||||
const count = await this.messagesService.getUnreadCountForDriver(driverId);
|
||||
return { driverId, unread: count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook endpoint for incoming Signal messages
|
||||
* This is called by signal-cli-rest-api when messages are received
|
||||
* Public endpoint - no authentication required
|
||||
*/
|
||||
@Public()
|
||||
@Post('webhook')
|
||||
async handleWebhook(@Body() payload: SignalWebhookPayload) {
|
||||
this.logger.debug('Received Signal webhook:', JSON.stringify(payload));
|
||||
|
||||
try {
|
||||
const envelope = payload.envelope;
|
||||
|
||||
if (!envelope?.dataMessage?.message) {
|
||||
this.logger.debug('Webhook received but no message content');
|
||||
return { success: true, message: 'No message content' };
|
||||
}
|
||||
|
||||
const fromNumber = envelope.sourceNumber || envelope.source;
|
||||
const content = envelope.dataMessage.message;
|
||||
const timestamp = envelope.dataMessage.timestamp?.toString();
|
||||
|
||||
const message = await this.messagesService.processIncomingMessage(
|
||||
fromNumber,
|
||||
content,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
if (message) {
|
||||
return { success: true, messageId: message.id };
|
||||
} else {
|
||||
return { success: true, message: 'Unknown sender' };
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error('Failed to process webhook:', error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all messages as a text file
|
||||
*/
|
||||
@Get('export')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMINISTRATOR')
|
||||
async exportMessages(@Res() res: Response) {
|
||||
const exportData = await this.messagesService.exportAllMessages();
|
||||
|
||||
const filename = `signal-chats-${new Date().toISOString().split('T')[0]}.txt`;
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(exportData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all messages
|
||||
*/
|
||||
@Delete('all')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMINISTRATOR')
|
||||
async deleteAllMessages() {
|
||||
const count = await this.messagesService.deleteAllMessages();
|
||||
return { success: true, deleted: count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message statistics
|
||||
*/
|
||||
@Get('stats')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||
async getMessageStats() {
|
||||
return this.messagesService.getMessageStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which events have driver responses since the event started
|
||||
* Used to determine if the "awaiting response" glow should show
|
||||
*/
|
||||
@Post('check-responses')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||
async checkDriverResponses(
|
||||
@Body()
|
||||
body: {
|
||||
events: Array<{ eventId: string; driverId: string; startTime: string }>;
|
||||
},
|
||||
) {
|
||||
const pairs = body.events.map((e) => ({
|
||||
eventId: e.eventId,
|
||||
driverId: e.driverId,
|
||||
sinceTime: new Date(e.startTime),
|
||||
}));
|
||||
|
||||
const respondedEventIds =
|
||||
await this.messagesService.checkDriverResponsesSince(pairs);
|
||||
return { respondedEventIds };
|
||||
}
|
||||
}
|
||||
432
backend/src/signal/messages.service.ts
Normal file
432
backend/src/signal/messages.service.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SignalService } from './signal.service';
|
||||
import { MessageDirection, EventStatus } from '@prisma/client';
|
||||
|
||||
export interface SendMessageDto {
|
||||
driverId: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface MessageWithDriver {
|
||||
id: string;
|
||||
driverId: string;
|
||||
direction: MessageDirection;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
isRead: boolean;
|
||||
driver: {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MessagesService {
|
||||
private readonly logger = new Logger(MessagesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly signalService: SignalService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all messages for a driver
|
||||
*/
|
||||
async getMessagesForDriver(driverId: string, limit: number = 50) {
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: { id: driverId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!driver) {
|
||||
throw new NotFoundException(`Driver with ID ${driverId} not found`);
|
||||
}
|
||||
|
||||
return this.prisma.signalMessage.findMany({
|
||||
where: { driverId },
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a driver
|
||||
*/
|
||||
async sendMessage(dto: SendMessageDto) {
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: { id: dto.driverId, deletedAt: null },
|
||||
});
|
||||
|
||||
if (!driver) {
|
||||
throw new NotFoundException(`Driver with ID ${dto.driverId} not found`);
|
||||
}
|
||||
|
||||
// Get the linked Signal number
|
||||
const fromNumber = await this.signalService.getLinkedNumber();
|
||||
if (!fromNumber) {
|
||||
throw new Error('No Signal account linked. Please link an account in Admin Tools.');
|
||||
}
|
||||
|
||||
// Check driver has a phone number
|
||||
if (!driver.phone) {
|
||||
throw new Error('Driver does not have a phone number configured.');
|
||||
}
|
||||
|
||||
// Format the driver's phone number
|
||||
const toNumber = this.signalService.formatPhoneNumber(driver.phone);
|
||||
|
||||
// Send via Signal
|
||||
const result = await this.signalService.sendMessage(fromNumber, toNumber, dto.content);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to send message via Signal');
|
||||
}
|
||||
|
||||
// Store the message in database
|
||||
const message = await this.prisma.signalMessage.create({
|
||||
data: {
|
||||
driverId: dto.driverId,
|
||||
direction: MessageDirection.OUTBOUND,
|
||||
content: dto.content,
|
||||
isRead: true, // Outbound messages are always "read"
|
||||
signalTimestamp: result.timestamp?.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Message sent to driver ${driver.name} (${toNumber})`);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process incoming message from Signal webhook
|
||||
*/
|
||||
async processIncomingMessage(
|
||||
fromNumber: string,
|
||||
content: string,
|
||||
signalTimestamp?: string,
|
||||
) {
|
||||
// Normalize phone number for matching
|
||||
const normalizedPhone = this.normalizePhoneForSearch(fromNumber);
|
||||
|
||||
// Find driver by phone number
|
||||
const driver = await this.prisma.driver.findFirst({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [
|
||||
{ phone: fromNumber },
|
||||
{ phone: normalizedPhone },
|
||||
{ phone: { contains: normalizedPhone.slice(-10) } }, // Last 10 digits
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!driver) {
|
||||
this.logger.warn(`Received message from unknown number: ${fromNumber}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for duplicate message
|
||||
if (signalTimestamp) {
|
||||
const existing = await this.prisma.signalMessage.findFirst({
|
||||
where: {
|
||||
driverId: driver.id,
|
||||
signalTimestamp,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
this.logger.debug(`Duplicate message ignored: ${signalTimestamp}`);
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
// Store the message
|
||||
const message = await this.prisma.signalMessage.create({
|
||||
data: {
|
||||
driverId: driver.id,
|
||||
direction: MessageDirection.INBOUND,
|
||||
content,
|
||||
isRead: false,
|
||||
signalTimestamp,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Incoming message from driver ${driver.name}: ${content.substring(0, 50)}...`);
|
||||
|
||||
// Check if this is a status response (1, 2, or 3)
|
||||
const trimmedContent = content.trim();
|
||||
if (['1', '2', '3'].includes(trimmedContent)) {
|
||||
await this.processDriverStatusResponse(driver, parseInt(trimmedContent, 10));
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a driver's status response (1=Confirmed, 2=Delayed, 3=Issue)
|
||||
*/
|
||||
private async processDriverStatusResponse(driver: any, response: number) {
|
||||
// Find the driver's current IN_PROGRESS event
|
||||
const activeEvent = await this.prisma.scheduleEvent.findFirst({
|
||||
where: {
|
||||
driverId: driver.id,
|
||||
status: EventStatus.IN_PROGRESS,
|
||||
deletedAt: null,
|
||||
},
|
||||
include: { vehicle: true },
|
||||
});
|
||||
|
||||
if (!activeEvent) {
|
||||
// No active event, send a clarification
|
||||
await this.sendAutoReply(driver, 'No active trip found for your response. If you need assistance, please send a message to the coordinator.');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
let replyMessage: string;
|
||||
let noteText: string;
|
||||
|
||||
switch (response) {
|
||||
case 1: // Confirmed
|
||||
noteText = `[${now.toLocaleTimeString()}] ✅ Driver confirmed en route`;
|
||||
replyMessage = `✅ Confirmed! Safe travels with your VIP. Reply when completed or if you need assistance.`;
|
||||
break;
|
||||
|
||||
case 2: // Delayed
|
||||
noteText = `[${now.toLocaleTimeString()}] ⏰ Driver reported DELAY - awaiting details`;
|
||||
replyMessage = `⏰ Delay noted. Please reply with the reason for the delay. The coordinator has been alerted.`;
|
||||
break;
|
||||
|
||||
case 3: // Issue
|
||||
noteText = `[${now.toLocaleTimeString()}] 🚨 Driver reported ISSUE - needs help`;
|
||||
replyMessage = `🚨 Issue reported! A coordinator will contact you shortly. Please describe the problem in your next message.`;
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the event with the driver's response
|
||||
await this.prisma.scheduleEvent.update({
|
||||
where: { id: activeEvent.id },
|
||||
data: {
|
||||
notes: activeEvent.notes
|
||||
? `${activeEvent.notes}\n${noteText}`
|
||||
: noteText,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Driver ${driver.name} responded with ${response} for event ${activeEvent.id}`);
|
||||
|
||||
// Send auto-reply
|
||||
await this.sendAutoReply(driver, replyMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an automated reply to a driver
|
||||
*/
|
||||
private async sendAutoReply(driver: any, message: string) {
|
||||
try {
|
||||
const fromNumber = await this.signalService.getLinkedNumber();
|
||||
if (!fromNumber) {
|
||||
this.logger.warn('No Signal account linked, cannot send auto-reply');
|
||||
return;
|
||||
}
|
||||
|
||||
const toNumber = this.signalService.formatPhoneNumber(driver.phone);
|
||||
await this.signalService.sendMessage(fromNumber, toNumber, message);
|
||||
|
||||
// Store the outbound message
|
||||
await this.prisma.signalMessage.create({
|
||||
data: {
|
||||
driverId: driver.id,
|
||||
direction: MessageDirection.OUTBOUND,
|
||||
content: message,
|
||||
isRead: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Auto-reply sent to driver ${driver.name}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send auto-reply to driver ${driver.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark messages as read for a driver
|
||||
*/
|
||||
async markMessagesAsRead(driverId: string) {
|
||||
return this.prisma.signalMessage.updateMany({
|
||||
where: {
|
||||
driverId,
|
||||
direction: MessageDirection.INBOUND,
|
||||
isRead: false,
|
||||
},
|
||||
data: { isRead: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread message count per driver
|
||||
*/
|
||||
async getUnreadCounts() {
|
||||
const result = await this.prisma.signalMessage.groupBy({
|
||||
by: ['driverId'],
|
||||
where: {
|
||||
direction: MessageDirection.INBOUND,
|
||||
isRead: false,
|
||||
},
|
||||
_count: true,
|
||||
});
|
||||
|
||||
return result.reduce((acc, item) => {
|
||||
acc[item.driverId] = item._count;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count for a specific driver
|
||||
*/
|
||||
async getUnreadCountForDriver(driverId: string) {
|
||||
return this.prisma.signalMessage.count({
|
||||
where: {
|
||||
driverId,
|
||||
direction: MessageDirection.INBOUND,
|
||||
isRead: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize phone number for database searching
|
||||
*/
|
||||
private normalizePhoneForSearch(phone: string): string {
|
||||
return phone.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all messages as formatted text
|
||||
*/
|
||||
async exportAllMessages(): Promise<string> {
|
||||
const messages = await this.prisma.signalMessage.findMany({
|
||||
include: {
|
||||
driver: {
|
||||
select: { id: true, name: true, phone: true },
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ driverId: 'asc' },
|
||||
{ timestamp: 'asc' },
|
||||
],
|
||||
});
|
||||
|
||||
if (messages.length === 0) {
|
||||
return 'No messages to export.';
|
||||
}
|
||||
|
||||
// Group messages by driver
|
||||
const byDriver: Record<string, typeof messages> = {};
|
||||
for (const msg of messages) {
|
||||
const driverId = msg.driverId;
|
||||
if (!byDriver[driverId]) {
|
||||
byDriver[driverId] = [];
|
||||
}
|
||||
byDriver[driverId].push(msg);
|
||||
}
|
||||
|
||||
// Format output
|
||||
const lines: string[] = [];
|
||||
lines.push('='.repeat(60));
|
||||
lines.push('SIGNAL CHAT EXPORT');
|
||||
lines.push(`Exported: ${new Date().toISOString()}`);
|
||||
lines.push(`Total Messages: ${messages.length}`);
|
||||
lines.push('='.repeat(60));
|
||||
lines.push('');
|
||||
|
||||
for (const [driverId, driverMessages] of Object.entries(byDriver)) {
|
||||
const driver = driverMessages[0]?.driver;
|
||||
lines.push('-'.repeat(60));
|
||||
lines.push(`DRIVER: ${driver?.name || 'Unknown'}`);
|
||||
lines.push(`Phone: ${driver?.phone || 'N/A'}`);
|
||||
lines.push(`Messages: ${driverMessages.length}`);
|
||||
lines.push('-'.repeat(60));
|
||||
|
||||
for (const msg of driverMessages) {
|
||||
const direction = msg.direction === 'INBOUND' ? '← IN ' : '→ OUT';
|
||||
const time = new Date(msg.timestamp).toLocaleString();
|
||||
lines.push(`[${time}] ${direction}: ${msg.content}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all messages
|
||||
*/
|
||||
async deleteAllMessages(): Promise<number> {
|
||||
const result = await this.prisma.signalMessage.deleteMany({});
|
||||
this.logger.log(`Deleted ${result.count} messages`);
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which driver-event pairs have driver responses since the event started
|
||||
* @param pairs Array of {driverId, eventId, sinceTime}
|
||||
* @returns Set of eventIds where the driver has responded since sinceTime
|
||||
*/
|
||||
async checkDriverResponsesSince(
|
||||
pairs: Array<{ driverId: string; eventId: string; sinceTime: Date }>,
|
||||
): Promise<string[]> {
|
||||
const respondedEventIds: string[] = [];
|
||||
|
||||
for (const pair of pairs) {
|
||||
const hasResponse = await this.prisma.signalMessage.findFirst({
|
||||
where: {
|
||||
driverId: pair.driverId,
|
||||
direction: MessageDirection.INBOUND,
|
||||
timestamp: { gte: pair.sinceTime },
|
||||
},
|
||||
});
|
||||
|
||||
if (hasResponse) {
|
||||
respondedEventIds.push(pair.eventId);
|
||||
}
|
||||
}
|
||||
|
||||
return respondedEventIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message statistics
|
||||
*/
|
||||
async getMessageStats() {
|
||||
const [total, inbound, outbound, unread] = await Promise.all([
|
||||
this.prisma.signalMessage.count(),
|
||||
this.prisma.signalMessage.count({
|
||||
where: { direction: MessageDirection.INBOUND },
|
||||
}),
|
||||
this.prisma.signalMessage.count({
|
||||
where: { direction: MessageDirection.OUTBOUND },
|
||||
}),
|
||||
this.prisma.signalMessage.count({
|
||||
where: { direction: MessageDirection.INBOUND, isRead: false },
|
||||
}),
|
||||
]);
|
||||
|
||||
const driversWithMessages = await this.prisma.signalMessage.groupBy({
|
||||
by: ['driverId'],
|
||||
});
|
||||
|
||||
return {
|
||||
total,
|
||||
inbound,
|
||||
outbound,
|
||||
unread,
|
||||
driversWithMessages: driversWithMessages.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
115
backend/src/signal/signal-polling.service.ts
Normal file
115
backend/src/signal/signal-polling.service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { SignalService } from './signal.service';
|
||||
import { MessagesService } from './messages.service';
|
||||
|
||||
@Injectable()
|
||||
export class SignalPollingService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(SignalPollingService.name);
|
||||
private pollingInterval: NodeJS.Timeout | null = null;
|
||||
private isPolling = false;
|
||||
|
||||
// Poll every 5 seconds
|
||||
private readonly POLL_INTERVAL_MS = 5000;
|
||||
|
||||
constructor(
|
||||
private readonly signalService: SignalService,
|
||||
private readonly messagesService: MessagesService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.startPolling();
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
private startPolling() {
|
||||
this.logger.log('Starting Signal message polling...');
|
||||
this.pollingInterval = setInterval(() => this.pollMessages(), this.POLL_INTERVAL_MS);
|
||||
// Also poll immediately on startup
|
||||
this.pollMessages();
|
||||
}
|
||||
|
||||
private stopPolling() {
|
||||
if (this.pollingInterval) {
|
||||
clearInterval(this.pollingInterval);
|
||||
this.pollingInterval = null;
|
||||
this.logger.log('Stopped Signal message polling');
|
||||
}
|
||||
}
|
||||
|
||||
private async pollMessages() {
|
||||
// Prevent concurrent polling
|
||||
if (this.isPolling) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isPolling = true;
|
||||
|
||||
try {
|
||||
const linkedNumber = await this.signalService.getLinkedNumber();
|
||||
if (!linkedNumber) {
|
||||
// No account linked, skip polling
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = await this.signalService.receiveMessages(linkedNumber);
|
||||
|
||||
if (messages && messages.length > 0) {
|
||||
this.logger.log(`Received ${messages.length} message(s) from Signal`);
|
||||
|
||||
for (const msg of messages) {
|
||||
await this.processMessage(msg);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Only log errors that aren't connection issues (Signal CLI might not be ready)
|
||||
if (!error.message?.includes('ECONNREFUSED')) {
|
||||
this.logger.error(`Error polling messages: ${error.message}`);
|
||||
}
|
||||
} finally {
|
||||
this.isPolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async processMessage(msg: any) {
|
||||
try {
|
||||
// Signal CLI returns messages in various formats
|
||||
// We're looking for envelope.dataMessage.message
|
||||
const envelope = msg.envelope;
|
||||
|
||||
if (!envelope) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the sender's phone number
|
||||
const fromNumber = envelope.sourceNumber || envelope.source;
|
||||
|
||||
// Check for data message (regular text message)
|
||||
const dataMessage = envelope.dataMessage;
|
||||
if (dataMessage?.message) {
|
||||
const content = dataMessage.message;
|
||||
const timestamp = dataMessage.timestamp?.toString();
|
||||
|
||||
this.logger.debug(`Processing message from ${fromNumber}: ${content.substring(0, 50)}...`);
|
||||
|
||||
await this.messagesService.processIncomingMessage(
|
||||
fromNumber,
|
||||
content,
|
||||
timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
// Also handle sync messages (messages sent from other linked devices)
|
||||
const syncMessage = envelope.syncMessage;
|
||||
if (syncMessage?.sentMessage?.message) {
|
||||
// This is a message we sent from another device, we can ignore it
|
||||
// or store it if needed
|
||||
this.logger.debug('Received sync message (sent from another device)');
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error processing message: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
150
backend/src/signal/signal.controller.ts
Normal file
150
backend/src/signal/signal.controller.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { SignalService, SignalStatus } from './signal.service';
|
||||
|
||||
@Controller('signal')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class SignalController {
|
||||
constructor(private readonly signalService: SignalService) {}
|
||||
|
||||
/**
|
||||
* Get Signal connection status
|
||||
*/
|
||||
@Get('status')
|
||||
@Roles('ADMINISTRATOR')
|
||||
async getStatus(): Promise<SignalStatus> {
|
||||
return this.signalService.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get QR code for linking device
|
||||
*/
|
||||
@Get('qrcode')
|
||||
@Roles('ADMINISTRATOR')
|
||||
async getQRCode() {
|
||||
const result = await this.signalService.getQRCodeLink();
|
||||
if (!result) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Device already linked. Unlink first to re-link.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
qrcode: result.qrcode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new phone number
|
||||
*/
|
||||
@Post('register')
|
||||
@Roles('ADMINISTRATOR')
|
||||
async registerNumber(@Body() body: { phoneNumber: string; captcha?: string }) {
|
||||
return this.signalService.registerNumber(body.phoneNumber, body.captcha);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify phone number with code
|
||||
*/
|
||||
@Post('verify')
|
||||
@Roles('ADMINISTRATOR')
|
||||
async verifyNumber(@Body() body: { phoneNumber: string; code: string }) {
|
||||
return this.signalService.verifyNumber(body.phoneNumber, body.code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink the current account
|
||||
*/
|
||||
@Delete('unlink/:phoneNumber')
|
||||
@Roles('ADMINISTRATOR')
|
||||
async unlinkAccount(@Param('phoneNumber') phoneNumber: string) {
|
||||
return this.signalService.unlinkAccount(phoneNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a test message
|
||||
*/
|
||||
@Post('send')
|
||||
@Roles('ADMINISTRATOR')
|
||||
async sendMessage(@Body() body: { to: string; message: string }) {
|
||||
const fromNumber = await this.signalService.getLinkedNumber();
|
||||
if (!fromNumber) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No Signal account linked. Please link an account first.',
|
||||
};
|
||||
}
|
||||
|
||||
const formattedTo = this.signalService.formatPhoneNumber(body.to);
|
||||
return this.signalService.sendMessage(fromNumber, formattedTo, body.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to multiple recipients
|
||||
*/
|
||||
@Post('send-bulk')
|
||||
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||
async sendBulkMessage(@Body() body: { recipients: string[]; message: string }) {
|
||||
const fromNumber = await this.signalService.getLinkedNumber();
|
||||
if (!fromNumber) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No Signal account linked. Please link an account first.',
|
||||
};
|
||||
}
|
||||
|
||||
const formattedRecipients = body.recipients.map((r) =>
|
||||
this.signalService.formatPhoneNumber(r),
|
||||
);
|
||||
return this.signalService.sendBulkMessage(
|
||||
fromNumber,
|
||||
formattedRecipients,
|
||||
body.message,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a PDF or file attachment via Signal
|
||||
*/
|
||||
@Post('send-attachment')
|
||||
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||
async sendAttachment(
|
||||
@Body()
|
||||
body: {
|
||||
to: string;
|
||||
message?: string;
|
||||
attachment: string; // Base64 encoded file
|
||||
filename: string;
|
||||
mimeType?: string;
|
||||
},
|
||||
) {
|
||||
const fromNumber = await this.signalService.getLinkedNumber();
|
||||
if (!fromNumber) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No Signal account linked. Please link an account first.',
|
||||
};
|
||||
}
|
||||
|
||||
const formattedTo = this.signalService.formatPhoneNumber(body.to);
|
||||
return this.signalService.sendMessageWithAttachment(
|
||||
fromNumber,
|
||||
formattedTo,
|
||||
body.message || '',
|
||||
body.attachment,
|
||||
body.filename,
|
||||
body.mimeType || 'application/pdf',
|
||||
);
|
||||
}
|
||||
}
|
||||
15
backend/src/signal/signal.module.ts
Normal file
15
backend/src/signal/signal.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SignalService } from './signal.service';
|
||||
import { SignalController } from './signal.controller';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { MessagesController } from './messages.controller';
|
||||
import { SignalPollingService } from './signal-polling.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [SignalController, MessagesController],
|
||||
providers: [SignalService, MessagesService, SignalPollingService],
|
||||
exports: [SignalService, MessagesService],
|
||||
})
|
||||
export class SignalModule {}
|
||||
327
backend/src/signal/signal.service.ts
Normal file
327
backend/src/signal/signal.service.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
interface SignalAccount {
|
||||
number: string;
|
||||
uuid: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface SignalStatus {
|
||||
isConnected: boolean;
|
||||
isLinked: boolean;
|
||||
phoneNumber: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface QRCodeResponse {
|
||||
qrcode: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SignalService {
|
||||
private readonly logger = new Logger(SignalService.name);
|
||||
private readonly client: AxiosInstance;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = process.env.SIGNAL_API_URL || 'http://localhost:8080';
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Signal API is available and get connection status
|
||||
*/
|
||||
async getStatus(): Promise<SignalStatus> {
|
||||
try {
|
||||
// Check if API is reachable
|
||||
const response = await this.client.get('/v1/about');
|
||||
|
||||
// Try to get registered accounts
|
||||
// API returns array of phone number strings: ["+1234567890"]
|
||||
const accountsResponse = await this.client.get('/v1/accounts');
|
||||
const accounts: string[] = accountsResponse.data;
|
||||
|
||||
if (accounts.length > 0) {
|
||||
return {
|
||||
isConnected: true,
|
||||
isLinked: true,
|
||||
phoneNumber: accounts[0],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isConnected: true,
|
||||
isLinked: false,
|
||||
phoneNumber: null,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Failed to connect to Signal API:', error.message);
|
||||
return {
|
||||
isConnected: false,
|
||||
isLinked: false,
|
||||
phoneNumber: null,
|
||||
error: error.code === 'ECONNREFUSED'
|
||||
? 'Signal API container is not running'
|
||||
: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get QR code for linking a new device
|
||||
*/
|
||||
async getQRCodeLink(deviceName: string = 'VIP Coordinator'): Promise<QRCodeResponse | null> {
|
||||
try {
|
||||
// First check if already linked
|
||||
const status = await this.getStatus();
|
||||
if (status.isLinked) {
|
||||
this.logger.warn('Device already linked to Signal');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Request QR code for device linking - returns raw PNG image
|
||||
const response = await this.client.get('/v1/qrcodelink', {
|
||||
params: { device_name: deviceName },
|
||||
timeout: 60000, // QR generation can take a moment
|
||||
responseType: 'arraybuffer', // Get raw binary data
|
||||
});
|
||||
|
||||
// Convert to base64
|
||||
const base64 = Buffer.from(response.data, 'binary').toString('base64');
|
||||
|
||||
return {
|
||||
qrcode: base64,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Failed to get QR code:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new phone number (requires verification)
|
||||
*/
|
||||
async registerNumber(phoneNumber: string, captcha?: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await this.client.post(`/v1/register/${phoneNumber}`, {
|
||||
captcha,
|
||||
use_voice: false,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Verification code sent. Check your phone.',
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Failed to register number:', error.message);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.error || error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a phone number with the code received
|
||||
*/
|
||||
async verifyNumber(phoneNumber: string, verificationCode: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await this.client.post(`/v1/register/${phoneNumber}/verify/${verificationCode}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Phone number verified and linked successfully!',
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Failed to verify number:', error.message);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.error || error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink/unregister the current account
|
||||
*/
|
||||
async unlinkAccount(phoneNumber: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
await this.client.delete(`/v1/accounts/${phoneNumber}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Account unlinked successfully',
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Failed to unlink account:', error.message);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.error || error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a recipient
|
||||
*/
|
||||
async sendMessage(
|
||||
fromNumber: string,
|
||||
toNumber: string,
|
||||
message: string,
|
||||
): Promise<{ success: boolean; timestamp?: number; error?: string }> {
|
||||
try {
|
||||
const response = await this.client.post(`/v2/send`, {
|
||||
number: fromNumber,
|
||||
recipients: [toNumber],
|
||||
message,
|
||||
});
|
||||
|
||||
this.logger.log(`Message sent to ${toNumber}`);
|
||||
return {
|
||||
success: true,
|
||||
timestamp: response.data.timestamp,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to send message to ${toNumber}:`, error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to multiple recipients
|
||||
*/
|
||||
async sendBulkMessage(
|
||||
fromNumber: string,
|
||||
toNumbers: string[],
|
||||
message: string,
|
||||
): Promise<{ success: boolean; sent: number; failed: number; errors: string[] }> {
|
||||
const results = {
|
||||
success: true,
|
||||
sent: 0,
|
||||
failed: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
for (const toNumber of toNumbers) {
|
||||
const result = await this.sendMessage(fromNumber, toNumber, message);
|
||||
if (result.success) {
|
||||
results.sent++;
|
||||
} else {
|
||||
results.failed++;
|
||||
results.errors.push(`${toNumber}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
results.success = results.failed === 0;
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the linked phone number (if any)
|
||||
*/
|
||||
async getLinkedNumber(): Promise<string | null> {
|
||||
try {
|
||||
const response = await this.client.get('/v1/accounts');
|
||||
// API returns array of phone number strings directly: ["+1234567890"]
|
||||
const accounts: string[] = response.data;
|
||||
|
||||
if (accounts.length > 0) {
|
||||
return accounts[0];
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format phone number for Signal (must include country code)
|
||||
*/
|
||||
formatPhoneNumber(phone: string): string {
|
||||
// Remove all non-digit characters
|
||||
let cleaned = phone.replace(/\D/g, '');
|
||||
|
||||
// Add US country code if not present
|
||||
if (cleaned.length === 10) {
|
||||
cleaned = '1' + cleaned;
|
||||
}
|
||||
|
||||
// Add + prefix
|
||||
if (!cleaned.startsWith('+')) {
|
||||
cleaned = '+' + cleaned;
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive pending messages for the account
|
||||
* This fetches and removes messages from Signal's queue
|
||||
*/
|
||||
async receiveMessages(phoneNumber: string): Promise<any[]> {
|
||||
try {
|
||||
const response = await this.client.get(`/v1/receive/${phoneNumber}`, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Response is an array of message envelopes
|
||||
return response.data || [];
|
||||
} catch (error: any) {
|
||||
// Don't log timeout errors or empty responses as errors
|
||||
if (error.code === 'ECONNABORTED' || error.response?.status === 204) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message with a file attachment (PDF, image, etc.)
|
||||
* @param fromNumber - The sender's phone number
|
||||
* @param toNumber - The recipient's phone number
|
||||
* @param message - Optional text message to accompany the attachment
|
||||
* @param attachment - Base64 encoded file data
|
||||
* @param filename - Name for the file
|
||||
* @param mimeType - MIME type of the file (e.g., 'application/pdf')
|
||||
*/
|
||||
async sendMessageWithAttachment(
|
||||
fromNumber: string,
|
||||
toNumber: string,
|
||||
message: string,
|
||||
attachment: string,
|
||||
filename: string,
|
||||
mimeType: string = 'application/pdf',
|
||||
): Promise<{ success: boolean; timestamp?: number; error?: string }> {
|
||||
try {
|
||||
// Format: data:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>
|
||||
const base64Attachment = `data:${mimeType};filename=${filename};base64,${attachment}`;
|
||||
|
||||
const response = await this.client.post(`/v2/send`, {
|
||||
number: fromNumber,
|
||||
recipients: [toNumber],
|
||||
message: message || '',
|
||||
base64_attachments: [base64Attachment],
|
||||
});
|
||||
|
||||
this.logger.log(`Message with attachment sent to ${toNumber}: ${filename}`);
|
||||
return {
|
||||
success: true,
|
||||
timestamp: response.data.timestamp,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to send attachment to ${toNumber}:`, error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.error || error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user