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:
2026-02-01 19:30:41 +01:00
parent 2d842ed294
commit 3b0b1205df
84 changed files with 12330 additions and 2103 deletions

View File

@@ -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"
}
}
}
}

View File

@@ -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",

View File

@@ -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;

View File

@@ -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")
);

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "drivers" ALTER COLUMN "phone" DROP NOT NULL;

View File

@@ -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
}

View File

@@ -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: [

View File

@@ -25,6 +25,7 @@ export type Subjects =
| 'ScheduleEvent'
| 'Flight'
| 'Vehicle'
| 'Settings'
| 'all';
/**

View 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,
};
}
}
}

View 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 {}

View File

@@ -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 {}

View File

@@ -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);

View File

@@ -6,7 +6,8 @@ export class CreateDriverDto {
name: string;
@IsString()
phone: string;
@IsOptional()
phone?: string;
@IsEnum(Department)
@IsOptional()

View 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;
}
}

View File

@@ -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 {}

View File

@@ -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 };
}
}

View File

@@ -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

View File

@@ -0,0 +1,3 @@
export * from './seed.module';
export * from './seed.service';
export * from './seed.controller';

View 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();
}
}

View 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 {}

View 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);
}
}

View 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;
}

View 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();
}
}

View 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 {}

View 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');
}
}
}

View 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 };
}
}

View 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,
};
}
}

View 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}`);
}
}
}

View 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',
);
}
}

View 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 {}

View 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,
};
}
}
}