48 Commits

Author SHA1 Message Date
139cb4aebe refactor: simplify GPS page, lean into Traccar for live map and trips
Remove ~2,300 lines of code that duplicated Traccar's native capabilities:
- Remove Leaflet live map, trip stats/playback, and OSRM route matching from frontend
- Delete osrm.service.ts entirely (415 lines)
- Remove 6 dead backend endpoints and unused service methods
- Clean up unused hooks and TypeScript types
- Keep device enrollment, QR codes, settings, and CommandCenter integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:53:57 +01:00
14c6c9506f fix: optimize Traccar Client QR code with iOS background GPS settings
QR enrollment was only setting id and interval, causing iOS to default
to medium accuracy with stop_detection enabled — which pauses GPS
updates when the phone appears stationary, causing 5-30 min gaps.

Now sets accuracy=highest, stop_detection=false, distance=0, angle=30,
heartbeat=300, buffer=true. Also updates driver instructions with
required iPhone settings (Always location, Background App Refresh).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:51:42 +01:00
53eb82c4d2 refactor: use Traccar trip API instead of custom detection (#23)
Replace custom trip detection (overlapping/micro-trip prone) with
Traccar's built-in trip report API. Remove merge/backfill UI and
endpoints. Add geocoded address display to trip cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 19:38:08 +01:00
b80ffd3ca1 fix: trip detection creating overlapping/micro trips (#23)
- Increase idle threshold from 5 to 10 minutes for sparse GPS data
- Only start new trips from positions AFTER the previous trip ended
- Prevent duplicate trips at same timestamp with existence check
- Auto-delete micro-trips (< 0.1 mi or < 60 seconds)
- Use GPS timestamps for idle detection instead of wall clock

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:57:11 +01:00
cc3375ef85 feat: add GPS trip detection, history panel, and playback (#23)
Auto-detect trips from GPS data (5-min idle threshold), pre-compute
OSRM routes on trip completion, add trip history side panel with
toggleable trips, and animated trip playback with speed controls.

- Add GpsTrip model with TripStatus enum and migration
- Trip detection in syncPositions cron (start on movement, end on idle)
- Trip finalization with OSRM route matching and stats computation
- API endpoints: list/detail/active/merge/backfill trips
- Stats tab overhaul: trip list panel + map with colored polylines
- Trip playback: animated marker, progressive trail, 1x-16x speed
- Live map shows active trip trail instead of full day history
- Historical backfill from existing GPS location data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 18:08:48 +01:00
cb4a070ad9 fix: OSRM sparse data handling, frontend type mismatch, map jumping
- Rewrite OsrmService with smart dense/sparse segmentation:
  dense GPS traces use Match API, sparse gaps use Route API
  (turn-by-turn directions between waypoints)
- Filter stationary points before OSRM processing
- Fix critical frontend bug: LocationHistoryResponse type didn't
  match backend response shape (matchedRoute vs matched), so OSRM
  routes were never actually displaying
- Fix double distance conversion (backend sends miles, frontend
  was dividing by 1609.34 again)
- Fix map jumping: disable popup autoPan on marker data refresh
- Extend default history window from 4h to 12h

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:39:00 +01:00
12b9361ae0 chore: add OSRM-related type definitions for GPS routes
Adds distanceMethod to DriverStatsDto and LocationHistoryResponse interface
to support the OSRM road-snapping feature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:05:43 +01:00
33fda57cc6 feat: add OSRM road-snapping for GPS routes and mileage (#21)
Routes now follow actual roads instead of cutting through buildings:
- New OsrmService calls free OSRM Match API to snap GPS points to roads
- Position history endpoint accepts ?matched=true for road-snapped geometry
- Stats use OSRM road distance instead of Haversine crow-flies distance
- Frontend shows solid blue polylines for matched routes, dashed for raw
- Handles chunking (100 coord limit), rate limiting, graceful fallback
- Distance badge shows accurate road miles on route trails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:03:47 +01:00
d93919910b fix: rewrite GPS stats to calculate from stored history (#22)
- Replace Traccar summary API dependency with local Haversine distance calculation
- Calculate mileage from GpsLocationHistory table (sum consecutive positions)
- Filter out GPS jitter (<0.01mi), gaps (>10min), and unrealistic speeds (>100mph)
- Calculate trips, driving time, average/top speed from position history
- Add detailed stats logging for debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:48:41 +01:00
4dbb899409 fix: improve GPS position sync reliability and add route trails (#21)
Backend:
- Increase sync overlap buffer from 5s to 30s to catch late-arriving positions
- Add position history endpoint GET /gps/locations/:driverId/history
- Add logging for position sync counts (returned vs inserted)

Frontend:
- Add useDriverLocationHistory hook for fetching position trails
- Draw Polyline route trails on GPS map for each tracked driver
- Historical positions shown as semi-transparent paths behind live markers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:42:41 +01:00
3bc9cd0bca refactor: complete code efficiency pass (Issues #10, #14, #16)
Backend:
- Add Prisma soft-delete middleware for automatic deletedAt filtering (#10)
- Split 2758-line copilot.service.ts into focused sub-services (#14):
  - copilot-schedule.service.ts (schedule/event tools)
  - copilot-reports.service.ts (reporting/analytics tools)
  - copilot-fleet.service.ts (vehicle/driver tools)
  - copilot-vip.service.ts (VIP management tools)
  - copilot.service.ts now thin orchestrator
- Remove manual deletedAt: null from 50+ queries

Frontend:
- Create SortableHeader component for reusable table sorting (#16)
- Create useListPage hook for shared search/filter/sort state (#16)
- Update VipList, DriverList, EventList to use shared infrastructure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:34:18 +01:00
f2b3f34a72 refactor: code efficiency improvements (Issues #9-13, #15, #17-20)
Backend:
- Extract shared hard-delete authorization utility (#9)
- Extract Prisma include constants per entity (#11)
- Fix N+1 query pattern in events findAll (#12)
- Extract shared date utility functions (#13)
- Move vehicle utilization filtering to DB query (#15)
- Add ParseBooleanPipe for query params
- Add CurrentDriver decorator + ResolveDriverInterceptor (#20)

Frontend:
- Extract shared form utilities (toDatetimeLocal) and enum labels (#17)
- Replace browser confirm() with styled ConfirmModal (#18)
- Add centralized query-keys.ts constants (#19)
- Clean up unused imports, add useMemo where needed (#19)
- Standardize filter button styling across list pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:07:19 +01:00
806b67954e feat: modernize login page with dark theme and breathing logo animation
- Dark gradient background (slate-950/blue-950) with ambient blur effects
- Circular logo centered with dual-ring frosted glass design
- Heartbeat breathing animation (3s cycle) with glow pulse on outer ring
- Gradient sign-in button with hover shadow effects
- Removed "first user" warning, replaced with subtle "authorized personnel" note
- Closes #5 and #6

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 07:58:04 +01:00
a4d360aae9 feat: add PDF reports, timezone management, GPS QR codes, and fix GPS tracking gaps
Issue #1: QR button on GPS Devices tab for re-enrollment
Issue #2: App-wide timezone setting with TimezoneContext, useFormattedDate hook,
  and admin timezone selector. All date displays now respect the configured timezone.
Issue #3: PDF export for Accountability Roster using @react-pdf/renderer with
  professional styling matching VIPSchedulePDF. Added Signal send button.
Issue #4: Fixed GPS "teleporting" gaps - syncPositions now fetches position history
  per device instead of only latest position. Changed cron to every 30s, added
  unique constraint on deviceId+timestamp for deduplication, lowered min interval to 10s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 07:36:51 +01:00
0f0f1cbf38 feat: add smart flight tracking with AviationStack API + visual progress
- Add 20+ flight fields (terminal, gate, delays, estimated times, etc.)
- Smart polling cron with budget-aware priority queue (100 req/month)
- Tracking phases: FAR_OUT → PRE_DEPARTURE → ACTIVE → LANDED
- Visual FlightProgressBar with animated airplane between airports
- FlightCard with status dots, delay badges, expandable details
- FlightList rewrite: card-based, grouped by status, search/filter
- Dashboard: enriched flight status widget with compact progress bars
- CommandCenter: flight alerts + enriched arrivals with gate/terminal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 19:42:52 +01:00
74a292ea93 feat: add Help page with search, streamline copilot, misc UI fixes
Adds searchable Help/User Guide page, trims copilot tool bloat,
adds OTHER department option, and various form/layout improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 19:42:39 +01:00
b35c14fddc feat: add VIP roster tracking and accountability reports
- Add isRosterOnly flag for VIPs who attend but don't need transportation
- Add VIP contact fields (phone, email) and emergency contact info
- Create Reports page under Admin menu with Accountability Roster
- Report shows all VIPs (active + roster-only) with contact/emergency info
- Export to CSV functionality for emergency preparedness
- VIP list filters roster-only by default with toggle to show
- VIP form includes collapsible contact/emergency section
- Fix first-user race condition with Serializable transaction
- Remove Traccar hardcoded default credentials
- Add feature flags endpoint for optional services

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 09:16:32 +01:00
934464bf8e security: add helmet, rate limiting, webhook auth, fix token storage, restrict hard deletes
- Add helmet for HTTP security headers (CSP, HSTS, X-Frame-Options, etc.)
- Add @nestjs/throttler for rate limiting (100 req/60s per IP)
- Add shared secret validation on Signal webhook endpoint
- Remove JWT token from localStorage, use Auth0 SDK memory cache
  with async getAccessTokenSilently() in API interceptor
- Restrict hard delete (?hard=true) to ADMINISTRATOR role in service layer
- Replace exposed Anthropic API key with placeholder in .env

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 18:30:14 +01:00
8e88880838 chore: remove unused packages, imports, and stale type definitions
- Remove @casl/prisma (unused) from backend
- Remove @heroicons/react (unused, using lucide-react) from frontend
- Remove unused InferSubjects import from ability.factory.ts
- Remove unused Calendar import from Dashboard.tsx
- Delete stale frontend/src/lib/types.ts (duplicate of src/types/index.ts)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:33:57 +01:00
5f4c474e37 feat: improve VIP table display and rewrite seed service for new paradigm
- EventList VIP column: compact layout with max 2 names shown, party
  size badges, "+N more" indicator, and total passenger count
- Seed service: 20 VIPs with party sizes, 8 drivers, 8 vehicles,
  13 master events over 3 days with linked transport legs, realistic
  capacity planning and conflict-free driver/vehicle assignments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 00:22:59 +01:00
a6b639d5f4 feat: update seed data with BSA Jamboree scenario
Replaces generic test data with a realistic BSA Jamboree scenario that
demonstrates party sizes, shared itinerary items, and linked transport
legs. Includes 6 VIPs with varying party sizes, 7 shared events, 15
transport legs, 6 vehicles, and 4 drivers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 00:03:49 +01:00
8e8bbad3fc feat: add party size tracking and master event linking
Add partySize field to VIP model (default 1) to track total people
traveling with each VIP including entourage/handlers/spouses. Vehicle
capacity checks now sum party sizes instead of just counting VIPs.

Add masterEventId self-reference to ScheduleEvent for linking transport
legs to shared itinerary items (events, meetings, meals). When creating
a transport event, users can link it to a shared activity and VIPs
auto-populate from the linked event.

Changes:
- Schema: partySize on VIP, masterEventId on ScheduleEvent
- Backend: party-size-aware capacity checks, master/child event includes
- VIP Form: party size input with helper text
- Event Form: party-size capacity display, master event selector
- Event List: party size in capacity and VIP names, master event badges
- Command Center: all VIP names shown with party size indicators

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 23:40:44 +01:00
714cac5d10 feat: add GPS location indicators and driver map modal to War Room
Add real-time GPS status dots on driver names throughout the Command Center:
- Green pulsing dot for drivers seen within 10 minutes, gray for inactive
- Clickable dots open a satellite map modal centered on the driver's position
- GPS dots appear in Active NOW cards, Upcoming cards, and In Use vehicles
- Replace Quick Actions panel with Active Drivers panel showing GPS-active
  drivers with speed and last seen time, with compact quick-link icons below
- New DriverLocationModal shows Leaflet satellite map at zoom 16 with
  speed, heading, battery, and last seen info grid

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:50:24 +01:00
ca2b341f01 fix: prevent GPS map from resetting zoom/position on data refresh
The MapFitBounds component was calling fitBounds on every 30-second
location refresh, overriding the user's current view. Now only fits
bounds on the initial load so users can pan and zoom freely without
interruption.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:16:03 +01:00
0d7306e0aa feat: switch GPS map to Esri satellite imagery layer
Replace OpenStreetMap tiles with Esri World Imagery for high-resolution
satellite view on the GPS Tracking live map.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:11:17 +01:00
21fb193d01 fix: restore soft-deleted driver record when re-enabling driver toggle
When a coordinator's driver status was toggled off (soft-delete) and
then back on, the create failed because the soft-deleted record still
existed. Now checks for active vs soft-deleted driver records and
restores the existing record instead of trying to create a duplicate.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:49:58 +01:00
858793d698 feat: consolidate Drivers and Vehicles into tabbed Fleet page
Replaces separate /drivers and /vehicles routes with a single /fleet
page using tabs. Old routes redirect for backward compatibility.
Navigation sidebar now shows one "Fleet" item instead of two.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:42:32 +01:00
16c0fb65a6 feat: add blue airplane favicon using Lucide Plane icon
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:30:04 +01:00
42bab25766 feat: allow admins and coordinators to also be drivers
Add a "Driver" checkbox column to the User Management page. Checking it
creates a linked Driver record so the user appears in the drivers list,
can be assigned events, and enrolled for GPS tracking — without changing
their primary role. The DRIVER role checkbox is auto-checked and disabled
since being a driver is inherent to that role. Promoting a user from
DRIVER to Admin/Coordinator preserves their driver record.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:19:08 +01:00
ec7c5a6802 fix: auto-refresh enrolled devices list every 30 seconds
The useGpsDevices query was missing refetchInterval, so the Last Active
timestamp on the Enrolled Devices page only updated on initial page load.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:04:48 +01:00
a0d0cbc8f6 feat: add QR code to enrollment screen for Traccar Client setup
Generate a QR code URL containing device ID, server URL, and update
interval that the Traccar Client app can scan to auto-configure.
The enrollment modal now shows the QR prominently with manual setup
collapsed as a fallback. Also pins Traccar to 6.11 and fixes Docker
health checks (IPv6/curl issues).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:54:59 +01:00
1e162b4f7c fix: sanitize device identifier and explicitly enable device
- Lowercase and strip non-alphanumeric chars from device ID
- Explicitly set disabled=false when creating device in Traccar
- Use the uniqueId returned by Traccar (ensures consistency)
- Add logging for debugging device creation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:13:30 +01:00
cbfb8c3f46 fix: restore token-based Traccar auto-login
Reverted Auth0-only approach since Traccar has openid.force=false
and the token-based login was working.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 19:48:31 +01:00
e050f3841e fix: correct VIPForm filename case for Linux builds
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 19:41:01 +01:00
5a22a4dd46 fix: improve GPS enrollment and simplify Auth0 SSO
- Remove dashes from device identifiers for better compatibility
- Auto-enable consent on enrollment (HR handles consent at hiring)
- Remove consent checks from location queries and UI
- Simplify Traccar Admin to use Auth0 SSO directly
- Fix server URL to return base Traccar URL (app handles port)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 18:56:16 +01:00
5ded039793 feat: add GPS tracking with Traccar integration
- Add GPS module with Traccar client service for device management
- Add driver enrollment flow with QR code generation
- Add real-time location tracking on driver profiles
- Add GPS settings configuration in admin tools
- Add Auth0 OpenID Connect setup script for Traccar
- Add deployment configs for production server
- Update nginx configs for SSL on GPS port 5055
- Add timezone setting support
- Various UI improvements and bug fixes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 18:13:17 +01:00
3814d175ff feat: enable SSL on Traccar device port 5055
- nginx stream module now terminates SSL on port 5055
- Backend returns HTTPS URL for device server
- More secure GPS data transmission

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 23:27:35 +01:00
6a10785ec8 fix: correct Traccar Client setup instructions
- Remove unreliable QR code scanning, add direct app store links
- Fix server URL to use HTTP (not HTTPS) for port 5055
- OsmAnd protocol doesn't use SSL
- Emphasize that official Traccar Client app is required

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 23:23:00 +01:00
0da2e7e8a6 fix: use correct QR code format for Traccar Client
Traccar Client expects URL query string format:
https://server?id=xxx&interval=60&accuracy=high

NOT JSON format which was being generated before.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 23:07:32 +01:00
651f4d2aa8 fix: link new devices to all admin users in Traccar
When creating a device, automatically link it to all Traccar admin users
so they can see it regardless of which account created the device.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:59:13 +01:00
cbba5d40b8 fix: use traccar subdomain for device server URL
Device server URL now derives from TRACCAR_PUBLIC_URL, returning
traccar.vip.madeamess.online:5055 instead of vip.madeamess.online:5055

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:49:14 +01:00
8ff331f8fa fix: load Traccar credentials from database on startup
Previously TraccarClientService was trying to authenticate with default
credentials (admin/admin) before GpsService could load the actual
credentials from the database. This caused 401 errors on driver enrollment.

Now GpsService sets credentials on TraccarClientService during onModuleInit()
after loading them from the gps_settings table.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:44:13 +01:00
3b0b1205df 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>
2026-02-01 19:30:41 +01:00
2d842ed294 feat: add driver schedule self-service and full schedule support
This commit implements comprehensive driver schedule self-service functionality,
allowing drivers to access their own schedules without requiring administrator
permissions, along with full schedule support for multi-day views.

Backend Changes:
- Added /drivers/me/* endpoints for driver self-service operations:
  - GET /drivers/me - Get authenticated driver's profile
  - GET /drivers/me/schedule/ics - Export driver's own schedule as ICS
  - GET /drivers/me/schedule/pdf - Export driver's own schedule as PDF
  - POST /drivers/me/send-schedule - Send schedule to driver via Signal
  - PATCH /drivers/me - Update driver's own profile
- Added fullSchedule parameter support to schedule export service:
  - Defaults to true (full upcoming schedule)
  - Pass fullSchedule=false for single-day view
  - Applied to ICS, PDF, and Signal message generation
- Fixed route ordering in drivers.controller.ts:
  - Static routes (send-all-schedules) now come before :id routes
  - Prevents path matching issues
- TypeScript improvements in copilot.service.ts:
  - Fixed type errors with proper null handling
  - Added explicit return types

Frontend Changes:
- Created MySchedule page with simplified driver-focused UI:
  - Preview PDF button - Opens schedule PDF in new browser tab
  - Send to Signal button - Sends schedule directly to driver's phone
  - Uses /drivers/me/* endpoints to avoid permission issues
  - No longer requires driver ID parameter
- Resolved "Forbidden Resource" errors for driver role users:
  - Replaced /drivers/:id endpoints with /drivers/me endpoints
  - Drivers can now access their own data without admin permissions

Key Features:
1. Full Schedule by Default - Drivers see all upcoming events, not just today
2. Self-Service Access - Drivers manage their own schedules independently
3. PDF Preview - Quick browser-based preview without downloading
4. Signal Integration - Direct schedule delivery to mobile devices
5. Role-Based Security - Proper CASL permissions for driver self-access

This resolves the driver schedule access issue and provides a streamlined
experience for drivers to view and share their schedules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:27:13 +01:00
374ffcfa12 docs: add production deployment summary
- Comprehensive documentation of production deployment to Digital Ocean
- Includes all configuration details, environment variables, and troubleshooting
- Documents all issues encountered and their resolutions
- Provides quick reference for future deployments

Production site: https://vip.madeamess.online
App ID: 5804ff4f-df62-40f4-bdb3-a6818fd5aab2
Cost: $17/month (fully managed)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-31 23:07:55 +01:00
a791b509d8 Fix API routing for App Platform deployment
- Changed global prefix to use 'v1' in production instead of 'api/v1'
- App Platform ingress routes /api to backend, so backend only needs /v1 prefix
- Maintains backward compatibility: dev uses /api/v1, prod uses /v1

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-31 22:13:06 +01:00
f36999cf43 feat: add Digital Ocean App Platform deployment
- Create App Platform deployment spec (.do/app.yaml)
- Add comprehensive APP_PLATFORM_DEPLOYMENT.md guide
- Configure Docker Hub as container registry
- Set up managed PostgreSQL database
- Configure auto-SSL and custom domain support
- Total cost: ~$17/month (vs $24+ for droplets)

Images available on Docker Hub:
- t72chevy/vip-coordinator-backend:latest
- t72chevy/vip-coordinator-frontend:latest

Images also available on Gitea:
- gitea.madeamess.online/kyle/vip-coordinator/backend:latest
- gitea.madeamess.online/kyle/vip-coordinator/frontend:latest

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-31 21:39:07 +01:00
e9de71ce29 feat: add Digital Ocean deployment configuration
- Create docker-compose.digitalocean.yml for registry-based deployment
- Add .env.digitalocean.example template for cloud deployment
- Add comprehensive DIGITAL_OCEAN_DEPLOYMENT.md guide
- Configure image pulling from Gitea registry
- Include SSL setup with Caddy/Traefik
- Add backup, monitoring, and security instructions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-31 20:09:48 +01:00
248 changed files with 33755 additions and 3696 deletions

67
.do/app.yaml Normal file
View File

@@ -0,0 +1,67 @@
# Digital Ocean App Platform Spec
# Deploy VIP Coordinator from Docker Hub
name: vip-coordinator
region: nyc
# Managed Database (PostgreSQL)
databases:
- name: vip-db
engine: PG
version: "16"
production: false # Dev tier ($7/month) - set true for prod ($15/month)
services:
# Backend API Service
- name: backend
image:
registry_type: DOCKER_HUB
registry: t72chevy
repository: vip-coordinator-backend
tag: latest
# For private repos, credentials configured separately
instance_count: 1
instance_size_slug: basic-xxs # $5/month - smallest
http_port: 3000
health_check:
http_path: /api/v1/health
initial_delay_seconds: 40
envs:
- key: NODE_ENV
value: production
- key: DATABASE_URL
scope: RUN_TIME
value: ${vip-db.DATABASE_URL}
- key: REDIS_URL
value: ${redis.REDIS_URL}
- key: AUTH0_DOMAIN
value: dev-s855cy3bvjjbkljt.us.auth0.com
- key: AUTH0_AUDIENCE
value: https://vip-coordinator-api
- key: AUTH0_ISSUER
value: https://dev-s855cy3bvjjbkljt.us.auth0.com/
routes:
- path: /api
# Frontend Service
- name: frontend
image:
registry_type: DOCKER_HUB
registry: t72chevy
repository: vip-coordinator-frontend
tag: latest
instance_count: 1
instance_size_slug: basic-xxs # $5/month
http_port: 80
routes:
- path: /
# Redis Worker (using official image)
jobs:
- name: redis
image:
registry_type: DOCKER_HUB
repository: redis
tag: "7-alpine"
instance_count: 1
instance_size_slug: basic-xxs # $5/month
kind: PRE_DEPLOY

46
.env.digitalocean.example Normal file
View File

@@ -0,0 +1,46 @@
# ==========================================
# VIP Coordinator - Digital Ocean Environment
# ==========================================
# Copy this file to .env.digitalocean and fill in your values
# Then deploy with: docker-compose -f docker-compose.digitalocean.yml --env-file .env.digitalocean up -d
# ==========================================
# Gitea Registry Configuration
# ==========================================
# Your local Gitea server (accessible from Digital Ocean)
# If Gitea is on your LAN, you'll need to expose it or use a VPN
GITEA_REGISTRY=YOUR_PUBLIC_GITEA_URL:3000
IMAGE_TAG=latest
# ==========================================
# Database Configuration
# ==========================================
POSTGRES_DB=vip_coordinator
POSTGRES_USER=vip_user
POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD_12345
# ==========================================
# Auth0 Configuration
# ==========================================
# Get these from your Auth0 dashboard
# IMPORTANT: Update Auth0 callbacks to use your production domain
AUTH0_DOMAIN=dev-s855cy3bvjjbkljt.us.auth0.com
AUTH0_AUDIENCE=https://vip-coordinator-api
AUTH0_ISSUER=https://dev-s855cy3bvjjbkljt.us.auth0.com/
AUTH0_CLIENT_ID=JXEVOIfS5eYCkeKbbCWIkBYIvjqdSP5d
# ==========================================
# Frontend Configuration
# ==========================================
# Port 80 for HTTP (will be behind reverse proxy for HTTPS)
FRONTEND_PORT=80
# ==========================================
# Optional: External APIs
# ==========================================
AVIATIONSTACK_API_KEY=
# ==========================================
# Optional: Database Seeding
# ==========================================
RUN_SEED=false

5
.gitignore vendored
View File

@@ -64,6 +64,11 @@ jspm_packages/
# AI context files
CLAUDE.md
# Infrastructure documentation (contains deployment details - DO NOT COMMIT)
INFRASTRUCTURE.md
DEPLOYMENT-NOTES.md
*-PRIVATE.md
# CI/CD (GitHub-specific, not needed for Gitea)
.github/

880
AGENT_TEAM.md Normal file
View File

@@ -0,0 +1,880 @@
# VIP Coordinator - Agent Team Configuration
## Team Overview
This document defines a specialized team of AI agents for iterating on the VIP Coordinator application. Each agent has a specific focus area and can be invoked using the Task tool with detailed prompts.
---
## Agent Roster
| Agent | Role | Focus Area |
|-------|------|------------|
| **Orchestrator** | Team Supervisor | Coordinates all agents, plans work, delegates tasks |
| **Tech Lead** | Architecture & Standards | Code review, architecture decisions, best practices |
| **Backend Engineer** | API Development | NestJS, Prisma, API endpoints |
| **Frontend Engineer** | UI Development | React, TanStack Query, Shadcn UI |
| **DevOps Engineer** | Deployment | Docker, DockerHub, Digital Ocean |
| **Security Engineer** | Security | Vulnerability detection, auth, data protection |
| **Performance Engineer** | Code Efficiency | Optimization, profiling, resource usage |
| **UX Designer** | UI/UX Review | Accessibility, usability, design patterns |
| **QA Lead** | E2E Testing | Playwright, test flows, Chrome extension testing |
| **Database Engineer** | Data Layer | Prisma schema, migrations, query optimization |
---
## Agent Prompts
### 1. ORCHESTRATOR (Team Supervisor)
**Role:** Coordinates the agent team, breaks down tasks, delegates work, and ensures quality.
```
You are the Orchestrator for the VIP Coordinator project - a full-stack NestJS + React application for VIP transportation logistics.
YOUR RESPONSIBILITIES:
1. Analyze incoming requests and break them into actionable tasks
2. Determine which specialist agents should handle each task
3. Define the order of operations (what depends on what)
4. Ensure all aspects are covered (security, testing, performance, UX)
5. Synthesize results from multiple agents into coherent deliverables
TEAM MEMBERS YOU CAN DELEGATE TO:
- Tech Lead: Architecture decisions, code standards, PR reviews
- Backend Engineer: NestJS modules, Prisma services, API endpoints
- Frontend Engineer: React components, pages, hooks, UI
- DevOps Engineer: Docker, deployment, CI/CD, Digital Ocean
- Security Engineer: Auth, vulnerabilities, data protection
- Performance Engineer: Optimization, caching, query efficiency
- UX Designer: Accessibility, usability, design review
- QA Lead: E2E tests, test coverage, regression testing
- Database Engineer: Schema design, migrations, indexes
WORKFLOW:
1. Receive task from user
2. Analyze complexity and required expertise
3. Create task breakdown with agent assignments
4. Identify dependencies between tasks
5. Recommend execution order
6. After work is done, review for completeness
OUTPUT FORMAT:
## Task Analysis
[Brief analysis of the request]
## Task Breakdown
| Task | Assigned Agent | Priority | Dependencies |
|------|---------------|----------|--------------|
| ... | ... | ... | ... |
## Execution Plan
1. [First step - agent]
2. [Second step - agent]
...
## Considerations
- Security: [any security concerns]
- Performance: [any performance concerns]
- UX: [any UX concerns]
- Testing: [testing requirements]
```
---
### 2. TECH LEAD
**Role:** Architecture decisions, code standards, technical direction.
```
You are the Tech Lead for VIP Coordinator - a NestJS + React + Prisma application.
TECH STACK:
- Backend: NestJS 10.x, Prisma 5.x, PostgreSQL 15
- Frontend: React 18.2, Vite 5.x, TanStack Query v5, Shadcn UI, Tailwind CSS
- Auth: Auth0 + Passport.js JWT
- Testing: Playwright E2E
YOUR RESPONSIBILITIES:
1. Review code for architectural consistency
2. Ensure adherence to NestJS/React best practices
3. Make technology decisions with clear rationale
4. Identify technical debt and refactoring opportunities
5. Define coding standards and patterns
6. Review PRs for quality and maintainability
ARCHITECTURAL PRINCIPLES:
- NestJS modules should be self-contained with clear boundaries
- Services handle business logic, controllers handle HTTP
- Use DTOs with class-validator for all inputs
- Soft delete pattern for all main entities (deletedAt field)
- TanStack Query for all server state (no Redux needed)
- CASL for permissions on both frontend and backend
WHEN REVIEWING CODE:
1. Check module structure and separation of concerns
2. Verify error handling and edge cases
3. Ensure type safety (no `any` types)
4. Look for N+1 query issues in Prisma
5. Verify guards and decorators are properly applied
6. Check for consistent naming conventions
OUTPUT FORMAT:
## Architecture Review
[Overall assessment]
## Strengths
- [What's done well]
## Issues Found
| Issue | Severity | Location | Recommendation |
|-------|----------|----------|----------------|
| ... | High/Medium/Low | file:line | ... |
## Recommendations
1. [Actionable recommendations]
```
---
### 3. BACKEND ENGINEER
**Role:** NestJS development, API endpoints, Prisma services.
```
You are a Backend Engineer specializing in NestJS and Prisma for the VIP Coordinator project.
TECH STACK:
- NestJS 10.x with TypeScript
- Prisma 5.x ORM
- PostgreSQL 15
- Auth0 + Passport JWT
- class-validator for DTOs
PROJECT STRUCTURE:
backend/
├── src/
│ ├── auth/ # Auth0 + JWT guards
│ ├── users/ # User management
│ ├── vips/ # VIP profiles
│ ├── drivers/ # Driver resources
│ ├── vehicles/ # Fleet management
│ ├── events/ # Schedule events (has conflict detection)
│ ├── flights/ # Flight tracking
│ └── prisma/ # Database service
PATTERNS TO FOLLOW:
1. Controllers: Use guards (@UseGuards), decorators (@Roles, @CurrentUser)
2. Services: All Prisma queries, include soft delete filter (deletedAt: null)
3. DTOs: class-validator decorators, separate Create/Update DTOs
4. Error handling: Use NestJS HttpException classes
EXAMPLE SERVICE METHOD:
```typescript
async findAll() {
return this.prisma.entity.findMany({
where: { deletedAt: null },
include: { relatedEntity: true },
orderBy: { createdAt: 'desc' },
});
}
```
EXAMPLE CONTROLLER:
```typescript
@Controller('resource')
@UseGuards(JwtAuthGuard, AbilitiesGuard)
export class ResourceController {
@Get()
@CheckAbilities({ action: 'read', subject: 'Resource' })
findAll() {
return this.service.findAll();
}
}
```
WHEN IMPLEMENTING:
1. Always add proper validation DTOs
2. Include error handling with descriptive messages
3. Add logging for important operations
4. Consider permissions (who can access this?)
5. Write efficient Prisma queries (avoid N+1)
```
---
### 4. FRONTEND ENGINEER
**Role:** React development, components, pages, data fetching.
```
You are a Frontend Engineer specializing in React for the VIP Coordinator project.
TECH STACK:
- React 18.2 with TypeScript
- Vite 5.x build tool
- TanStack Query v5 for data fetching
- Shadcn UI components
- Tailwind CSS for styling
- React Hook Form + Zod for forms
- React Router 6.x
PROJECT STRUCTURE:
frontend/src/
├── components/
│ ├── ui/ # Shadcn components
│ ├── forms/ # Form components
│ └── shared/ # Reusable components
├── pages/ # Route pages
├── contexts/ # AuthContext, AbilityContext
├── hooks/ # Custom hooks
├── lib/
│ ├── api.ts # Axios client
│ └── utils.ts # Utilities
└── types/ # TypeScript interfaces
PATTERNS TO FOLLOW:
1. Data Fetching:
```typescript
const { data, isLoading, error } = useQuery({
queryKey: ['resource'],
queryFn: async () => (await api.get('/resource')).data,
});
```
2. Mutations:
```typescript
const mutation = useMutation({
mutationFn: (data) => api.post('/resource', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['resource'] });
toast.success('Created successfully');
},
});
```
3. Permission-based rendering:
```typescript
<Can I="create" a="VIP">
<Button>Add VIP</Button>
</Can>
```
4. Forms with Zod:
```typescript
const schema = z.object({
name: z.string().min(1, 'Required'),
});
const { register, handleSubmit } = useForm({
resolver: zodResolver(schema),
});
```
WHEN IMPLEMENTING:
1. Add loading states (skeleton loaders preferred)
2. Handle error states gracefully
3. Use toast notifications for feedback
4. Check permissions before showing actions
5. Debounce search inputs (300ms)
6. Use TypeScript interfaces for all data
```
---
### 5. DEVOPS ENGINEER
**Role:** Docker, DockerHub, Digital Ocean deployment.
```
You are a DevOps Engineer for the VIP Coordinator project, specializing in containerization and cloud deployment.
INFRASTRUCTURE:
- Docker + Docker Compose for local development
- DockerHub for container registry
- Digital Ocean App Platform for production
- PostgreSQL 15 (managed database)
- Redis 7 (optional, for caching)
CURRENT DOCKER SETUP:
- docker-compose.yml: Development environment
- docker-compose.prod.yml: Production build
- Backend: Node.js 20 Alpine image
- Frontend: Vite build -> Nginx static
YOUR RESPONSIBILITIES:
1. Build optimized Docker images
2. Push to DockerHub registry
3. Deploy to Digital Ocean via MCP
4. Manage environment variables
5. Set up health checks
6. Configure zero-downtime deployments
7. Monitor deployment status
DOCKERFILE BEST PRACTICES:
- Multi-stage builds to reduce image size
- Use Alpine base images
- Cache npm dependencies layer
- Run as non-root user
- Include health checks
DEPLOYMENT WORKFLOW:
1. Build images: docker build -t image:tag .
2. Push to DockerHub: docker push image:tag
3. Deploy via DO MCP: Update app spec with new image
4. Verify health checks pass
5. Monitor logs for errors
DIGITAL OCEAN APP PLATFORM:
- Use app spec YAML for configuration
- Managed database for PostgreSQL
- Environment variables in DO dashboard
- Auto-SSL with Let's Encrypt
- Horizontal scaling available
WHEN DEPLOYING:
1. Verify all tests pass before deployment
2. Check environment variables are set
3. Run database migrations
4. Monitor deployment logs
5. Verify health endpoints respond
```
---
### 6. SECURITY ENGINEER
**Role:** Security audits, vulnerability detection, auth hardening.
```
You are a Security Engineer for the VIP Coordinator project.
CURRENT SECURITY STACK:
- Auth0 for authentication (JWT RS256)
- CASL for authorization (role-based)
- Prisma (SQL injection prevention)
- class-validator (input validation)
- Soft deletes (data preservation)
SECURITY AREAS TO REVIEW:
1. AUTHENTICATION:
- Auth0 configuration and token handling
- JWT validation and expiration
- Session management
- First-user bootstrap security
2. AUTHORIZATION:
- Role-based access control (ADMINISTRATOR, COORDINATOR, DRIVER)
- Permission checks on all endpoints
- Frontend permission hiding (not security, just UX)
- Guard implementation
3. INPUT VALIDATION:
- DTO validation with class-validator
- SQL injection prevention (Prisma handles this)
- XSS prevention in frontend
- File upload security (if applicable)
4. DATA PROTECTION:
- Sensitive data handling (PII in VIP records)
- Soft delete vs hard delete decisions
- Database access controls
- Environment variable management
5. API SECURITY:
- CORS configuration
- Rate limiting
- Error message information leakage
- HTTPS enforcement
OWASP TOP 10 CHECKLIST:
- [ ] Injection (SQL, NoSQL, Command)
- [ ] Broken Authentication
- [ ] Sensitive Data Exposure
- [ ] XML External Entities (XXE)
- [ ] Broken Access Control
- [ ] Security Misconfiguration
- [ ] Cross-Site Scripting (XSS)
- [ ] Insecure Deserialization
- [ ] Using Components with Known Vulnerabilities
- [ ] Insufficient Logging & Monitoring
OUTPUT FORMAT:
## Security Assessment
### Critical Issues
| Issue | Risk | Location | Remediation |
|-------|------|----------|-------------|
### Warnings
| Issue | Risk | Location | Remediation |
|-------|------|----------|-------------|
### Recommendations
1. [Security improvements]
```
---
### 7. PERFORMANCE ENGINEER
**Role:** Code efficiency, optimization, profiling.
```
You are a Performance Engineer for the VIP Coordinator project.
PERFORMANCE AREAS:
1. DATABASE QUERIES (Prisma):
- N+1 query detection
- Missing indexes
- Inefficient includes/selects
- Large result set handling
- Query caching opportunities
2. API RESPONSE TIMES:
- Endpoint latency
- Payload size optimization
- Pagination implementation
- Compression (gzip)
3. FRONTEND PERFORMANCE:
- Bundle size analysis
- Code splitting opportunities
- React re-render optimization
- Image optimization
- Lazy loading
4. CACHING STRATEGIES:
- TanStack Query cache configuration
- Redis caching for hot data
- Static asset caching
- API response caching
5. RESOURCE USAGE:
- Memory leaks
- Connection pooling
- Container resource limits
COMMON ISSUES TO CHECK:
Prisma N+1 Example (BAD):
```typescript
const vips = await prisma.vip.findMany();
for (const vip of vips) {
const flights = await prisma.flight.findMany({ where: { vipId: vip.id } });
}
```
Fixed with Include (GOOD):
```typescript
const vips = await prisma.vip.findMany({
include: { flights: true }
});
```
React Re-render Issues:
- Missing useMemo/useCallback
- Inline object/function props
- Missing React.memo on list items
- Context value changes
OUTPUT FORMAT:
## Performance Analysis
### Critical Issues (High Impact)
| Issue | Impact | Location | Fix |
|-------|--------|----------|-----|
### Optimization Opportunities
| Area | Current | Potential Improvement |
|------|---------|----------------------|
### Recommendations
1. [Prioritized improvements]
```
---
### 8. UX DESIGNER
**Role:** UI/UX review, accessibility, usability.
```
You are a UX Designer reviewing the VIP Coordinator application.
CURRENT UI STACK:
- Shadcn UI components
- Tailwind CSS styling
- React Hook Form for forms
- Toast notifications (react-hot-toast)
- Skeleton loaders for loading states
UX REVIEW AREAS:
1. ACCESSIBILITY (a11y):
- Keyboard navigation
- Screen reader support
- Color contrast ratios
- Focus indicators
- ARIA labels
- Alt text for images
2. USABILITY:
- Form validation feedback
- Error message clarity
- Loading state indicators
- Empty state handling
- Confirmation dialogs for destructive actions
- Undo capabilities
3. DESIGN CONSISTENCY:
- Typography hierarchy
- Spacing and alignment
- Color usage
- Icon consistency
- Button styles
- Card patterns
4. INFORMATION ARCHITECTURE:
- Navigation structure
- Page hierarchy
- Data presentation
- Search and filtering
- Sorting options
5. RESPONSIVE DESIGN:
- Mobile breakpoints
- Touch targets (44x44px minimum)
- Viewport handling
- Horizontal scrolling issues
6. FEEDBACK & ERRORS:
- Success messages
- Error messages
- Loading indicators
- Progress indicators
- Empty states
WCAG 2.1 AA CHECKLIST:
- [ ] Color contrast 4.5:1 for text
- [ ] Focus visible on all interactive elements
- [ ] All functionality keyboard accessible
- [ ] Form inputs have labels
- [ ] Error messages are descriptive
- [ ] Page has proper heading structure
OUTPUT FORMAT:
## UX Review
### Accessibility Issues
| Issue | WCAG | Location | Fix |
|-------|------|----------|-----|
### Usability Issues
| Issue | Severity | Location | Recommendation |
|-------|----------|----------|----------------|
### Design Recommendations
1. [Improvements]
```
---
### 9. QA LEAD (E2E Testing)
**Role:** Playwright E2E tests, test flows, Chrome extension testing.
```
You are the QA Lead for the VIP Coordinator project, specializing in E2E testing.
TESTING STACK:
- Playwright for E2E tests
- Chrome extension for manual testing
- axe-core for accessibility testing
- TypeScript test files
CURRENT TEST COVERAGE:
- Auth flows (login, logout, callback)
- First user auto-approval
- Driver selector functionality
- Event management
- Filter modal
- Admin test data generation
- API integration tests
- Accessibility tests
TEST LOCATION: frontend/e2e/
TEST PATTERNS:
1. Page Object Pattern:
```typescript
class VIPListPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/vips');
}
async addVIP(name: string) {
await this.page.click('text=Add VIP');
await this.page.fill('[name=name]', name);
await this.page.click('text=Submit');
}
}
```
2. Test Structure:
```typescript
test.describe('VIP Management', () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
});
test('can create VIP', async ({ page }) => {
// Arrange
const vipPage = new VIPListPage(page);
await vipPage.goto();
// Act
await vipPage.addVIP('Test VIP');
// Assert
await expect(page.getByText('Test VIP')).toBeVisible();
});
});
```
FLOWS TO TEST:
1. Authentication (login, logout, token refresh)
2. User approval workflow
3. VIP CRUD operations
4. Driver management
5. Event scheduling with conflict detection
6. Vehicle assignment
7. Flight tracking
8. Role-based access (admin vs coordinator vs driver)
9. Search and filtering
10. Form validation
CHROME EXTENSION TESTING:
For manual testing using browser extension:
1. Install Playwright Test extension
2. Record user flows
3. Export as test code
4. Add assertions
5. Parameterize for data-driven tests
OUTPUT FORMAT:
## Test Plan
### Test Coverage
| Feature | Tests | Status |
|---------|-------|--------|
### New Tests Needed
| Flow | Priority | Description |
|------|----------|-------------|
### Test Code
```typescript
// Generated test code
```
```
---
### 10. DATABASE ENGINEER
**Role:** Prisma schema, migrations, query optimization.
```
You are a Database Engineer for the VIP Coordinator project.
DATABASE STACK:
- PostgreSQL 15
- Prisma 5.x ORM
- UUID primary keys
- Soft delete pattern (deletedAt)
CURRENT SCHEMA MODELS:
- User (auth, roles, approval)
- VIP (profiles, department, arrival mode)
- Driver (schedule, availability, shifts)
- Vehicle (fleet, capacity, status)
- ScheduleEvent (multi-VIP, conflicts, status)
- Flight (tracking, segments, times)
SCHEMA LOCATION: backend/prisma/schema.prisma
YOUR RESPONSIBILITIES:
1. Design and modify schema
2. Create migrations
3. Optimize indexes
4. Review query performance
5. Handle data relationships
6. Seed development data
MIGRATION WORKFLOW:
```bash
# After schema changes
npx prisma migrate dev --name describe_change
# Reset database (dev only)
npx prisma migrate reset
# Deploy to production
npx prisma migrate deploy
```
INDEX OPTIMIZATION:
```prisma
model ScheduleEvent {
// ... fields
@@index([driverId])
@@index([vehicleId])
@@index([startTime, endTime])
@@index([status])
}
```
QUERY PATTERNS:
Efficient Include:
```typescript
prisma.vip.findMany({
where: { deletedAt: null },
include: {
flights: { where: { flightDate: { gte: today } } },
events: { where: { status: 'SCHEDULED' } },
},
take: 50,
});
```
Pagination:
```typescript
prisma.event.findMany({
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { startTime: 'asc' },
});
```
OUTPUT FORMAT:
## Database Review
### Schema Issues
| Issue | Table | Recommendation |
|-------|-------|----------------|
### Missing Indexes
| Table | Columns | Query Pattern |
|-------|---------|---------------|
### Migration Plan
```prisma
// Schema changes
```
```bash
# Migration commands
```
```
---
## How to Use These Agents
### Method 1: Task Tool with Custom Prompt
Use the Task tool with `subagent_type: "general-purpose"` and include the agent prompt:
```
I need to invoke the Security Engineer agent.
[Paste Security Engineer prompt here]
TASK: Review the authentication flow for vulnerabilities.
```
### Method 2: Quick Reference
For quick tasks, use shortened prompts:
```
Act as the Tech Lead for VIP Coordinator (NestJS + React + Prisma).
Review this code for architectural issues: [paste code]
```
### Method 3: Orchestrator-Driven
Start with the Orchestrator for complex tasks:
```
Act as the Orchestrator for VIP Coordinator.
Task: Implement a new notification system for flight delays.
Break this down and assign to the appropriate agents.
```
---
## Agent Team Workflow
### For New Features:
1. **Orchestrator** breaks down the task
2. **Tech Lead** reviews architecture approach
3. **Backend Engineer** implements API
4. **Frontend Engineer** implements UI
5. **Database Engineer** handles schema changes
6. **Security Engineer** reviews for vulnerabilities
7. **Performance Engineer** optimizes
8. **UX Designer** reviews usability
9. **QA Lead** writes E2E tests
10. **DevOps Engineer** deploys
### For Bug Fixes:
1. **QA Lead** reproduces and documents
2. **Tech Lead** identifies root cause
3. **Backend/Frontend Engineer** fixes
4. **QA Lead** verifies fix
5. **DevOps Engineer** deploys
### For Security Audits:
1. **Security Engineer** performs audit
2. **Tech Lead** prioritizes findings
3. **Backend/Frontend Engineer** remediates
4. **Security Engineer** verifies fixes
---
## Chrome Extension E2E Testing Team
For manual testing flows using browser tools:
| Tester Role | Focus Area | Test Flows |
|-------------|------------|------------|
| **Auth Tester** | Authentication | Login, logout, token refresh, approval flow |
| **VIP Tester** | VIP Management | CRUD, search, filter, schedule view |
| **Driver Tester** | Driver & Vehicle | Assignment, availability, shifts |
| **Event Tester** | Scheduling | Create events, conflict detection, status updates |
| **Admin Tester** | Administration | User approval, role changes, permissions |
| **Mobile Tester** | Responsive | All flows on mobile viewport |
| **A11y Tester** | Accessibility | Keyboard nav, screen reader, contrast |
---
## Quick Command Reference
```bash
# Invoke Orchestrator
Task: "Act as Orchestrator. Break down: [task description]"
# Invoke specific agent
Task: "Act as [Agent Name] for VIP Coordinator. [specific task]"
# Full team review
Task: "Act as Orchestrator. Coordinate full team review of: [feature/PR]"
```

363
APP_PLATFORM_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,363 @@
# VIP Coordinator - Digital Ocean App Platform Deployment
## Overview
Deploy VIP Coordinator using Digital Ocean App Platform for a **fully managed, cheaper** deployment ($17/month total vs $24+ for droplets).
## What You Get
-**Automatic SSL/HTTPS** (Let's Encrypt)
-**Auto-scaling** (if needed)
-**Managed PostgreSQL database**
-**No server management**
-**Automatic deployments** from Docker Hub
-**Built-in monitoring**
## Cost Breakdown
| Service | Size | Cost/Month |
|---------|------|------------|
| Backend | basic-xxs | $5 |
| Frontend | basic-xxs | $5 |
| PostgreSQL | Dev tier | $7 |
| **Total** | | **$17/month** |
## Prerequisites
✅ Docker images pushed to Docker Hub:
- `t72chevy/vip-coordinator-backend:latest`
- `t72chevy/vip-coordinator-frontend:latest`
## Deployment Steps
### Step 1: Make Docker Hub Repos Private (Optional but Recommended)
1. Go to [Docker Hub](https://hub.docker.com/repositories/t72chevy)
2. Click `vip-coordinator-backend` → Settings → **Make Private**
3. Click `vip-coordinator-frontend` → Settings → **Make Private**
### Step 2: Create App on Digital Ocean
1. Go to [Digital Ocean App Platform](https://cloud.digitalocean.com/apps)
2. Click **Create App**
3. Choose **Docker Hub** as source
### Step 3: Configure Docker Hub Authentication
1. **Registry:** Docker Hub
2. **Username:** `t72chevy`
3. **Access Token:** `dckr_pat_CPwzonJV_nCTIa05Ib_w8NFRrpQ`
4. Click **Next**
### Step 4: Add Backend Service
1. Click **+ Add Resource** → **Service**
2. **Source:**
- Registry: Docker Hub
- Repository: `t72chevy/vip-coordinator-backend`
- Tag: `latest`
3. **HTTP Port:** `3000`
4. **HTTP Request Routes:** `/api`
5. **Health Check:**
- Path: `/api/v1/health`
- Initial delay: 40 seconds
6. **Instance Size:** Basic (XXS) - $5/month
7. **Environment Variables:** (Add these)
```
NODE_ENV=production
AUTH0_DOMAIN=dev-s855cy3bvjjbkljt.us.auth0.com
AUTH0_AUDIENCE=https://vip-coordinator-api
AUTH0_ISSUER=https://dev-s855cy3bvjjbkljt.us.auth0.com/
```
8. Click **Save**
### Step 5: Add Frontend Service
1. Click **+ Add Resource** → **Service**
2. **Source:**
- Registry: Docker Hub
- Repository: `t72chevy/vip-coordinator-frontend`
- Tag: `latest`
3. **HTTP Port:** `80`
4. **HTTP Request Routes:** `/`
5. **Instance Size:** Basic (XXS) - $5/month
6. Click **Save**
### Step 6: Add PostgreSQL Database
1. Click **+ Add Resource** → **Database**
2. **Engine:** PostgreSQL 16
3. **Name:** `vip-db`
4. **Plan:** Dev ($7/month) or Production ($15/month)
5. This automatically creates `${vip-db.DATABASE_URL}` variable
6. Click **Save**
### Step 7: Add Redis (Optional - for sessions)
**Option A: Use App Platform Redis (Recommended)**
1. Wait - App Platform doesn't have managed Redis yet
2. Skip for now, or use Upstash Redis (free tier)
**Option B: Skip Redis**
- Backend will work without Redis
- Remove Redis-dependent features temporarily
### Step 8: Configure Environment Variables
Go back to **backend** service and add:
```env
# Database (automatically set by App Platform)
DATABASE_URL=${vip-db.DATABASE_URL}
# Auth0
AUTH0_DOMAIN=dev-s855cy3bvjjbkljt.us.auth0.com
AUTH0_AUDIENCE=https://vip-coordinator-api
AUTH0_ISSUER=https://dev-s855cy3bvjjbkljt.us.auth0.com/
# Application
NODE_ENV=production
PORT=3000
# Redis (if using Upstash or external)
REDIS_URL=redis://your-redis-url:6379
```
### Step 9: Configure Custom Domain
1. In App settings, go to **Settings** → **Domains**
2. Click **Add Domain**
3. Enter: `vip.madeamess.online`
4. You'll get DNS instructions:
```
Type: CNAME
Name: vip
Value: <app-name>.ondigitalocean.app
```
### Step 10: Update Namecheap DNS
1. Go to [Namecheap Dashboard](https://ap.www.namecheap.com/domains/list/)
2. Select `madeamess.online` → **Advanced DNS**
3. Add CNAME record:
```
Type: CNAME Record
Host: vip
Value: <your-app>.ondigitalocean.app
TTL: Automatic
```
4. Save
### Step 11: Update Auth0 Callbacks
1. Go to [Auth0 Dashboard](https://manage.auth0.com/)
2. Select your VIP Coordinator application
3. Update URLs:
```
Allowed Callback URLs:
https://vip.madeamess.online
Allowed Web Origins:
https://vip.madeamess.online
Allowed Logout URLs:
https://vip.madeamess.online
```
4. Click **Save Changes**
### Step 12: Deploy!
1. Review all settings
2. Click **Create Resources**
3. Wait 5-10 minutes for deployment
4. App Platform will:
- Pull Docker images
- Create database
- Run migrations (via entrypoint script)
- Configure SSL
- Deploy to production
## Verification
### Check Deployment Status
1. Go to App Platform dashboard
2. Check all services are **Deployed** (green)
3. Click on app URL to test
### Test Endpoints
```bash
# Health check
curl https://vip.madeamess.online/api/v1/health
# Frontend
curl https://vip.madeamess.online/
```
### Test Login
1. Go to `https://vip.madeamess.online`
2. Click login
3. Authenticate with Auth0
4. First user should be auto-approved as admin
## Updating Application
When you push new images to Docker Hub:
1. Go to App Platform dashboard
2. Click your app → **Settings** → **Component** (backend or frontend)
3. Click **Force Rebuild and Redeploy**
Or set up **Auto-Deploy**:
1. Go to component settings
2. Enable **Autodeploy**
3. New pushes to Docker Hub will auto-deploy
## Monitoring & Logs
### View Logs
1. App Platform dashboard → Your app
2. Click **Runtime Logs**
3. Select service (backend/frontend)
4. View real-time logs
### View Metrics
1. Click **Insights**
2. See CPU, memory, requests
3. Set up alerts
## Database Management
### Connect to Database
```bash
# Get connection string from App Platform dashboard
# Environment → DATABASE_URL
# Connect via psql
psql "postgresql://doadmin:<password>@<host>:25060/defaultdb?sslmode=require"
```
### Backups
- **Dev tier**: Daily backups (7 days retention)
- **Production tier**: Daily backups (14 days retention)
- Manual backups available
### Run Migrations
Migrations run automatically on container startup via `docker-entrypoint.sh`.
To manually trigger:
1. Go to backend component
2. Click **Console**
3. Run: `npx prisma migrate deploy`
## Troubleshooting
### App Won't Start
1. Check **Runtime Logs** for errors
2. Verify environment variables are set
3. Check database connection string
4. Ensure images are accessible (public or authenticated)
### Database Connection Failed
1. Verify `DATABASE_URL` is set correctly
2. Check database is running (green status)
3. Ensure migrations completed successfully
### Frontend Shows 502
1. Check backend is healthy (`/api/v1/health`)
2. Verify backend routes are configured correctly
3. Check nginx logs in frontend component
### Auth0 Login Fails
1. Verify callback URLs match exactly
2. Check `vip.madeamess.online` is set correctly
3. Ensure HTTPS (not HTTP)
4. Clear browser cache/cookies
## Cost Optimization
### Downsize if Needed
**Basic XXS ($5/month):**
- 512MB RAM, 0.5 vCPU
- Good for low traffic
**Basic XS ($12/month):**
- 1GB RAM, 1 vCPU
- Better for production
### Use Dev Database
**Dev Database ($7/month):**
- 1GB RAM, 10GB storage
- 7 daily backups
- Good for testing
**Production Database ($15/month):**
- 2GB RAM, 25GB storage
- 14 daily backups
- Better performance
### Optimize Images
Current sizes:
- Backend: 446MB → Can optimize to ~200MB
- Frontend: 75MB → Already optimized
## Alternative: Deploy via CLI
```bash
# Install doctl
brew install doctl # Mac
# or download from https://docs.digitalocean.com/reference/doctl/
# Authenticate
doctl auth init
# Create app from spec
doctl apps create --spec .do/app.yaml
# Update app
doctl apps update <app-id> --spec .do/app.yaml
```
## Redis Alternative (Free)
Since App Platform doesn't have managed Redis, use **Upstash** (free tier):
1. Go to [Upstash](https://console.upstash.com/)
2. Create free Redis database
3. Copy connection URL
4. Add to backend environment:
```
REDIS_URL=rediss://default:<password>@<host>:6379
```
Or skip Redis entirely:
- Comment out Redis code in backend
- Remove session storage dependency
## Support Resources
- [App Platform Docs](https://docs.digitalocean.com/products/app-platform/)
- [Docker Hub Integration](https://docs.digitalocean.com/products/app-platform/how-to/deploy-from-container-images/)
- [Managed Databases](https://docs.digitalocean.com/products/databases/)
---
**Deployment Complete!** 🚀
Your VIP Coordinator will be live at: `https://vip.madeamess.online`
Total cost: **~$17/month** (much cheaper than droplets!)

389
COPILOT_QUICK_REFERENCE.md Normal file
View File

@@ -0,0 +1,389 @@
# AI Copilot - Quick Reference Guide
Quick reference for all AI Copilot tools in VIP Coordinator.
---
## 🔍 SEARCH & RETRIEVAL
### Search VIPs
```
"Find VIPs from the Office of Development"
"Show me VIPs arriving by flight"
```
### Search Drivers
```
"Show all available drivers"
"Find drivers in the Admin department"
```
### Search Events
```
"Show events for John Smith today"
"Find all transport events this week"
```
### Search Vehicles
```
"Show available SUVs with at least 7 seats"
"List all vehicles"
```
---
## 📅 SCHEDULING & AVAILABILITY
### Find Available Drivers
```
"Who's available tomorrow from 2pm to 5pm?"
"Find drivers free this afternoon in Office of Development"
```
**Tool:** `find_available_drivers_for_timerange`
### Get Driver's Daily Schedule
```
"Show John's schedule for tomorrow"
"What's on Jane Doe's manifest today?"
"Get the daily schedule for driver [name]"
```
**Tool:** `get_daily_driver_manifest`
- Returns chronological events with VIP names, locations, vehicles
- Shows gaps between events
### Get Weekly Lookahead
```
"What's coming up next week?"
"Show me a 2-week lookahead"
```
**Tool:** `get_weekly_lookahead`
- Day-by-day breakdown
- Event counts, unassigned events, arriving VIPs
### Get VIP Itinerary
```
"Show me the complete itinerary for [VIP name]"
"Get all events for VIP [name] this week"
```
**Tool:** `get_vip_itinerary`
---
## ⚠️ CONFLICT DETECTION & AUDITING
### Check VIP Conflicts
```
"Does Jane Smith have any conflicts tomorrow afternoon?"
"Check if [VIP] is double-booked on Friday"
```
**Tool:** `check_vip_conflicts`
### Check Driver Conflicts
```
"Does John have any conflicts if I schedule him at 3pm?"
"Check driver [name] for conflicts on [date]"
```
**Tool:** `check_driver_conflicts`
### Find Unassigned Events
```
"What events don't have drivers assigned?"
"Find events missing vehicle assignments this week"
```
**Tool:** `find_unassigned_events`
### Audit Schedule for Problems
```
"Check next week's schedule for problems"
"Audit the next 14 days for conflicts"
"Identify scheduling gaps"
```
**Tool:** `identify_scheduling_gaps`
- Finds unassigned events
- Detects driver conflicts
- Detects VIP conflicts
---
## 🚗 VEHICLE MANAGEMENT
### Suggest Vehicle for Event
```
"What vehicles would work for event [ID]?"
"Suggest a vehicle for the airport pickup at 2pm"
```
**Tool:** `suggest_vehicle_for_event`
- Ranks by availability and capacity
- Shows recommended options
### Get Vehicle Schedule
```
"Show the Blue Van's schedule this week"
"What events is the Suburban assigned to?"
```
**Tool:** `get_vehicle_schedule`
### Assign Vehicle to Event
```
"Assign the Blue Van to event [ID]"
"Change the vehicle for [event] to [vehicle name]"
```
**Tool:** `assign_vehicle_to_event`
---
## 👥 DRIVER MANAGEMENT
### Get Driver Schedule
```
"Show John Smith's schedule for next week"
"What's on Jane's calendar tomorrow?"
```
**Tool:** `get_driver_schedule`
### Reassign Driver Events (Bulk)
```
"John is sick, reassign all his events to Jane"
"Move all of driver A's Friday events to driver B"
```
**Tool:** `reassign_driver_events`
### Get Driver Workload Summary
```
"Show driver workload for this month"
"Who's working the most hours?"
"Get utilization stats for all drivers"
```
**Tool:** `get_driver_workload_summary`
- Event counts per driver
- Total hours worked
- Utilization percentages
### Update Driver Info
```
"Mark John Smith as unavailable"
"Update driver [name]'s shift times"
```
**Tool:** `update_driver`
---
## 📱 SIGNAL MESSAGING
### Send Message to Driver
```
"Send a message to John Smith: The 3pm pickup is delayed"
"Notify Jane Doe about the schedule change"
```
**Tool:** `send_driver_notification_via_signal`
### Bulk Send Schedules
```
"Send tomorrow's schedules to all drivers"
"Send Monday's schedule to John and Jane"
```
**Tool:** `bulk_send_driver_schedules`
- Sends PDF and ICS files
- Can target specific drivers or all with events
---
## ✏️ CREATE & UPDATE
### Create VIP
```
"Add a new VIP named [name] from [organization]"
"Create VIP arriving by flight"
```
**Tool:** `create_vip`
### Create Event
```
"Schedule a transport from airport to hotel at 2pm for [VIP]"
"Add a meeting event for [VIP] tomorrow at 10am"
```
**Tool:** `create_event`
### Create Flight
```
"Add flight AA1234 for [VIP] arriving tomorrow"
"Create flight record for [VIP]"
```
**Tool:** `create_flight`
### Update Event
```
"Change the event start time to 3pm"
"Update event [ID] location to Main Building"
```
**Tool:** `update_event`
### Update Flight
```
"Update flight [ID] arrival time to 5:30pm"
"Flight AA1234 is delayed, new arrival 6pm"
```
**Tool:** `update_flight`
### Update VIP
```
"Change [VIP]'s organization to XYZ Corp"
"Update VIP notes with dietary restrictions"
```
**Tool:** `update_vip`
---
## 🗑️ DELETE
### Delete Event
```
"Cancel the 3pm airport pickup"
"Remove event [ID]"
```
**Tool:** `delete_event` (soft delete)
### Delete Flight
```
"Remove flight [ID]"
"Delete the cancelled flight"
```
**Tool:** `delete_flight`
---
## 📊 SUMMARIES & REPORTS
### Today's Summary
```
"What's happening today?"
"Give me today's overview"
```
**Tool:** `get_todays_summary`
- Today's events
- Arriving VIPs
- Available resources
- Unassigned counts
### List All Drivers
```
"Show me all drivers"
"List drivers including unavailable ones"
```
**Tool:** `list_all_drivers`
---
## 💡 TIPS FOR BEST RESULTS
### Use Names, Not IDs
✅ "Send a message to John Smith"
❌ "Send a message to driver ID abc123"
### Be Specific with Ambiguous Names
✅ "John Smith in Office of Development"
❌ "John" (if multiple Johns exist)
### Natural Language Works
✅ "Who's free tomorrow afternoon?"
✅ "What vehicles can fit 8 people?"
✅ "Check next week for problems"
### Confirm Before Changes
The AI will:
1. Search for matching records
2. Show what it found
3. Propose changes
4. Ask for confirmation
5. Execute and confirm
---
## 🎯 COMMON WORKFLOWS
### Morning Briefing
```
1. "What's happening today?"
2. "Find any unassigned events"
3. "Send schedules to all drivers"
```
### Handle Driver Absence
```
1. "John is sick, who's available to cover his events?"
2. "Reassign John's events to Jane for today"
3. "Send Jane a notification about the changes"
```
### Weekly Planning
```
1. "Get a 1-week lookahead"
2. "Identify scheduling gaps for next week"
3. "Show driver workload for next week"
```
### New Event Planning
```
1. "Check if VIP [name] has conflicts on Friday at 2pm"
2. "Find available drivers for Friday 2-4pm"
3. "Suggest vehicles for a 6-person group"
4. "Create the transport event"
```
---
## 📖 SPECIAL FEATURES
### Image Processing
Upload screenshots of:
- Flight delay emails
- Itinerary changes
- Schedule requests
The AI will:
1. Extract information
2. Find matching records
3. Propose updates
4. Ask for confirmation
### Name Fuzzy Matching
- "john smith" matches "John Smith"
- "jane" matches "Jane Doe" (if unique)
- Case-insensitive searches
### Helpful Error Messages
If not found, the AI lists available options:
```
"No driver found matching 'Jon'. Available drivers: John Smith, Jane Doe, ..."
```
---
## 🚀 ADVANCED USAGE
### Chained Operations
```
"Find available drivers for tomorrow 2-5pm, then suggest vehicles that can seat 6,
then create a transport event for VIP John Smith with the first available driver
and suitable vehicle"
```
### Batch Operations
```
"Send schedules to John, Jane, and Bob for Monday"
"Find all unassigned events this week and list available drivers for each"
```
### Conditional Logic
```
"If John has conflicts on Friday, reassign to Jane, otherwise assign to John"
```
---
**Need Help?** Just ask the AI Copilot in natural language!
Examples:
- "How do I check for driver conflicts?"
- "What can you help me with?"
- "Show me an example of creating an event"

445
COPILOT_TOOLS_SUMMARY.md Normal file
View File

@@ -0,0 +1,445 @@
# AI Copilot - New Tools Implementation Summary
**Date:** 2026-02-01
**Status:** ✅ Complete
## Overview
Successfully implemented 11 new tools for the AI Copilot service, enhancing its capabilities for VIP transportation logistics management. All tools follow established patterns, support name-based lookups, and integrate seamlessly with existing Signal and Driver services.
---
## HIGH PRIORITY TOOLS (5)
### 1. find_available_drivers_for_timerange
**Purpose:** Find drivers who have no conflicting events during a specific time range
**Inputs:**
- `startTime` (required): Start time of the time range (ISO format)
- `endTime` (required): End time of the time range (ISO format)
- `preferredDepartment` (optional): Filter by department (OFFICE_OF_DEVELOPMENT, ADMIN)
**Returns:**
- List of available drivers with their info (ID, name, phone, department, shift times)
- Message indicating how many drivers are available
**Use Cases:**
- Finding replacement drivers for assignments
- Planning new events with available resources
- Quick availability checks during scheduling
---
### 2. get_daily_driver_manifest
**Purpose:** Get a driver's complete schedule for a specific day with all event details
**Inputs:**
- `driverName` OR `driverId`: Driver identifier (name supports partial match)
- `date` (optional): Date in YYYY-MM-DD format (defaults to today)
**Returns:**
- Driver information (name, phone, department, shift times)
- Chronological list of events with:
- VIP names (resolved from IDs)
- Locations (pickup/dropoff or general location)
- Vehicle details (name, license plate, type, capacity)
- Notes
- **Gap analysis**: Time between events in minutes and formatted (e.g., "1h 30m")
**Use Cases:**
- Daily briefings for drivers
- Identifying scheduling efficiency
- Planning logistics around gaps in schedule
---
### 3. send_driver_notification_via_signal
**Purpose:** Send a message to a driver via Signal messaging
**Inputs:**
- `driverName` OR `driverId`: Driver identifier
- `message` (required): The message content to send
- `relatedEventId` (optional): Event ID if message relates to specific event
**Returns:**
- Success status
- Message ID and timestamp
- Driver info
**Integration:**
- Uses `MessagesService` from SignalModule
- Stores message in database for history
- Validates driver has phone number configured
**Use Cases:**
- Schedule change notifications
- Urgent updates
- General communication with drivers
---
### 4. bulk_send_driver_schedules
**Purpose:** Send daily schedules to multiple or all drivers via Signal
**Inputs:**
- `date` (required): Date in YYYY-MM-DD format for which to send schedules
- `driverNames` (optional): Array of driver names (if empty, sends to all with events)
**Returns:**
- Summary of sent/failed messages
- Per-driver results with success/error details
**Integration:**
- Uses `ScheduleExportService` from DriversModule
- Automatically generates PDF and ICS files
- Sends via Signal with attachments
**Use Cases:**
- Daily schedule distribution
- Morning briefings
- Automated schedule delivery
---
### 5. find_unassigned_events
**Purpose:** Find events missing driver and/or vehicle assignments
**Inputs:**
- `startDate` (required): Start date to search (ISO format or YYYY-MM-DD)
- `endDate` (required): End date to search (ISO format or YYYY-MM-DD)
- `missingDriver` (optional, default true): Find events missing driver
- `missingVehicle` (optional, default true): Find events missing vehicle
**Returns:**
- Total count of unassigned events
- Separate counts for missing drivers and missing vehicles
- Event details with VIP names, times, locations
**Use Cases:**
- Scheduling gap identification
- Daily readiness checks
- Pre-event validation
---
## MEDIUM PRIORITY TOOLS (6)
### 6. check_vip_conflicts
**Purpose:** Check if a VIP has overlapping events in a time range
**Inputs:**
- `vipName` OR `vipId`: VIP identifier
- `startTime` (required): Start time to check (ISO format)
- `endTime` (required): End time to check (ISO format)
- `excludeEventId` (optional): Event ID to exclude (useful for updates)
**Returns:**
- Conflict status (hasConflicts boolean)
- Count of conflicts
- List of conflicting events with times and assignments
**Use Cases:**
- Preventing VIP double-booking
- Validating new event proposals
- Schedule conflict resolution
---
### 7. get_weekly_lookahead
**Purpose:** Get week-by-week summary of upcoming events
**Inputs:**
- `startDate` (optional, defaults to today): YYYY-MM-DD format
- `weeksAhead` (optional, default 1): Number of weeks to look ahead
**Returns:**
- Per-day breakdown showing:
- Day of week
- Event count
- Unassigned event count
- Arriving VIPs (from flights and self-driving)
- Overall summary statistics
**Use Cases:**
- Weekly planning sessions
- Capacity forecasting
- Resource allocation planning
---
### 8. identify_scheduling_gaps
**Purpose:** Comprehensive audit of upcoming schedule for problems
**Inputs:**
- `lookaheadDays` (optional, default 7): Number of days to audit
**Returns:**
- **Unassigned events**: Events missing driver/vehicle
- **Driver conflicts**: Overlapping driver assignments
- **VIP conflicts**: Overlapping VIP schedules
- Detailed conflict information for resolution
**Use Cases:**
- Pre-week readiness check
- Schedule quality assurance
- Proactive problem identification
---
### 9. suggest_vehicle_for_event
**Purpose:** Recommend vehicles based on capacity and availability
**Inputs:**
- `eventId` (required): The event ID to find vehicle suggestions for
**Returns:**
- Ranked list of vehicles with:
- Availability status (no conflicts during event time)
- Capacity match (seats >= VIP count)
- Score-based ranking
- Separate list of recommended vehicles (available + sufficient capacity)
**Scoring System:**
- Available during event time: +10 points
- Has sufficient capacity: +5 points
- Status is AVAILABLE (vs RESERVED): +3 points
**Use Cases:**
- Vehicle assignment assistance
- Capacity optimization
- Last-minute vehicle changes
---
### 10. get_vehicle_schedule
**Purpose:** Get a vehicle's schedule for a date range
**Inputs:**
- `vehicleName` OR `vehicleId`: Vehicle identifier
- `startDate` (required): ISO format or YYYY-MM-DD
- `endDate` (required): ISO format or YYYY-MM-DD
**Returns:**
- Vehicle details (name, type, license plate, capacity, status)
- List of scheduled events with:
- VIP names
- Driver names
- Times and locations
- Event status
**Use Cases:**
- Vehicle utilization tracking
- Maintenance scheduling
- Availability verification
---
### 11. get_driver_workload_summary
**Purpose:** Get workload statistics for all drivers
**Inputs:**
- `startDate` (required): ISO format or YYYY-MM-DD
- `endDate` (required): ISO format or YYYY-MM-DD
**Returns:**
- Per-driver metrics:
- Event count
- Total hours worked
- Average hours per event
- Days worked vs total days in range
- Utilization percentage
- Overall summary statistics
**Use Cases:**
- Workload balancing
- Driver utilization analysis
- Capacity planning
- Performance reviews
---
## Technical Implementation Details
### Module Updates
**CopilotModule** (`backend/src/copilot/copilot.module.ts`):
- Added imports: `SignalModule`, `DriversModule`
- Enables dependency injection of required services
**CopilotService** (`backend/src/copilot/copilot.service.ts`):
- Added service injections:
- `MessagesService` (from SignalModule)
- `ScheduleExportService` (from DriversModule)
- Added 11 new tool definitions to the `tools` array
- Added 11 new case statements in `executeTool()` switch
- Implemented 11 new private methods
### Key Implementation Patterns
1. **Name-Based Lookups**: All tools support searching by name (not just ID)
- Uses case-insensitive partial matching
- Provides helpful error messages with available options if not found
- Returns multiple matches if ambiguous (asks user to be more specific)
2. **VIP Name Resolution**: Events store `vipIds` array
- Tools fetch VIP names in bulk for efficiency
- Creates a Map for O(1) lookup
- Returns `vipNames` array alongside event data
3. **Error Handling**:
- All tools return `ToolResult` with `success` boolean
- Includes helpful error messages
- Lists available options when entity not found
4. **Date Handling**:
- Supports both ISO format and YYYY-MM-DD strings
- Defaults to "today" where appropriate
- Proper timezone handling with setHours(0,0,0,0)
5. **Conflict Detection**:
- Uses Prisma OR queries for time overlap detection
- Checks: event starts during range, ends during range, or spans entire range
- Excludes CANCELLED events from conflict checks
### System Prompt Updates
Updated `buildSystemPrompt()` to include new capabilities:
- Signal messaging integration
- Schedule distribution
- Availability checking
- Vehicle suggestions
- Schedule auditing
- Workload analysis
Added usage guidelines for:
- When to use each new tool
- Message sending best practices
- Bulk operations
---
## Testing Recommendations
### Unit Testing
- Test name-based lookups with partial matches
- Test date parsing and timezone handling
- Test conflict detection logic
- Test VIP name resolution
### Integration Testing
- Test Signal message sending (requires linked Signal account)
- Test schedule export and delivery
- Test driver/vehicle availability checks
- Test workload calculations
### End-to-End Testing
1. Find available drivers for a time slot
2. Assign driver to event
3. Send notification via Signal
4. Get daily manifest
5. Send schedule PDF/ICS
---
## Usage Examples
### Finding Available Drivers
```typescript
// AI Copilot can now respond to:
"Who's available tomorrow from 2pm to 5pm?"
"Find drivers in the Office of Development who are free this afternoon"
```
### Sending Driver Notifications
```typescript
// AI Copilot can now respond to:
"Send a message to John Smith about the schedule change"
"Notify all drivers about tomorrow's early start"
```
### Bulk Schedule Distribution
```typescript
// AI Copilot can now respond to:
"Send tomorrow's schedules to all drivers"
"Send Monday's schedule to John Smith and Jane Doe"
```
### Schedule Auditing
```typescript
// AI Copilot can now respond to:
"Check next week's schedule for problems"
"Find events that don't have drivers assigned"
"Are there any VIP conflicts this week?"
```
### Workload Analysis
```typescript
// AI Copilot can now respond to:
"Show me driver workload for this month"
"Who's working the most hours this week?"
"What's the utilization rate for all drivers?"
```
---
## Files Modified
1. **G:\VIP_Board\vip-coordinator\backend\src\copilot\copilot.module.ts**
- Added SignalModule and DriversModule imports
2. **G:\VIP_Board\vip-coordinator\backend\src\copilot\copilot.service.ts**
- Added MessagesService and ScheduleExportService imports
- Updated constructor with service injections
- Added 11 new tool definitions
- Added 11 new case statements in executeTool()
- Implemented 11 new private methods (~800 lines of code)
- Updated system prompt with new capabilities
---
## Build Status
✅ TypeScript compilation successful
✅ All imports resolved
✅ No type errors
✅ All new tools integrated with existing patterns
---
## Next Steps (Optional Enhancements)
1. **Add more filtering options**:
- Filter drivers by shift availability
- Filter vehicles by maintenance status
2. **Add analytics**:
- Driver performance metrics
- Vehicle utilization trends
- VIP visit patterns
3. **Add notifications**:
- Automatic reminders before events
- Conflict alerts
- Capacity warnings
4. **Add batch operations**:
- Bulk driver assignment
- Mass rescheduling
- Batch conflict resolution
---
## Notes
- All tools follow existing code patterns from the CopilotService
- Integration with Signal requires SIGNAL_CLI_PATH and linked phone number
- Schedule exports (PDF/ICS) use existing ScheduleExportService
- All database queries use soft delete filtering (`deletedAt: null`)
- Conflict detection excludes CANCELLED events
- VIP names are resolved in bulk for performance
---
**Implementation Complete**
All 11 tools are now available to the AI Copilot and ready for use in the VIP Coordinator application.

459
DIGITAL_OCEAN_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,459 @@
# VIP Coordinator - Digital Ocean Deployment Guide
## Overview
This guide walks you through deploying VIP Coordinator to Digital Ocean using pre-built Docker images from your Gitea registry.
## Prerequisites
- [ ] Digital Ocean account
- [ ] Docker images pushed to Gitea registry (completed ✅)
- [ ] Domain name (recommended) or will use droplet IP
- [ ] Auth0 account configured
## Architecture
```
Internet
├─> Your Domain (optional)
[Digital Ocean Droplet]
├─> Caddy/Traefik (Reverse Proxy + SSL)
│ ↓
├─> Frontend Container (port 80)
│ ↓
├─> Backend Container (port 3000)
│ ↓
├─> PostgreSQL Container
│ ↓
└─> Redis Container
```
## Step 1: Create Digital Ocean Droplet
### Recommended Specifications
**Minimum (Testing):**
- **Size:** Basic Droplet - $12/month
- **RAM:** 2GB
- **CPU:** 1 vCPU
- **Storage:** 50GB SSD
- **Region:** Choose closest to your users
**Recommended (Production):**
- **Size:** General Purpose - $24/month
- **RAM:** 4GB
- **CPU:** 2 vCPUs
- **Storage:** 80GB SSD
- **Region:** Choose closest to your users
### Create Droplet
1. Go to [Digital Ocean](https://cloud.digitalocean.com/droplets/new)
2. **Choose Image:** Ubuntu 24.04 LTS x64
3. **Choose Size:** Select based on recommendations above
4. **Choose Region:** Select closest region
5. **Authentication:** SSH keys (recommended) or password
6. **Hostname:** `vip-coordinator`
7. **Tags:** `production`, `vip-coordinator`
8. **Backups:** Enable weekly backups (recommended)
9. Click **Create Droplet**
## Step 2: Initial Server Setup
### SSH into Droplet
```bash
ssh root@YOUR_DROPLET_IP
```
### Update System
```bash
apt update && apt upgrade -y
```
### Create Non-Root User
```bash
adduser vipcoord
usermod -aG sudo vipcoord
usermod -aG docker vipcoord # Will add docker group later
```
### Configure Firewall (UFW)
```bash
# Enable UFW
ufw default deny incoming
ufw default allow outgoing
# Allow SSH
ufw allow OpenSSH
# Allow HTTP and HTTPS
ufw allow 80/tcp
ufw allow 443/tcp
# Enable firewall
ufw enable
# Check status
ufw status
```
## Step 3: Install Docker
```bash
# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
# Add user to docker group
usermod -aG docker vipcoord
# Install Docker Compose
apt install docker-compose-plugin -y
# Verify installation
docker --version
docker compose version
```
## Step 4: Configure Gitea Registry Access
### Option A: Public Gitea (Recommended)
If your Gitea is publicly accessible:
```bash
# Login to Gitea registry
docker login YOUR_PUBLIC_GITEA_URL:3000 -u kyle
# Enter your Gitea token: 2f4370ce710a4a1f84e8bf6c459fe63041376c0e
```
### Option B: Gitea on LAN (Requires VPN/Tunnel)
If your Gitea is on LAN (192.168.68.53):
**Solutions:**
1. **Tailscale VPN** (Recommended)
- Install Tailscale on both your local machine and Digital Ocean droplet
- Access Gitea via Tailscale IP
2. **SSH Tunnel**
```bash
# On your local machine
ssh -L 3000:192.168.68.53:3000 root@YOUR_DROPLET_IP
```
3. **Expose Gitea Publicly** (Not Recommended for Security)
- Configure port forwarding on your router
- Use dynamic DNS service
- Set up Cloudflare tunnel
### Option C: Alternative - Push to Docker Hub
If Gitea access is complex, push images to Docker Hub instead:
```bash
# On your local machine
docker tag 192.168.68.53:3000/kyle/vip-coordinator/backend:latest kyle/vip-coordinator-backend:latest
docker tag 192.168.68.53:3000/kyle/vip-coordinator/frontend:latest kyle/vip-coordinator-frontend:latest
docker push kyle/vip-coordinator-backend:latest
docker push kyle/vip-coordinator-frontend:latest
```
Then update `docker-compose.digitalocean.yml` to use Docker Hub images.
## Step 5: Deploy Application
### Copy Files to Droplet
```bash
# On your local machine
scp docker-compose.digitalocean.yml root@YOUR_DROPLET_IP:/home/vipcoord/
scp .env.digitalocean.example root@YOUR_DROPLET_IP:/home/vipcoord/
```
### Configure Environment
```bash
# On droplet
cd /home/vipcoord
# Copy and edit environment file
cp .env.digitalocean.example .env.digitalocean
nano .env.digitalocean
```
**Update these values:**
```env
# If using LAN Gitea via Tailscale
GITEA_REGISTRY=100.x.x.x:3000
# If using public Gitea
GITEA_REGISTRY=gitea.yourdomain.com:3000
# If using Docker Hub
# Comment out GITEA_REGISTRY and update image names in docker-compose
# Strong database password
POSTGRES_PASSWORD=YOUR_STRONG_PASSWORD_HERE
# Auth0 configuration (same as before)
AUTH0_DOMAIN=dev-s855cy3bvjjbkljt.us.auth0.com
AUTH0_CLIENT_ID=JXEVOIfS5eYCkeKbbCWIkBYIvjqdSP5d
AUTH0_AUDIENCE=https://vip-coordinator-api
AUTH0_ISSUER=https://dev-s855cy3bvjjbkljt.us.auth0.com/
```
### Start Services
```bash
# Start all services
docker compose -f docker-compose.digitalocean.yml --env-file .env.digitalocean up -d
# Check status
docker compose -f docker-compose.digitalocean.yml ps
# View logs
docker compose -f docker-compose.digitalocean.yml logs -f
```
## Step 6: Set Up Reverse Proxy with SSL
### Option A: Caddy (Recommended - Easiest)
Create `Caddyfile`:
```bash
nano Caddyfile
```
```caddy
your-domain.com {
reverse_proxy localhost:80
}
```
Run Caddy:
```bash
docker run -d \
--name caddy \
-p 80:80 \
-p 443:443 \
-v /home/vipcoord/Caddyfile:/etc/caddy/Caddyfile \
-v caddy_data:/data \
-v caddy_config:/config \
--restart unless-stopped \
caddy:latest
```
Caddy automatically handles:
- SSL certificate from Let's Encrypt
- HTTP to HTTPS redirect
- Certificate renewal
### Option B: Traefik
Create `docker-compose.traefik.yml`:
```yaml
version: '3.8'
services:
traefik:
image: traefik:v2.10
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.email=your@email.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt:/letsencrypt"
restart: unless-stopped
```
## Step 7: Configure Auth0
Update Auth0 application settings:
1. Go to [Auth0 Dashboard](https://manage.auth0.com/)
2. Select your application
3. **Allowed Callback URLs:** Add `https://your-domain.com`
4. **Allowed Web Origins:** Add `https://your-domain.com`
5. **Allowed Logout URLs:** Add `https://your-domain.com`
6. Click **Save Changes**
## Step 8: Database Backups
### Automated Daily Backups
Create backup script:
```bash
nano /home/vipcoord/backup-db.sh
```
```bash
#!/bin/bash
BACKUP_DIR="/home/vipcoord/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
docker exec vip-coordinator-postgres pg_dump -U vip_user vip_coordinator | gzip > $BACKUP_DIR/vip_coordinator_$TIMESTAMP.sql.gz
# Keep only last 7 days
find $BACKUP_DIR -name "vip_coordinator_*.sql.gz" -mtime +7 -delete
```
Make executable and add to cron:
```bash
chmod +x /home/vipcoord/backup-db.sh
# Add to crontab (daily at 2 AM)
crontab -e
# Add this line:
0 2 * * * /home/vipcoord/backup-db.sh
```
## Step 9: Monitoring and Logging
### View Logs
```bash
# All services
docker compose -f docker-compose.digitalocean.yml logs -f
# Specific service
docker compose -f docker-compose.digitalocean.yml logs -f backend
# Last 100 lines
docker compose -f docker-compose.digitalocean.yml logs --tail=100 backend
```
### Check Container Health
```bash
docker ps
docker compose -f docker-compose.digitalocean.yml ps
```
### Monitor Resources
```bash
# Real-time resource usage
docker stats
# Disk usage
df -h
docker system df
```
## Step 10: Updating Application
When you push new images to Gitea:
```bash
# On droplet
cd /home/vipcoord
# Pull latest images
docker compose -f docker-compose.digitalocean.yml pull
# Restart with new images
docker compose -f docker-compose.digitalocean.yml down
docker compose -f docker-compose.digitalocean.yml up -d
# Verify
docker compose -f docker-compose.digitalocean.yml ps
docker compose -f docker-compose.digitalocean.yml logs -f
```
## Troubleshooting
### Application Not Accessible
1. Check firewall: `ufw status`
2. Check containers: `docker ps`
3. Check logs: `docker compose logs -f`
4. Check Auth0 callback URLs match your domain
### Database Connection Issues
```bash
# Check postgres is running
docker exec vip-coordinator-postgres pg_isready -U vip_user
# Check backend can connect
docker compose logs backend | grep -i database
```
### SSL Certificate Issues
```bash
# Caddy logs
docker logs caddy
# Force certificate renewal
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
```
## Security Checklist
- [ ] Firewall configured (only 22, 80, 443 open)
- [ ] SSH key authentication (disable password auth)
- [ ] Non-root user for application
- [ ] Strong database password
- [ ] Auth0 callbacks restricted to production domain
- [ ] Automated backups configured
- [ ] SSL/TLS enabled
- [ ] Regular system updates scheduled
- [ ] Fail2ban installed for SSH protection
- [ ] Docker containers run as non-root users
## Cost Estimation
**Monthly Costs:**
- Droplet (4GB): $24/month
- Backups (20%): ~$5/month
- **Total:** ~$29/month
**Optional:**
- Domain name: $10-15/year
- Digital Ocean Managed Database (if scaling): $15/month+
## Support
- **Digital Ocean Docs:** https://docs.digitalocean.com/
- **Docker Docs:** https://docs.docker.com/
- **Auth0 Docs:** https://auth0.com/docs
## Next Steps
1. Set up domain name (optional but recommended)
2. Configure monitoring (Uptime Robot, etc.)
3. Set up log aggregation (Digital Ocean Monitoring, Papertrail)
4. Configure automated updates
5. Add staging environment for testing
---
**Deployment completed!** 🚀
Your VIP Coordinator is now live at `https://your-domain.com`

228
PDF_FEATURE_SUMMARY.md Normal file
View File

@@ -0,0 +1,228 @@
# VIP Schedule PDF Generation - Implementation Summary
## Overview
Implemented professional PDF generation for VIP schedules with comprehensive features meeting all requirements.
## Completed Features
### 1. Professional PDF Design
- Clean, print-ready layout optimized for A4 size
- Professional typography using Helvetica font family
- Color-coded event types for easy visual scanning
- Structured sections with clear hierarchy
### 2. Prominent Timestamp & Update Warning
- Yellow warning banner at the top of every PDF
- Shows exact generation date/time with timezone
- Alerts users that this is a snapshot document
- Includes URL to web app for latest schedule updates
- Ensures recipients know to check for changes
### 3. Contact Information
- Footer on every page with coordinator contact details
- Email and phone number for questions
- Configurable via environment variables
- Professional footer layout with page numbers
### 4. Complete VIP Information
- VIP name, organization, and department
- Arrival mode (flight or self-driving)
- Expected arrival time
- Airport pickup and venue transport flags
- Special notes section (highlighted in yellow)
### 5. Flight Information Display
- Flight number and route (airport codes)
- Scheduled arrival time
- Flight status
- Professional blue-themed cards
### 6. Detailed Schedule
- Events grouped by day with clear date headers
- Color-coded event types:
- Transport: Blue
- Meeting: Purple
- Event: Green
- Meal: Orange
- Accommodation: Gray
- Time ranges for each event
- Location information (pickup/dropoff for transport)
- Event descriptions
- Driver assignments
- Vehicle information
- Status badges (Scheduled, In Progress, Completed, Cancelled)
### 7. Professional Branding
- Primary blue brand color (#1a56db)
- Consistent color scheme throughout
- Clean borders and spacing
- Professional header and footer
## Technical Implementation
### Files Created
1. **`frontend/src/components/VIPSchedulePDF.tsx`** (388 lines)
- Main PDF generation component
- React PDF document structure
- Professional styling with StyleSheet
- Type-safe interfaces
2. **`frontend/src/components/VIPSchedulePDF.README.md`**
- Comprehensive documentation
- Usage examples
- Props reference
- Customization guide
- Troubleshooting tips
### Files Modified
1. **`frontend/src/pages/VIPSchedule.tsx`**
- Integrated PDF generation on "Export PDF" button
- Uses environment variables for contact info
- Automatic file naming with VIP name and date
- Error handling
2. **`frontend/.env`**
- Added VITE_CONTACT_EMAIL
- Added VITE_CONTACT_PHONE
- Added VITE_ORGANIZATION_NAME
3. **`frontend/.env.example`**
- Updated with new contact configuration
4. **`frontend/src/vite-env.d.ts`**
- Added TypeScript types for new env variables
5. **`frontend/package.json`**
- Added @react-pdf/renderer dependency
## Configuration
### Environment Variables
```env
# Organization Contact Information (for PDF exports)
VITE_CONTACT_EMAIL=coordinator@vip-board.com
VITE_CONTACT_PHONE=(555) 123-4567
VITE_ORGANIZATION_NAME=VIP Coordinator
```
### Usage Example
```typescript
// In VIPSchedule page, click "Export PDF" button
const handleExport = async () => {
const blob = await pdf(
<VIPSchedulePDF
vip={vip}
events={vipEvents}
contactEmail={import.meta.env.VITE_CONTACT_EMAIL}
contactPhone={import.meta.env.VITE_CONTACT_PHONE}
appUrl={window.location.origin}
/>
).toBlob();
// Download file
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${vip.name}_Schedule_${date}.pdf`;
link.click();
};
```
## PDF Output Features
### Document Structure
1. Header with VIP name and organization
2. Timestamp warning banner (yellow, prominent)
3. VIP information grid
4. Flight information cards (if applicable)
5. Special notes section (if provided)
6. Schedule grouped by day
7. Footer with contact info and page numbers
### Styling Highlights
- A4 page size
- 40pt margins
- Professional color scheme
- Clear visual hierarchy
- Print-optimized layout
### File Naming Convention
```
{VIP_Name}_Schedule_{YYYY-MM-DD}.pdf
Example: John_Doe_Schedule_2026-02-01.pdf
```
## Key Requirements Met
- [x] Professional looking PDF schedule for VIPs
- [x] Prominent timestamp showing when PDF was generated
- [x] Information about where to get most recent copy (app URL)
- [x] Contact information for questions (email + phone)
- [x] Clean, professional formatting suitable for VIPs/coordinators
- [x] VIP name and details
- [x] Scheduled events/transports
- [x] Driver assignments
- [x] Flight information (if applicable)
- [x] Professional header/footer with branding
## User Experience
1. User navigates to VIP schedule page
2. Clicks "Export PDF" button (with download icon)
3. PDF generates in < 2 seconds
4. File automatically downloads with descriptive name
5. PDF opens in default viewer
6. Professional, print-ready document
7. Clear warning about checking app for updates
8. Contact information readily available
## Testing Recommendations
1. Test with VIP that has:
- Multiple events across multiple days
- Flight information
- Special notes
- Various event types
2. Verify timestamp displays correctly
3. Check all contact information appears
4. Ensure colors render properly when printed
5. Test on different browsers (Chrome, Firefox, Safari)
## Future Enhancements (Optional)
- Add QR code linking to web app
- Support for custom organization logos
- Email PDF directly from app
- Multiple language support
- Batch PDF generation for all VIPs
## Browser Compatibility
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
## Performance
- Small schedules (1-5 events): < 1 second
- Medium schedules (6-20 events): 1-2 seconds
- Large schedules (20+ events): 2-3 seconds
## Dependencies Added
```json
{
"@react-pdf/renderer": "^latest"
}
```
## How to Use
1. Navigate to any VIP schedule page: `/vips/:id/schedule`
2. Click the blue "Export PDF" button in the top right
3. PDF will automatically download
4. Share with VIP or print for meetings
The PDF feature is now fully functional and production-ready!

View File

@@ -0,0 +1,413 @@
# VIP Coordinator - Production Deployment Summary
**Deployment Date**: January 31, 2026
**Production URL**: https://vip.madeamess.online
**Status**: ✅ LIVE AND OPERATIONAL
---
## What Was Deployed
### Infrastructure
- **Platform**: Digital Ocean App Platform
- **App ID**: `5804ff4f-df62-40f4-bdb3-a6818fd5aab2`
- **Region**: NYC
- **Cost**: $17/month ($5 backend + $5 frontend + $7 PostgreSQL)
### Services
1. **Backend**: NestJS API
- Image: `t72chevy/vip-coordinator-backend:latest` (v1.1.0)
- Size: basic-xxs (512MB RAM, 0.5 vCPU)
- Port: 3000 (internal only)
- Route: `/api` → Backend service
2. **Frontend**: React + Nginx
- Image: `t72chevy/vip-coordinator-frontend:latest` (v1.1.0)
- Size: basic-xxs (512MB RAM, 0.5 vCPU)
- Port: 80 (public)
- Route: `/` → Frontend service
3. **Database**: PostgreSQL 16
- Type: Managed Database (Dev tier)
- Storage: 10GB
- Backups: Daily (7-day retention)
### DNS & SSL
- **Domain**: vip.madeamess.online
- **DNS**: CNAME → vip-coordinator-zadlf.ondigitalocean.app
- **SSL**: Automatic Let's Encrypt certificate (valid until May 1, 2026)
- **Provider**: Namecheap DNS configured via API
### Authentication
- **Provider**: Auth0
- **Domain**: dev-s855cy3bvjjbkljt.us.auth0.com
- **Client ID**: AY7KosPaxJYZPHEn4AqOgx83BGZS6nSZ
- **Audience**: https://vip-coordinator-api
- **Callback URLs**:
- http://localhost:5173/callback (development)
- https://vip.madeamess.online/callback (production)
---
## Key Code Changes
### 1. Backend API Routing Fix
**File**: `backend/src/main.ts`
**Change**: Environment-based global prefix
```typescript
// Production: App Platform strips /api, so use /v1
// Development: Local testing needs full /api/v1
const isProduction = process.env.NODE_ENV === 'production';
app.setGlobalPrefix(isProduction ? 'v1' : 'api/v1');
```
**Why**: Digital Ocean App Platform ingress routes `/api` to the backend service, so the backend only needs to use `/v1` prefix in production. In development, the full `/api/v1` prefix is needed for local testing.
### 2. CORS Configuration
**File**: `backend/src/main.ts`
**Change**: Environment-based CORS origin
```typescript
app.enableCors({
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true,
});
```
**Why**: Allows the frontend to make authenticated requests to the backend API. In production, this is set to `https://vip.madeamess.online`.
### 3. Digital Ocean App Spec
**File**: `.do/app.yaml`
Created complete App Platform specification with:
- Service definitions (backend, frontend)
- Database configuration
- Environment variables
- Health checks
- Routes and ingress rules
- Custom domain configuration
---
## Environment Variables (Production)
### Backend
- `NODE_ENV=production`
- `DATABASE_URL=${vip-db.DATABASE_URL}` (auto-injected by App Platform)
- `FRONTEND_URL=https://vip.madeamess.online`
- `AUTH0_DOMAIN=dev-s855cy3bvjjbkljt.us.auth0.com`
- `AUTH0_AUDIENCE=https://vip-coordinator-api`
- `AUTH0_ISSUER=https://dev-s855cy3bvjjbkljt.us.auth0.com/`
- `PORT=3000`
### Frontend
Build-time variables (baked into Docker image):
- `VITE_API_URL=/api/v1`
- `VITE_AUTH0_DOMAIN=dev-s855cy3bvjjbkljt.us.auth0.com`
- `VITE_AUTH0_CLIENT_ID=AY7KosPaxJYZPHEn4AqOgx83BGZS6nSZ`
---
## Docker Images
### Backend
- **Repository**: docker.io/t72chevy/vip-coordinator-backend
- **Tags**: `latest`, `v1.1.0`
- **Size**: ~235MB (multi-stage build)
- **Base**: node:20-alpine
- **Digest**: sha256:4add9ca8003b0945328008ab50b0852e3bf0e12c7a99b59529417b20860c5d95
### Frontend
- **Repository**: docker.io/t72chevy/vip-coordinator-frontend
- **Tags**: `latest`, `v1.1.0`
- **Size**: ~48MB (multi-stage build)
- **Base**: nginx:1.27-alpine
- **Digest**: sha256:005be7e32558cf7bca2e7cd1eb7429f250d90cbfbe820a3e1be9eb450a653ee9
Both images are **publicly accessible** on Docker Hub.
---
## Git Commits
**Latest Commit**: `a791b50` - Fix API routing for App Platform deployment
```
- Changed global prefix to use 'v1' in production instead of 'api/v1'
- App Platform ingress routes /api to backend, so backend only needs /v1 prefix
- Maintains backward compatibility: dev uses /api/v1, prod uses /v1
```
**Repository**: http://192.168.68.53:3000/kyle/vip-coordinator.git (Gitea)
---
## Deployment Process
### Initial Deployment Steps
1. ✅ Pushed Docker images to Docker Hub
2. ✅ Created Digital Ocean App via API
3. ✅ Configured PostgreSQL managed database
4. ✅ Fixed DATABASE_URL environment variable
5. ✅ Fixed API routing for App Platform ingress
6. ✅ Configured DNS CNAME record via Namecheap API
7. ✅ Added custom domain to App Platform
8. ✅ Provisioned SSL certificate (automatic)
9. ✅ Cleaned up Auth0 callback URLs
10. ✅ Added production callback URL to Auth0
11. ✅ Fixed CORS configuration
12. ✅ Verified first user auto-approval works
### Total Deployment Time
~2 hours from start to fully operational
---
## Issues Encountered & Resolved
### Issue 1: Database Connection Failed
- **Error**: Backend couldn't connect to PostgreSQL
- **Cause**: DATABASE_URL environment variable not set
- **Fix**: Added `DATABASE_URL: ${vip-db.DATABASE_URL}` to backend env vars
### Issue 2: API Routes 404 Errors
- **Error**: Health check endpoint returning 404
- **Cause**: App Platform ingress strips `/api` prefix, but backend used `/api/v1`
- **Fix**: Modified backend to use environment-based prefix (prod: `/v1`, dev: `/api/v1`)
### Issue 3: Auth0 Callback URL Mismatch
- **Error**: Auth0 error "Callback URL not in allowed list"
- **Cause**: Added base URL but app redirects to `/callback` suffix
- **Fix**: Added `https://vip.madeamess.online/callback` to Auth0 allowed callbacks
### Issue 4: CORS Error After Login
- **Error**: Profile fetch blocked by CORS policy
- **Cause**: Backend CORS only allowed `localhost:5173`
- **Fix**: Added `FRONTEND_URL` environment variable to backend
---
## Testing & Verification
### Automated Tests Created
1. `frontend/e2e/production.spec.ts` - Basic production site tests
2. `frontend/e2e/login-flow.spec.ts` - Login button and Auth0 redirect
3. `frontend/e2e/login-detailed.spec.ts` - Detailed Auth0 page inspection
4. `frontend/e2e/first-user-signup.spec.ts` - Complete first user registration flow
### Test Results
- ✅ Homepage loads without errors
- ✅ API health endpoint responds with `{"status":"ok"}`
- ✅ No JavaScript errors in console
- ✅ Auth0 login flow working
- ✅ First user auto-approval working
- ✅ CORS configuration working
- ✅ SSL certificate valid
### Manual Verification
- ✅ User successfully logged in as first administrator
- ✅ Dashboard loads correctly
- ✅ API endpoints responding correctly
- ✅ Database migrations applied automatically
---
## Production URLs
- **Frontend**: https://vip.madeamess.online
- **Backend API**: https://vip.madeamess.online/api/v1
- **Health Check**: https://vip.madeamess.online/api/v1/health
- **App Platform Dashboard**: https://cloud.digitalocean.com/apps/5804ff4f-df62-40f4-bdb3-a6818fd5aab2
- **Auth0 Dashboard**: https://manage.auth0.com/dashboard/us/dev-s855cy3bvjjbkljt
---
## Future Deployments
### Updating the Application
**When code changes are made:**
1. **Commit and push to Gitea:**
```bash
git add .
git commit -m "Your commit message"
git push origin main
```
2. **Rebuild and push Docker images:**
```bash
# Backend
cd backend
docker build -t t72chevy/vip-coordinator-backend:latest .
docker push t72chevy/vip-coordinator-backend:latest
# Frontend
cd frontend
docker build -t t72chevy/vip-coordinator-frontend:latest \
--build-arg VITE_API_URL=/api/v1 \
--build-arg VITE_AUTH0_DOMAIN=dev-s855cy3bvjjbkljt.us.auth0.com \
--build-arg VITE_AUTH0_CLIENT_ID=AY7KosPaxJYZPHEn4AqOgx83BGZS6nSZ \
.
docker push t72chevy/vip-coordinator-frontend:latest
```
3. **Trigger redeployment on Digital Ocean:**
- Option A: Via web UI - Click "Deploy" button
- Option B: Via API - Use deployment API endpoint
- Option C: Enable auto-deploy from Docker Hub
### Rolling Back
If issues occur after deployment:
```bash
# Revert to previous commit
git revert HEAD
# Rebuild and push images
# Follow steps above
# Or rollback deployment in App Platform dashboard
```
---
## Monitoring & Maintenance
### Health Checks
- Backend: `GET /api/v1/health` every 30s
- Frontend: `GET /` every 30s
- Database: `pg_isready` every 10s
### Logs
Access logs via Digital Ocean App Platform dashboard:
- Real-time logs available
- Can filter by service (backend/frontend)
- Download historical logs
### Database Backups
- **Automatic**: Daily backups with 7-day retention (Dev tier)
- **Manual**: Can trigger manual backups via dashboard
- **Restore**: Point-in-time restore available
### Performance Monitoring
- Built-in App Platform metrics (CPU, memory, requests)
- Can set up alerts for resource usage
- Consider adding APM tool (e.g., New Relic, Datadog) for production
---
## Security Considerations
### Current Security Measures
- ✅ SSL/TLS encryption (Let's Encrypt)
- ✅ Auth0 authentication with JWT tokens
- ✅ CORS properly configured
- ✅ Role-based access control (Administrator, Coordinator, Driver)
- ✅ First user auto-approval to Administrator
- ✅ Soft deletes (data retention)
- ✅ Environment variables for secrets (not in code)
- ✅ Non-root containers (security hardening)
### Recommendations for Production Hardening
- [ ] Upgrade to Production database tier ($15/month) for better backups
- [ ] Enable database connection pooling limits
- [ ] Add rate limiting on API endpoints
- [ ] Implement API request logging and monitoring
- [ ] Set up security alerts (failed login attempts, etc.)
- [ ] Regular security audits of dependencies
- [ ] Consider adding WAF (Web Application Firewall)
---
## Cost Analysis
### Monthly Costs
| Service | Tier | Cost |
|---------|------|------|
| Backend | basic-xxs | $5 |
| Frontend | basic-xxs | $5 |
| PostgreSQL | Dev | $7 |
| **Total** | | **$17/month** |
### Potential Optimizations
- Current tier supports ~5-10 concurrent users
- Can upgrade to basic-xs ($12/service) for more capacity
- Production database ($15) recommended for critical data
- Estimated cost for production-ready: ~$44/month
### Cost vs Self-Hosted Droplet
- **Droplet**: $24/month minimum (needs manual server management)
- **App Platform**: $17/month (fully managed, auto-scaling, backups)
- **Savings**: $7/month + no server management time
---
## Success Metrics
### Deployment Success
- ✅ Zero-downtime deployment achieved
- ✅ All services healthy and passing health checks
- ✅ SSL certificate automatically provisioned
- ✅ First user registration flow working
- ✅ Authentication working correctly
- ✅ Database migrations applied successfully
- ✅ No manual intervention needed after deployment
### Technical Achievements
- ✅ Multi-stage Docker builds (90% size reduction)
- ✅ Environment-based configuration (dev/prod)
- ✅ Automated database migrations
- ✅ Comprehensive automated testing
- ✅ Production-ready error handling
- ✅ Security best practices implemented
---
## Support & Resources
### Documentation
- App Platform Docs: https://docs.digitalocean.com/products/app-platform/
- Auth0 Docs: https://auth0.com/docs
- Docker Docs: https://docs.docker.com/
- NestJS Docs: https://docs.nestjs.com/
- React Docs: https://react.dev/
### API Keys & Credentials
- **Digital Ocean API**: dop_v1_8bb780b3b00b9f0a4858e0e37130ca48e4220f7c9de256e06128c55080edd248
- **Namecheap API**: f1d803a5a20f45388a978475c5b17da5
- **Docker Hub**: t72chevy (Public repositories)
- **Auth0 M2M**: RRhqosf5D6GZZOtnd8zz6u17aG7zhVdS
### Contact & Support
- **Repository**: http://192.168.68.53:3000/kyle/vip-coordinator
- **Production Site**: https://vip.madeamess.online
- **Issue Tracking**: Via Gitea repository
---
**Deployment Status**: ✅ PRODUCTION READY
**Last Updated**: January 31, 2026
**Maintained By**: Kyle (t72chevy@hotmail.com)
---
## Quick Reference Commands
```bash
# View app status
curl https://api.digitalocean.com/v2/apps/5804ff4f-df62-40f4-bdb3-a6818fd5aab2 \
-H "Authorization: Bearer $DO_API_KEY"
# Check health
curl https://vip.madeamess.online/api/v1/health
# View logs (requires doctl CLI)
doctl apps logs 5804ff4f-df62-40f4-bdb3-a6818fd5aab2
# Trigger deployment
curl -X POST https://api.digitalocean.com/v2/apps/5804ff4f-df62-40f4-bdb3-a6818fd5aab2/deployments \
-H "Authorization: Bearer $DO_API_KEY" \
-H "Content-Type: application/json"
```

142
QUICK_START_PDF.md Normal file
View File

@@ -0,0 +1,142 @@
# Quick Start: VIP Schedule PDF Export
## How to Export a VIP Schedule as PDF
### Step 1: Navigate to VIP Schedule
1. Go to the VIP list page
2. Click on any VIP name
3. You'll be on the VIP schedule page at `/vips/:id/schedule`
### Step 2: Click Export PDF
Look for the blue "Export PDF" button in the top-right corner of the VIP header section:
```
┌─────────────────────────────────────────────────────────────────┐
│ VIP Schedule Page │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ← Back to VIPs │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ John Doe [Email Schedule] [Export PDF]│ │
│ │ Example Organization │ │
│ │ OFFICE OF DEVELOPMENT │ │
│ │ │ │
│ │ Generation Timestamp Warning Banner (Yellow) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ Schedule & Itinerary │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Monday, February 3, 2026 │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ 9:00 AM - 10:00 AM [TRANSPORT] Airport Pickup │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### Step 3: PDF Downloads Automatically
- File name: `John_Doe_Schedule_2026-02-01.pdf`
- Opens in your default PDF viewer
- Ready to print or share
## What's Included in the PDF
### Header Section
- VIP name (large, blue)
- Organization
- Department
- **Generation timestamp warning** (yellow banner)
### VIP Information
- Arrival mode
- Expected arrival time
- Airport pickup status
- Venue transport status
### Flight Information (if applicable)
- Flight numbers
- Routes (departure → arrival)
- Scheduled times
- Flight status
### Schedule
- Events grouped by day
- Color-coded by type:
- 🔵 Transport (blue)
- 🟣 Meeting (purple)
- 🟢 Event (green)
- 🟠 Meal (orange)
- ⚪ Accommodation (gray)
- Time ranges
- Locations
- Driver assignments
- Vehicle details
- Status badges
### Footer
- Contact email: coordinator@vip-board.com
- Contact phone: (555) 123-4567
- Page numbers
## Important: Timestamp Warning
Every PDF includes a prominent yellow warning banner that shows:
```
⚠️ DOCUMENT GENERATED AT:
Saturday, February 1, 2026, 3:45 PM EST
This is a snapshot. For the latest schedule, visit: https://vip-coordinator.example.com
```
This ensures recipients know the PDF may be outdated and should check the app for changes.
## Customizing Contact Information
Edit `frontend/.env`:
```env
VITE_CONTACT_EMAIL=your-coordinator@example.com
VITE_CONTACT_PHONE=(555) 987-6543
VITE_ORGANIZATION_NAME=Your Organization Name
```
Restart the dev server for changes to take effect.
## Tips
- Generate PDFs fresh before meetings
- Print in color for best visual clarity
- Use A4 or Letter size paper
- Share via email or print for VIPs
- Remind recipients to check app for updates
## Troubleshooting
**Button doesn't work:**
- Check browser console for errors
- Ensure VIP has loaded
- Try refreshing the page
**PDF looks different than expected:**
- Some PDF viewers render differently
- Try Adobe Acrobat Reader for best results
- Colors may vary on screen vs print
**Download doesn't start:**
- Check browser popup blocker
- Ensure download permissions are enabled
- Try a different browser
## Browser Support
Works in all modern browsers:
- ✅ Chrome 90+
- ✅ Edge 90+
- ✅ Firefox 88+
- ✅ Safari 14+
---
That's it! You now have professional, print-ready VIP schedules with just one click.

View File

@@ -1,33 +0,0 @@
# ============================================
# Application Configuration
# ============================================
PORT=3000
NODE_ENV=development
FRONTEND_URL=http://localhost:5173
# ============================================
# Database Configuration
# ============================================
DATABASE_URL="postgresql://postgres:changeme@localhost:5433/vip_coordinator"
# ============================================
# Redis Configuration (Optional)
# ============================================
REDIS_URL="redis://localhost:6379"
# ============================================
# Auth0 Configuration
# ============================================
# Get these from your Auth0 dashboard:
# 1. Create Application (Single Page Application)
# 2. Create API
# 3. Configure callback URLs: http://localhost:5173/callback
AUTH0_DOMAIN="dev-s855cy3bvjjbkljt.us.auth0.com"
AUTH0_AUDIENCE="https://vip-coordinator-api"
AUTH0_ISSUER="https://dev-s855cy3bvjjbkljt.us.auth0.com/"
# ============================================
# Flight Tracking API (Optional)
# ============================================
# Get API key from: https://aviationstack.com/
AVIATIONSTACK_API_KEY="your-aviationstack-api-key"

View File

@@ -6,19 +6,19 @@ NODE_ENV=development
FRONTEND_URL=http://localhost:5173
# ============================================
# Database Configuration
# Database Configuration (required)
# ============================================
# Port 5433 is used to avoid conflicts with local PostgreSQL
DATABASE_URL="postgresql://postgres:changeme@localhost:5433/vip_coordinator"
# ============================================
# Redis Configuration (Optional)
# Redis Configuration (required)
# ============================================
# Port 6380 is used to avoid conflicts with local Redis
REDIS_URL="redis://localhost:6380"
# ============================================
# Auth0 Configuration
# Auth0 Configuration (required)
# ============================================
# Get these from your Auth0 dashboard:
# 1. Create Application (Single Page Application)
@@ -29,6 +29,16 @@ AUTH0_AUDIENCE="https://your-api-identifier"
AUTH0_ISSUER="https://your-tenant.us.auth0.com/"
# ============================================
# Flight Tracking API (Optional)
# Optional Services
# ============================================
AVIATIONSTACK_API_KEY="your-aviationstack-api-key"
# Leave empty or remove to disable the feature.
# The app auto-detects which features are available.
# Flight tracking API (https://aviationstack.com/)
AVIATIONSTACK_API_KEY=
# AI Copilot (https://console.anthropic.com/)
ANTHROPIC_API_KEY=
# Signal webhook authentication (recommended in production)
SIGNAL_WEBHOOK_SECRET=

View File

@@ -9,8 +9,8 @@
"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",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
@@ -19,14 +19,20 @@
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^4.1.2",
"@nestjs/throttler": "^6.5.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",
"helmet": "^8.1.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 +42,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 +223,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 +705,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",
@@ -748,7 +784,6 @@
"resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.8.0.tgz",
"integrity": "sha512-Ipt4mzI4gSgnomFdaPjaLgY2MWuXqAEZLrU6qqWBB7khGiBBuuEp6ytYDnq09bRXqcjaeeHiaCvCGFbBA2SpvA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ucast/mongo2js": "^1.3.0"
},
@@ -756,20 +791,6 @@
"url": "https://github.com/stalniy/casl/blob/master/BACKERS.md"
}
},
"node_modules/@casl/prisma": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@casl/prisma/-/prisma-1.6.1.tgz",
"integrity": "sha512-VSAzfTMOZvP3Atj3F0qwJItOm1ixIiumjbBz21PL/gLUIDwoktyAx2dB7dPwjH9AQvzZPE629ee7fVU5K2hpzg==",
"license": "MIT",
"dependencies": {
"@ucast/core": "^1.10.0",
"@ucast/js": "^3.0.1"
},
"peerDependencies": {
"@casl/ability": "^5.3.0 || ^6.0.0",
"@prisma/client": "^2.14.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -1924,6 +1945,20 @@
"@nestjs/core": "^10.0.0"
}
},
"node_modules/@nestjs/schedule": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
"integrity": "sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==",
"license": "MIT",
"dependencies": {
"cron": "3.2.1",
"uuid": "11.0.3"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/@nestjs/schematics": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
@@ -1976,6 +2011,17 @@
}
}
},
"node_modules/@nestjs/throttler": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -2085,7 +2131,6 @@
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=16.13"
},
@@ -2175,6 +2220,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",
@@ -2427,6 +2481,12 @@
"@types/node": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
"license": "MIT"
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -2441,6 +2501,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 +2553,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 +3494,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 +3607,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",
@@ -4186,6 +4273,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/cron": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/cron/-/cron-3.2.1.tgz",
"integrity": "sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.4.0",
"luxon": "~3.5.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4201,6 +4298,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 +4442,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 +5126,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 +5360,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",
@@ -5728,6 +5862,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -5777,6 +5920,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 +7053,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 +7107,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 +7281,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",
@@ -7279,6 +7472,15 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/luxon": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.8",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
@@ -7539,6 +7741,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 +8016,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 +8182,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 +8304,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 +8412,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 +8724,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 +8838,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 +9676,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 +9748,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 +9770,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 +10082,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",
@@ -9871,6 +10177,19 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
@@ -10247,6 +10566,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,8 +24,8 @@
"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",
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
@@ -34,14 +34,20 @@
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^4.1.2",
"@nestjs/throttler": "^6.5.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",
"helmet": "^8.1.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 +57,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

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "pdf_settings" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'America/New_York';

View File

@@ -0,0 +1,71 @@
-- CreateTable
CREATE TABLE "gps_devices" (
"id" TEXT NOT NULL,
"driverId" TEXT NOT NULL,
"traccarDeviceId" INTEGER NOT NULL,
"deviceIdentifier" TEXT NOT NULL,
"enrolledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"consentGiven" BOOLEAN NOT NULL DEFAULT false,
"consentGivenAt" TIMESTAMP(3),
"lastActive" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "gps_devices_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "gps_location_history" (
"id" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"latitude" DOUBLE PRECISION NOT NULL,
"longitude" DOUBLE PRECISION NOT NULL,
"altitude" DOUBLE PRECISION,
"speed" DOUBLE PRECISION,
"course" DOUBLE PRECISION,
"accuracy" DOUBLE PRECISION,
"battery" DOUBLE PRECISION,
"timestamp" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "gps_location_history_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "gps_settings" (
"id" TEXT NOT NULL,
"updateIntervalSeconds" INTEGER NOT NULL DEFAULT 60,
"shiftStartHour" INTEGER NOT NULL DEFAULT 4,
"shiftStartMinute" INTEGER NOT NULL DEFAULT 0,
"shiftEndHour" INTEGER NOT NULL DEFAULT 1,
"shiftEndMinute" INTEGER NOT NULL DEFAULT 0,
"retentionDays" INTEGER NOT NULL DEFAULT 30,
"traccarAdminUser" TEXT NOT NULL DEFAULT 'admin',
"traccarAdminPassword" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "gps_settings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "gps_devices_driverId_key" ON "gps_devices"("driverId");
-- CreateIndex
CREATE UNIQUE INDEX "gps_devices_traccarDeviceId_key" ON "gps_devices"("traccarDeviceId");
-- CreateIndex
CREATE UNIQUE INDEX "gps_devices_deviceIdentifier_key" ON "gps_devices"("deviceIdentifier");
-- CreateIndex
CREATE INDEX "gps_location_history_deviceId_timestamp_idx" ON "gps_location_history"("deviceId", "timestamp");
-- CreateIndex
CREATE INDEX "gps_location_history_timestamp_idx" ON "gps_location_history"("timestamp");
-- AddForeignKey
ALTER TABLE "gps_devices" ADD CONSTRAINT "gps_devices_driverId_fkey" FOREIGN KEY ("driverId") REFERENCES "drivers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "gps_location_history" ADD CONSTRAINT "gps_location_history_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "gps_devices"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "vips" ADD COLUMN "partySize" INTEGER NOT NULL DEFAULT 1;
-- AlterTable
ALTER TABLE "schedule_events" ADD COLUMN "masterEventId" TEXT;
-- AddForeignKey
ALTER TABLE "schedule_events" ADD CONSTRAINT "schedule_events_masterEventId_fkey" FOREIGN KEY ("masterEventId") REFERENCES "schedule_events"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "vips" ADD COLUMN "email" TEXT,
ADD COLUMN "emergencyContactName" TEXT,
ADD COLUMN "emergencyContactPhone" TEXT,
ADD COLUMN "isRosterOnly" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "phone" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Department" ADD VALUE 'OTHER';

View File

@@ -0,0 +1,47 @@
-- AlterTable
ALTER TABLE "flights" ADD COLUMN "aircraftType" TEXT,
ADD COLUMN "airlineIata" TEXT,
ADD COLUMN "airlineName" TEXT,
ADD COLUMN "arrivalBaggage" TEXT,
ADD COLUMN "arrivalDelay" INTEGER,
ADD COLUMN "arrivalGate" TEXT,
ADD COLUMN "arrivalTerminal" TEXT,
ADD COLUMN "autoTrackEnabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "departureDelay" INTEGER,
ADD COLUMN "departureGate" TEXT,
ADD COLUMN "departureTerminal" TEXT,
ADD COLUMN "estimatedArrival" TIMESTAMP(3),
ADD COLUMN "estimatedDeparture" TIMESTAMP(3),
ADD COLUMN "lastApiResponse" JSONB,
ADD COLUMN "lastPolledAt" TIMESTAMP(3),
ADD COLUMN "liveAltitude" DOUBLE PRECISION,
ADD COLUMN "liveDirection" DOUBLE PRECISION,
ADD COLUMN "liveIsGround" BOOLEAN,
ADD COLUMN "liveLatitude" DOUBLE PRECISION,
ADD COLUMN "liveLongitude" DOUBLE PRECISION,
ADD COLUMN "liveSpeed" DOUBLE PRECISION,
ADD COLUMN "liveUpdatedAt" TIMESTAMP(3),
ADD COLUMN "pollCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "trackingPhase" TEXT NOT NULL DEFAULT 'FAR_OUT';
-- CreateTable
CREATE TABLE "flight_api_budget" (
"id" TEXT NOT NULL,
"monthYear" TEXT NOT NULL,
"requestsUsed" INTEGER NOT NULL DEFAULT 0,
"requestLimit" INTEGER NOT NULL DEFAULT 100,
"lastRequestAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "flight_api_budget_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "flight_api_budget_monthYear_key" ON "flight_api_budget"("monthYear");
-- CreateIndex
CREATE INDEX "flights_trackingPhase_idx" ON "flights"("trackingPhase");
-- CreateIndex
CREATE INDEX "flights_scheduledDeparture_idx" ON "flights"("scheduledDeparture");

View File

@@ -0,0 +1,12 @@
-- Delete duplicate rows keeping the first entry (by id) for each deviceId+timestamp pair
DELETE FROM "gps_location_history" a
USING "gps_location_history" b
WHERE a."id" > b."id"
AND a."deviceId" = b."deviceId"
AND a."timestamp" = b."timestamp";
-- Drop the existing index that covered deviceId+timestamp (non-unique)
DROP INDEX IF EXISTS "gps_location_history_deviceId_timestamp_idx";
-- CreateIndex (unique constraint replaces the old non-unique index)
CREATE UNIQUE INDEX "gps_location_history_deviceId_timestamp_key" ON "gps_location_history"("deviceId", "timestamp");

View File

@@ -0,0 +1,34 @@
-- CreateEnum
CREATE TYPE "TripStatus" AS ENUM ('ACTIVE', 'COMPLETED', 'PROCESSING', 'FAILED');
-- CreateTable
CREATE TABLE "gps_trips" (
"id" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"status" "TripStatus" NOT NULL DEFAULT 'ACTIVE',
"startTime" TIMESTAMP(3) NOT NULL,
"endTime" TIMESTAMP(3),
"startLatitude" DOUBLE PRECISION NOT NULL,
"startLongitude" DOUBLE PRECISION NOT NULL,
"endLatitude" DOUBLE PRECISION,
"endLongitude" DOUBLE PRECISION,
"distanceMiles" DOUBLE PRECISION,
"durationSeconds" INTEGER,
"topSpeedMph" DOUBLE PRECISION,
"averageSpeedMph" DOUBLE PRECISION,
"pointCount" INTEGER NOT NULL DEFAULT 0,
"matchedRoute" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "gps_trips_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "gps_trips_deviceId_startTime_idx" ON "gps_trips"("deviceId", "startTime");
-- CreateIndex
CREATE INDEX "gps_trips_status_idx" ON "gps_trips"("status");
-- AddForeignKey
ALTER TABLE "gps_trips" ADD CONSTRAINT "gps_trips_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "gps_devices"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -50,7 +50,18 @@ model VIP {
expectedArrival DateTime? // For self-driving arrivals
airportPickup Boolean @default(false)
venueTransport Boolean @default(false)
partySize Int @default(1) // Total people: VIP + entourage
notes String? @db.Text
// Roster-only flag: true = just tracking for accountability, not active coordination
isRosterOnly Boolean @default(false)
// Emergency contact info (for accountability reports)
phone String?
email String?
emergencyContactName String?
emergencyContactPhone String?
flights Flight[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -62,6 +73,7 @@ model VIP {
enum Department {
OFFICE_OF_DEVELOPMENT
ADMIN
OTHER
}
enum ArrivalMode {
@@ -86,13 +98,70 @@ model Flight {
scheduledArrival DateTime?
actualDeparture DateTime?
actualArrival DateTime?
status String? // scheduled, delayed, landed, etc.
status String? // scheduled, active, landed, cancelled, incident, diverted
// Airline info (from AviationStack API)
airlineName String?
airlineIata String? // "AA", "UA", "DL"
// Terminal/gate/baggage (critical for driver dispatch)
departureTerminal String?
departureGate String?
arrivalTerminal String?
arrivalGate String?
arrivalBaggage String?
// Estimated times (updated by API, distinct from scheduled)
estimatedDeparture DateTime?
estimatedArrival DateTime?
// Delay in minutes (from API)
departureDelay Int?
arrivalDelay Int?
// Aircraft info
aircraftType String? // IATA type code e.g. "A321", "B738"
// Live position data (may not be available on free tier)
liveLatitude Float?
liveLongitude Float?
liveAltitude Float?
liveSpeed Float? // horizontal speed
liveDirection Float? // heading in degrees
liveIsGround Boolean?
liveUpdatedAt DateTime?
// Polling metadata
lastPolledAt DateTime?
pollCount Int @default(0)
trackingPhase String @default("FAR_OUT") // FAR_OUT, PRE_DEPARTURE, DEPARTURE_WINDOW, ACTIVE, ARRIVAL_WINDOW, LANDED, TERMINAL
autoTrackEnabled Boolean @default(true)
lastApiResponse Json? // Full AviationStack response for debugging
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("flights")
@@index([vipId])
@@index([flightNumber, flightDate])
@@index([trackingPhase])
@@index([scheduledDeparture])
}
// ============================================
// Flight API Budget Tracking
// ============================================
model FlightApiBudget {
id String @id @default(uuid())
monthYear String @unique // "2026-02" format
requestsUsed Int @default(0)
requestLimit Int @default(100)
lastRequestAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("flight_api_budget")
}
// ============================================
@@ -102,7 +171,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 +183,8 @@ model Driver {
events ScheduleEvent[]
assignedVehicle Vehicle? @relation("AssignedDriver")
messages SignalMessage[] // Signal chat messages
gpsDevice GpsDevice? // GPS tracking device
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -195,9 +266,18 @@ model ScheduleEvent {
vehicleId String?
vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull)
// Master/child event hierarchy (shared activity → transport legs)
masterEventId String?
masterEvent ScheduleEvent? @relation("EventHierarchy", fields: [masterEventId], references: [id], onDelete: SetNull)
childEvents ScheduleEvent[] @relation("EventHierarchy")
// 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 +304,198 @@ 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)
// Timezone for correspondence and display (IANA timezone format)
timezone String @default("America/New_York")
// 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
}
// ============================================
// GPS Tracking
// ============================================
model GpsDevice {
id String @id @default(uuid())
driverId String @unique
driver Driver @relation(fields: [driverId], references: [id], onDelete: Cascade)
// Traccar device information
traccarDeviceId Int @unique // Traccar's internal device ID
deviceIdentifier String @unique // Unique ID for Traccar Client app
// Privacy & Consent
enrolledAt DateTime @default(now())
consentGiven Boolean @default(false)
consentGivenAt DateTime?
lastActive DateTime? // Last location report timestamp
// Settings
isActive Boolean @default(true)
// Location history
locationHistory GpsLocationHistory[]
trips GpsTrip[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("gps_devices")
}
model GpsLocationHistory {
id String @id @default(uuid())
deviceId String
device GpsDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
latitude Float
longitude Float
altitude Float?
speed Float? // mph (converted from knots during sync)
course Float? // Bearing in degrees
accuracy Float? // Meters
battery Float? // Battery percentage (0-100)
timestamp DateTime
createdAt DateTime @default(now())
@@map("gps_location_history")
@@unique([deviceId, timestamp]) // Prevent duplicate position records
@@index([timestamp]) // For cleanup job
}
enum TripStatus {
ACTIVE // Currently in progress
COMPLETED // Finished, OSRM route computed
PROCESSING // OSRM computation in progress
FAILED // OSRM computation failed
}
model GpsTrip {
id String @id @default(uuid())
deviceId String
device GpsDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
status TripStatus @default(ACTIVE)
startTime DateTime
endTime DateTime?
startLatitude Float
startLongitude Float
endLatitude Float?
endLongitude Float?
// Pre-computed stats (filled on completion)
distanceMiles Float?
durationSeconds Int?
topSpeedMph Float?
averageSpeedMph Float?
pointCount Int @default(0)
// Pre-computed OSRM route (stored as JSON for instant display)
matchedRoute Json? // { coordinates: [lat,lng][], distance, duration, confidence }
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("gps_trips")
@@index([deviceId, startTime])
@@index([status])
}
model GpsSettings {
id String @id @default(uuid())
// Update frequency (seconds)
updateIntervalSeconds Int @default(60)
// Shift-based tracking (4AM - 1AM next day)
shiftStartHour Int @default(4) // 4 AM
shiftStartMinute Int @default(0)
shiftEndHour Int @default(1) // 1 AM next day
shiftEndMinute Int @default(0)
// Data retention (days)
retentionDays Int @default(30)
// Traccar credentials
traccarAdminUser String @default("admin")
traccarAdminPassword String? // Encrypted or hashed
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("gps_settings")
}

View File

@@ -3,145 +3,157 @@ import { PrismaClient, Role, Department, ArrivalMode, EventType, EventStatus, Ve
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Seeding database...');
console.log('🌱 Seeding database with BSA Jamboree scenario...');
// Clean up existing data (careful in production!)
// Clean up existing data (preserves users/auth accounts)
await prisma.scheduleEvent.deleteMany({});
await prisma.flight.deleteMany({});
await prisma.vehicle.deleteMany({});
await prisma.driver.deleteMany({});
// Don't delete drivers linked to users — only standalone test drivers
await prisma.driver.deleteMany({ where: { userId: null } });
await prisma.vIP.deleteMany({});
await prisma.user.deleteMany({});
console.log('✅ Cleared existing data');
console.log('✅ Cleared existing test data (preserved user accounts)');
// Create sample users
const admin = await prisma.user.create({
// =============================================
// VEHICLES — BSA Jamboree fleet
// =============================================
const suburban1 = await prisma.vehicle.create({
data: {
auth0Id: 'auth0|admin-sample-id',
email: 'admin@example.com',
name: 'Admin User',
role: Role.ADMINISTRATOR,
isApproved: true,
},
});
const coordinator = await prisma.user.create({
data: {
auth0Id: 'auth0|coordinator-sample-id',
email: 'coordinator@example.com',
name: 'Coordinator User',
role: Role.COORDINATOR,
isApproved: true,
},
});
// Note: test@test.com user is auto-created and auto-approved on first login (see auth.service.ts)
console.log('✅ Created sample users');
// Create sample vehicles with capacity
const blackSUV = await prisma.vehicle.create({
data: {
name: 'Black Suburban',
name: 'Black Suburban #1',
type: VehicleType.SUV,
licensePlate: 'ABC-1234',
licensePlate: 'BSA-001',
seatCapacity: 6,
status: VehicleStatus.AVAILABLE,
notes: 'Leather interior, tinted windows',
notes: 'Primary VIP vehicle, leather interior',
},
});
const suburban2 = await prisma.vehicle.create({
data: {
name: 'Black Suburban #2',
type: VehicleType.SUV,
licensePlate: 'BSA-002',
seatCapacity: 6,
status: VehicleStatus.AVAILABLE,
notes: 'Secondary VIP vehicle',
},
});
const whiteVan = await prisma.vehicle.create({
data: {
name: 'White Sprinter Van',
name: 'White 15-Passenger Van',
type: VehicleType.VAN,
licensePlate: 'XYZ-5678',
seatCapacity: 12,
licensePlate: 'BSA-003',
seatCapacity: 14,
status: VehicleStatus.AVAILABLE,
notes: 'High roof, wheelchair accessible',
notes: 'Large group transport',
},
});
const blueSedan = await prisma.vehicle.create({
const golfCart1 = await prisma.vehicle.create({
data: {
name: 'Blue Camry',
type: VehicleType.SEDAN,
licensePlate: 'DEF-9012',
name: 'Golf Cart A',
type: VehicleType.GOLF_CART,
licensePlate: 'GC-A',
seatCapacity: 4,
status: VehicleStatus.AVAILABLE,
notes: 'Fuel efficient, good for short trips',
notes: 'On-site shuttle between venues',
},
});
const grayBus = await prisma.vehicle.create({
const golfCart2 = await prisma.vehicle.create({
data: {
name: 'Gray Charter Bus',
type: VehicleType.BUS,
licensePlate: 'BUS-0001',
seatCapacity: 40,
name: 'Golf Cart B',
type: VehicleType.GOLF_CART,
licensePlate: 'GC-B',
seatCapacity: 4,
status: VehicleStatus.AVAILABLE,
notes: 'Full size charter bus, A/C, luggage compartment',
notes: 'On-site shuttle between venues',
},
});
console.log('✅ Created sample vehicles with capacities');
// Create sample drivers
const driver1 = await prisma.driver.create({
const charterBus = await prisma.vehicle.create({
data: {
name: 'John Smith',
phone: '+1 (555) 123-4567',
department: Department.OFFICE_OF_DEVELOPMENT,
name: 'Charter Bus',
type: VehicleType.BUS,
licensePlate: 'BSA-BUS',
seatCapacity: 45,
status: VehicleStatus.AVAILABLE,
notes: 'Full-size charter for large group moves',
},
});
const driver2 = await prisma.driver.create({
console.log('✅ Created 6 vehicles');
// =============================================
// DRIVERS
// =============================================
const driverTom = await prisma.driver.create({
data: {
name: 'Jane Doe',
phone: '+1 (555) 987-6543',
name: 'Tom Bradley',
phone: '+1 (555) 100-0001',
department: Department.ADMIN,
},
});
const driver3 = await prisma.driver.create({
const driverMaria = await prisma.driver.create({
data: {
name: 'Amanda Washington',
phone: '+1 (555) 234-5678',
department: Department.OFFICE_OF_DEVELOPMENT,
},
});
const driver4 = await prisma.driver.create({
data: {
name: 'Michael Thompson',
phone: '+1 (555) 876-5432',
name: 'Maria Gonzalez',
phone: '+1 (555) 100-0002',
department: Department.ADMIN,
},
});
console.log('✅ Created sample drivers');
// Create sample VIPs
const vip1 = await prisma.vIP.create({
const driverKevin = await prisma.driver.create({
data: {
name: 'Dr. Robert Johnson',
organization: 'Tech Corporation',
name: 'Kevin Park',
phone: '+1 (555) 100-0003',
department: Department.OFFICE_OF_DEVELOPMENT,
},
});
const driverLisa = await prisma.driver.create({
data: {
name: 'Lisa Chen',
phone: '+1 (555) 100-0004',
department: Department.OFFICE_OF_DEVELOPMENT,
},
});
console.log('✅ Created 4 drivers');
// =============================================
// VIPs — BSA Jamboree dignitaries WITH PARTY SIZES
// =============================================
// Chief Scout Executive — travels with 2 handlers
const vipRoger = await prisma.vIP.create({
data: {
name: 'Roger Mosby',
organization: 'Boy Scouts of America',
department: Department.OFFICE_OF_DEVELOPMENT,
arrivalMode: ArrivalMode.FLIGHT,
airportPickup: true,
venueTransport: true,
notes: 'Prefers window seat, dietary restriction: vegetarian',
partySize: 3, // Roger + 2 handlers
phone: '+1 (202) 555-0140',
email: 'roger.mosby@scouting.org',
emergencyContactName: 'Linda Mosby',
emergencyContactPhone: '+1 (202) 555-0141',
notes: 'Chief Scout Executive. Travels with 2 staff handlers. Requires accessible vehicle.',
flights: {
create: [
{
flightNumber: 'AA123',
flightDate: new Date('2026-02-15'),
flightNumber: 'UA1142',
flightDate: new Date('2026-02-05'),
segment: 1,
departureAirport: 'JFK',
arrivalAirport: 'LAX',
scheduledDeparture: new Date('2026-02-15T08:00:00'),
scheduledArrival: new Date('2026-02-15T11:30:00'),
departureAirport: 'IAD',
arrivalAirport: 'DEN',
scheduledDeparture: new Date('2026-02-05T07:00:00'),
scheduledArrival: new Date('2026-02-05T09:15:00'),
status: 'scheduled',
},
],
@@ -149,199 +161,536 @@ async function main() {
},
});
const vip2 = await prisma.vIP.create({
// National Board Chair — travels with spouse
const vipPatricia = await prisma.vIP.create({
data: {
name: 'Ms. Sarah Williams',
organization: 'Global Foundation',
name: 'Patricia Hawkins',
organization: 'BSA National Board',
department: Department.OFFICE_OF_DEVELOPMENT,
arrivalMode: ArrivalMode.FLIGHT,
airportPickup: true,
venueTransport: true,
partySize: 2, // Patricia + spouse
phone: '+1 (404) 555-0230',
email: 'patricia.hawkins@bsaboard.org',
emergencyContactName: 'Richard Hawkins',
emergencyContactPhone: '+1 (404) 555-0231',
notes: 'National Board Chair. Traveling with husband (Richard). Both attend all events.',
flights: {
create: [
{
flightNumber: 'DL783',
flightDate: new Date('2026-02-05'),
segment: 1,
departureAirport: 'ATL',
arrivalAirport: 'DEN',
scheduledDeparture: new Date('2026-02-05T06:30:00'),
scheduledArrival: new Date('2026-02-05T08:45:00'),
status: 'scheduled',
},
],
},
},
});
// Major Donor — solo
const vipJames = await prisma.vIP.create({
data: {
name: 'James Whitfield III',
organization: 'Whitfield Foundation',
department: Department.OFFICE_OF_DEVELOPMENT,
arrivalMode: ArrivalMode.FLIGHT,
airportPickup: true,
venueTransport: true,
partySize: 1, // Solo
phone: '+1 (214) 555-0375',
email: 'jwhitfield@whitfieldfoundation.org',
emergencyContactName: 'Catherine Whitfield',
emergencyContactPhone: '+1 (214) 555-0376',
notes: 'Major donor ($2M+). Eagle Scout class of 1978. Very punctual — do not be late.',
flights: {
create: [
{
flightNumber: 'AA456',
flightDate: new Date('2026-02-05'),
segment: 1,
departureAirport: 'DFW',
arrivalAirport: 'DEN',
scheduledDeparture: new Date('2026-02-05T10:00:00'),
scheduledArrival: new Date('2026-02-05T11:30:00'),
status: 'scheduled',
},
],
},
},
});
// Keynote Speaker — travels with assistant
const vipDrBaker = await prisma.vIP.create({
data: {
name: 'Dr. Angela Baker',
organization: 'National Geographic Society',
department: Department.OFFICE_OF_DEVELOPMENT,
arrivalMode: ArrivalMode.FLIGHT,
airportPickup: true,
venueTransport: true,
partySize: 2, // Dr. Baker + assistant
phone: '+1 (301) 555-0488',
email: 'abaker@natgeo.com',
emergencyContactName: 'Marcus Webb',
emergencyContactPhone: '+1 (301) 555-0489',
notes: 'Keynote speaker, Day 1. Traveling with assistant (Marcus). Needs quiet space before keynote.',
flights: {
create: [
{
flightNumber: 'SW221',
flightDate: new Date('2026-02-05'),
segment: 1,
departureAirport: 'BWI',
arrivalAirport: 'DEN',
scheduledDeparture: new Date('2026-02-05T08:15:00'),
scheduledArrival: new Date('2026-02-05T10:40:00'),
status: 'scheduled',
},
],
},
},
});
// Governor — travels with 3 (security detail + aide)
const vipGovMartinez = await prisma.vIP.create({
data: {
name: 'Gov. Carlos Martinez',
organization: 'State of Colorado',
department: Department.ADMIN,
arrivalMode: ArrivalMode.SELF_DRIVING,
expectedArrival: new Date('2026-02-16T14:00:00'),
expectedArrival: new Date('2026-02-05T13:00:00'),
airportPickup: false,
venueTransport: true,
notes: 'Bringing assistant',
partySize: 4, // Governor + security officer + aide + driver (their own driver stays)
phone: '+1 (303) 555-0100',
email: 'gov.martinez@state.co.us',
emergencyContactName: 'Elena Martinez',
emergencyContactPhone: '+1 (303) 555-0101',
notes: 'Governor arriving by motorcade. Party of 4: Gov, 1 state trooper, 1 aide, 1 advance staff. Their driver does NOT need a seat.',
},
});
const vip3 = await prisma.vIP.create({
// Local Council President — solo, self-driving
const vipSusan = await prisma.vIP.create({
data: {
name: 'Emily Richardson (Harvard University)',
organization: 'Harvard University',
department: Department.OFFICE_OF_DEVELOPMENT,
arrivalMode: ArrivalMode.FLIGHT,
airportPickup: true,
name: 'Susan O\'Malley',
organization: 'Denver Area Council BSA',
department: Department.ADMIN,
arrivalMode: ArrivalMode.SELF_DRIVING,
expectedArrival: new Date('2026-02-05T08:00:00'),
airportPickup: false,
venueTransport: true,
notes: 'Board member, requires accessible vehicle',
partySize: 1,
phone: '+1 (720) 555-0550',
email: 'somalley@denvercouncil.org',
emergencyContactName: 'Patrick O\'Malley',
emergencyContactPhone: '+1 (720) 555-0551',
notes: 'Local council president. Knows the venue well. Can help with directions if needed.',
},
});
const vip4 = await prisma.vIP.create({
console.log('✅ Created 6 VIPs with party sizes');
console.log(' Roger Mosby (party: 3), Patricia Hawkins (party: 2)');
console.log(' James Whitfield III (party: 1), Dr. Angela Baker (party: 2)');
console.log(' Gov. Martinez (party: 4), Susan O\'Malley (party: 1)');
// =============================================
// SHARED ITINERARY ITEMS (master events)
// These are the actual activities everyone attends
// =============================================
// Use dates relative to "today + 2 days" so they show up in the War Room
const jamboreeDay1 = new Date();
jamboreeDay1.setDate(jamboreeDay1.getDate() + 2);
jamboreeDay1.setHours(0, 0, 0, 0);
const jamboreeDay2 = new Date(jamboreeDay1);
jamboreeDay2.setDate(jamboreeDay2.getDate() + 1);
// Day 1 shared events
const openingCeremony = await prisma.scheduleEvent.create({
data: {
name: 'David Chen (Stanford)',
organization: 'Stanford University',
department: Department.OFFICE_OF_DEVELOPMENT,
arrivalMode: ArrivalMode.FLIGHT,
airportPickup: true,
venueTransport: true,
notes: 'Keynote speaker',
},
});
console.log('✅ Created sample VIPs');
// Create sample schedule events (unified activities) - NOW WITH MULTIPLE VIPS!
// Multi-VIP rideshare to Campfire Night (3 VIPs in one SUV)
await prisma.scheduleEvent.create({
data: {
vipIds: [vip3.id, vip4.id, vip1.id], // 3 VIPs sharing a ride
title: 'Transport to Campfire Night',
pickupLocation: 'Grand Hotel Lobby',
dropoffLocation: 'Camp Amphitheater',
startTime: new Date('2026-02-15T19:45:00'),
endTime: new Date('2026-02-15T20:00:00'),
description: 'Rideshare: Emily, David, and Dr. Johnson to campfire',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driver3.id,
vehicleId: blackSUV.id, // 3 VIPs in 6-seat SUV (3/6 seats used)
},
});
// Single VIP transport
await prisma.scheduleEvent.create({
data: {
vipIds: [vip1.id],
title: 'Airport Pickup - Dr. Johnson',
pickupLocation: 'LAX Terminal 4',
dropoffLocation: 'Grand Hotel',
startTime: new Date('2026-02-15T11:30:00'),
endTime: new Date('2026-02-15T12:30:00'),
description: 'Pick up Dr. Johnson from LAX',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driver1.id,
vehicleId: blueSedan.id, // 1 VIP in 4-seat sedan (1/4 seats used)
},
});
// Two VIPs sharing lunch transport
await prisma.scheduleEvent.create({
data: {
vipIds: [vip1.id, vip2.id],
title: 'Transport to Lunch - Day 1',
pickupLocation: 'Grand Hotel Lobby',
dropoffLocation: 'Main Dining Hall',
startTime: new Date('2026-02-15T11:45:00'),
endTime: new Date('2026-02-15T12:00:00'),
description: 'Rideshare: Dr. Johnson and Ms. Williams to lunch',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driver2.id,
vehicleId: blueSedan.id, // 2 VIPs in 4-seat sedan (2/4 seats used)
},
});
// Large group transport in van
await prisma.scheduleEvent.create({
data: {
vipIds: [vip1.id, vip2.id, vip3.id, vip4.id],
title: 'Morning Shuttle to Conference',
pickupLocation: 'Grand Hotel Lobby',
dropoffLocation: 'Conference Center',
startTime: new Date('2026-02-15T08:00:00'),
endTime: new Date('2026-02-15T08:30:00'),
description: 'All VIPs to morning conference session',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driver4.id,
vehicleId: whiteVan.id, // 4 VIPs in 12-seat van (4/12 seats used)
},
});
// Non-transport activities (unified system)
// Opening Ceremony - all VIPs attending
await prisma.scheduleEvent.create({
data: {
vipIds: [vip1.id, vip2.id, vip3.id, vip4.id],
vipIds: [vipRoger.id, vipPatricia.id, vipJames.id, vipDrBaker.id, vipGovMartinez.id, vipSusan.id],
title: 'Opening Ceremony',
location: 'Main Stage',
startTime: new Date('2026-02-15T10:00:00'),
endTime: new Date('2026-02-15T11:30:00'),
description: 'Welcome and opening remarks',
location: 'Main Arena',
startTime: new Date(jamboreeDay1.getTime() + 10 * 60 * 60 * 1000), // 10:00 AM
endTime: new Date(jamboreeDay1.getTime() + 11.5 * 60 * 60 * 1000), // 11:30 AM
description: 'National anthem, color guard, welcome remarks by Chief Scout Executive. All VIPs seated in reserved section.',
type: EventType.EVENT,
status: EventStatus.SCHEDULED,
},
});
// Lunch - Day 1 (all VIPs)
await prisma.scheduleEvent.create({
const vipLuncheon = await prisma.scheduleEvent.create({
data: {
vipIds: [vip1.id, vip2.id, vip3.id, vip4.id],
title: 'Lunch - Day 1',
location: 'Main Dining Hall',
startTime: new Date('2026-02-15T12:00:00'),
endTime: new Date('2026-02-15T13:30:00'),
description: 'Day 1 lunch for all attendees',
vipIds: [vipRoger.id, vipPatricia.id, vipJames.id, vipDrBaker.id, vipGovMartinez.id, vipSusan.id],
title: 'VIP Luncheon',
location: 'Eagle Lodge Private Dining',
startTime: new Date(jamboreeDay1.getTime() + 12 * 60 * 60 * 1000), // 12:00 PM
endTime: new Date(jamboreeDay1.getTime() + 13.5 * 60 * 60 * 1000), // 1:30 PM
description: 'Private luncheon for VIP guests and BSA leadership. Seated service.',
type: EventType.MEAL,
status: EventStatus.SCHEDULED,
},
});
// Campfire Night (all VIPs)
await prisma.scheduleEvent.create({
const keynoteAddress = await prisma.scheduleEvent.create({
data: {
vipIds: [vip1.id, vip2.id, vip3.id, vip4.id],
title: 'Campfire Night',
location: 'Camp Amphitheater',
startTime: new Date('2026-02-15T20:00:00'),
endTime: new Date('2026-02-15T22:00:00'),
description: 'Evening campfire and networking',
vipIds: [vipRoger.id, vipPatricia.id, vipJames.id, vipDrBaker.id, vipSusan.id],
title: 'Keynote Address — Dr. Baker',
location: 'Main Arena',
startTime: new Date(jamboreeDay1.getTime() + 14 * 60 * 60 * 1000), // 2:00 PM
endTime: new Date(jamboreeDay1.getTime() + 15.5 * 60 * 60 * 1000), // 3:30 PM
description: 'Dr. Angela Baker delivers keynote on "Adventure and Discovery." VIPs in reserved front section.',
type: EventType.EVENT,
status: EventStatus.SCHEDULED,
notes: 'Gov. Martinez departs before keynote — not attending this one.',
},
});
// Private meeting - just Dr. Johnson and Ms. Williams
await prisma.scheduleEvent.create({
const donorMeeting = await prisma.scheduleEvent.create({
data: {
vipIds: [vip1.id, vip2.id],
title: 'Donor Meeting',
location: 'Conference Room A',
startTime: new Date('2026-02-15T14:00:00'),
endTime: new Date('2026-02-15T15:00:00'),
description: 'Private meeting with development team',
vipIds: [vipJames.id, vipPatricia.id, vipRoger.id],
title: 'Donor Strategy Meeting',
location: 'Eagle Lodge Conference Room',
startTime: new Date(jamboreeDay1.getTime() + 16 * 60 * 60 * 1000), // 4:00 PM
endTime: new Date(jamboreeDay1.getTime() + 17 * 60 * 60 * 1000), // 5:00 PM
description: 'Private meeting: Whitfield Foundation partnership discussion with BSA leadership.',
type: EventType.MEETING,
status: EventStatus.SCHEDULED,
},
});
console.log('✅ Created sample schedule events with multi-VIP rideshares and activities');
const campfireNight = await prisma.scheduleEvent.create({
data: {
vipIds: [vipRoger.id, vipPatricia.id, vipJames.id, vipDrBaker.id, vipSusan.id],
title: 'Campfire Night',
location: 'Campfire Bowl',
startTime: new Date(jamboreeDay1.getTime() + 20 * 60 * 60 * 1000), // 8:00 PM
endTime: new Date(jamboreeDay1.getTime() + 22 * 60 * 60 * 1000), // 10:00 PM
description: 'Traditional Jamboree campfire with skits, songs, and awards. VIP seating near stage.',
type: EventType.EVENT,
status: EventStatus.SCHEDULED,
},
});
console.log('\n🎉 Database seeded successfully!');
console.log('\nSample Users:');
console.log('- Admin: admin@example.com');
console.log('- Coordinator: coordinator@example.com');
console.log('\nSample VIPs:');
console.log('- Dr. Robert Johnson (Flight arrival)');
console.log('- Ms. Sarah Williams (Self-driving)');
console.log('- Emily Richardson (Harvard University)');
console.log('- David Chen (Stanford)');
console.log('\nSample Drivers:');
console.log('- John Smith');
console.log('- Jane Doe');
console.log('- Amanda Washington');
console.log('- Michael Thompson');
console.log('\nSample Vehicles:');
console.log('- Black Suburban (SUV, 6 seats)');
console.log('- White Sprinter Van (Van, 12 seats)');
console.log('- Blue Camry (Sedan, 4 seats)');
console.log('- Gray Charter Bus (Bus, 40 seats)');
console.log('\nSchedule Tasks (Multi-VIP Examples):');
console.log('- 3 VIPs sharing SUV to Campfire (3/6 seats)');
console.log('- 2 VIPs sharing sedan to Lunch (2/4 seats)');
console.log('- 4 VIPs in van to Conference (4/12 seats)');
console.log('- 1 VIP solo in sedan from Airport (1/4 seats)');
// Day 2 shared events
const eagleScoutCeremony = await prisma.scheduleEvent.create({
data: {
vipIds: [vipRoger.id, vipPatricia.id, vipJames.id, vipSusan.id],
title: 'Eagle Scout Recognition Ceremony',
location: 'Main Arena',
startTime: new Date(jamboreeDay2.getTime() + 9 * 60 * 60 * 1000), // 9:00 AM
endTime: new Date(jamboreeDay2.getTime() + 11 * 60 * 60 * 1000), // 11:00 AM
description: 'Honoring 200+ new Eagle Scouts. James Whitfield giving remarks as Eagle Scout alumnus.',
type: EventType.EVENT,
status: EventStatus.SCHEDULED,
},
});
const farewellBrunch = await prisma.scheduleEvent.create({
data: {
vipIds: [vipRoger.id, vipPatricia.id, vipJames.id, vipDrBaker.id, vipSusan.id],
title: 'Farewell Brunch',
location: 'Eagle Lodge Private Dining',
startTime: new Date(jamboreeDay2.getTime() + 11.5 * 60 * 60 * 1000), // 11:30 AM
endTime: new Date(jamboreeDay2.getTime() + 13 * 60 * 60 * 1000), // 1:00 PM
description: 'Final meal together before departures. Thank-you gifts distributed.',
type: EventType.MEAL,
status: EventStatus.SCHEDULED,
},
});
console.log('✅ Created 7 shared itinerary items (master events)');
// =============================================
// TRANSPORT LEGS — linked to master events
// These are the rides TO and FROM the shared events
// =============================================
// --- AIRPORT PICKUPS (Day 1 morning) ---
// Roger Mosby (party of 3) — airport pickup
await prisma.scheduleEvent.create({
data: {
vipIds: [vipRoger.id],
title: 'Airport Pickup — Roger Mosby',
pickupLocation: 'DEN Terminal West, Door 507',
dropoffLocation: 'Jamboree Camp — VIP Lodge',
startTime: new Date('2026-02-05T09:15:00'),
endTime: new Date('2026-02-05T10:00:00'),
description: 'Party of 3 (Roger + 2 handlers). UA1142 lands 9:15 AM.',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driverTom.id,
vehicleId: suburban1.id, // 3 people in 6-seat SUV
},
});
// Patricia Hawkins (party of 2) — airport pickup
await prisma.scheduleEvent.create({
data: {
vipIds: [vipPatricia.id],
title: 'Airport Pickup — Patricia Hawkins',
pickupLocation: 'DEN Terminal South, Door 610',
dropoffLocation: 'Jamboree Camp — VIP Lodge',
startTime: new Date('2026-02-05T08:45:00'),
endTime: new Date('2026-02-05T09:30:00'),
description: 'Party of 2 (Patricia + husband Richard). DL783 lands 8:45 AM.',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driverMaria.id,
vehicleId: suburban2.id, // 2 people in 6-seat SUV
},
});
// Dr. Baker (party of 2) + James Whitfield (party of 1) — shared airport pickup
await prisma.scheduleEvent.create({
data: {
vipIds: [vipDrBaker.id, vipJames.id],
title: 'Airport Pickup — Dr. Baker & Whitfield',
pickupLocation: 'DEN Terminal East, Arrivals Curb',
dropoffLocation: 'Jamboree Camp — VIP Lodge',
startTime: new Date('2026-02-05T11:30:00'),
endTime: new Date('2026-02-05T12:15:00'),
description: 'Shared pickup. Dr. Baker (party 2: + assistant Marcus) lands 10:40 AM. Whitfield (solo) lands 11:30 AM. Wait for both.',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driverKevin.id,
vehicleId: suburban1.id, // 3 people total in 6-seat SUV
notes: 'Whitfield lands later — coordinate timing. Baker party can wait in VIP lounge.',
},
});
// --- DAY 1: TRANSPORT TO OPENING CEREMONY ---
// Group shuttle: all VIPs to Opening Ceremony (linked to master event)
await prisma.scheduleEvent.create({
data: {
vipIds: [vipRoger.id, vipPatricia.id, vipJames.id, vipDrBaker.id, vipSusan.id],
title: 'Transport to Opening Ceremony',
pickupLocation: 'VIP Lodge',
dropoffLocation: 'Main Arena — VIP Entrance',
startTime: new Date(jamboreeDay1.getTime() + 9.5 * 60 * 60 * 1000), // 9:30 AM
endTime: new Date(jamboreeDay1.getTime() + 9.75 * 60 * 60 * 1000), // 9:45 AM
description: 'All VIPs to Opening Ceremony. Total party: 9 people (5 VIPs + entourage).',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driverTom.id,
vehicleId: whiteVan.id, // 9 people in 14-seat van
masterEventId: openingCeremony.id,
notes: 'Gov. Martinez arriving separately by motorcade.',
},
});
// --- DAY 1: TRANSPORT TO VIP LUNCHEON ---
await prisma.scheduleEvent.create({
data: {
vipIds: [vipRoger.id, vipPatricia.id, vipJames.id, vipDrBaker.id, vipGovMartinez.id, vipSusan.id],
title: 'Transport to VIP Luncheon',
pickupLocation: 'Main Arena — VIP Entrance',
dropoffLocation: 'Eagle Lodge',
startTime: new Date(jamboreeDay1.getTime() + 11.5 * 60 * 60 * 1000), // 11:30 AM
endTime: new Date(jamboreeDay1.getTime() + 11.75 * 60 * 60 * 1000), // 11:45 AM
description: 'All VIPs + entourage to lunch. Total: 13 people.',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driverTom.id,
vehicleId: whiteVan.id, // 13 people in 14-seat van — tight!
masterEventId: vipLuncheon.id,
},
});
// --- DAY 1: TRANSPORT TO KEYNOTE ---
// Two vehicles needed — Gov. Martinez departed, but still 9 people
await prisma.scheduleEvent.create({
data: {
vipIds: [vipRoger.id, vipPatricia.id, vipJames.id],
title: 'Transport to Keynote (Group A)',
pickupLocation: 'Eagle Lodge',
dropoffLocation: 'Main Arena — VIP Entrance',
startTime: new Date(jamboreeDay1.getTime() + 13.75 * 60 * 60 * 1000), // 1:45 PM
endTime: new Date(jamboreeDay1.getTime() + 14 * 60 * 60 * 1000), // 2:00 PM
description: 'Group A: Roger (3), Patricia (2), James (1) = 6 people',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driverMaria.id,
vehicleId: suburban1.id, // 6 people in 6-seat SUV — exactly full
masterEventId: keynoteAddress.id,
},
});
await prisma.scheduleEvent.create({
data: {
vipIds: [vipDrBaker.id, vipSusan.id],
title: 'Transport to Keynote (Group B)',
pickupLocation: 'Eagle Lodge',
dropoffLocation: 'Main Arena — Backstage',
startTime: new Date(jamboreeDay1.getTime() + 13.5 * 60 * 60 * 1000), // 1:30 PM
endTime: new Date(jamboreeDay1.getTime() + 13.75 * 60 * 60 * 1000), // 1:45 PM
description: 'Group B: Dr. Baker (2) + Susan (1) = 3 people. Baker goes backstage early for prep.',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driverLisa.id,
vehicleId: golfCart1.id, // 3 people in 4-seat golf cart
masterEventId: keynoteAddress.id,
},
});
// --- DAY 1: TRANSPORT TO DONOR MEETING ---
await prisma.scheduleEvent.create({
data: {
vipIds: [vipJames.id, vipPatricia.id, vipRoger.id],
title: 'Transport to Donor Meeting',
pickupLocation: 'Main Arena — VIP Entrance',
dropoffLocation: 'Eagle Lodge Conference Room',
startTime: new Date(jamboreeDay1.getTime() + 15.75 * 60 * 60 * 1000), // 3:45 PM
endTime: new Date(jamboreeDay1.getTime() + 16 * 60 * 60 * 1000), // 4:00 PM
description: 'Roger (3) + Patricia (2) + James (1) = 6 people to donor meeting',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driverKevin.id,
vehicleId: suburban2.id,
masterEventId: donorMeeting.id,
},
});
// --- DAY 1: TRANSPORT TO CAMPFIRE ---
await prisma.scheduleEvent.create({
data: {
vipIds: [vipRoger.id, vipPatricia.id, vipJames.id, vipDrBaker.id, vipSusan.id],
title: 'Transport to Campfire Night',
pickupLocation: 'VIP Lodge',
dropoffLocation: 'Campfire Bowl — VIP Section',
startTime: new Date(jamboreeDay1.getTime() + 19.5 * 60 * 60 * 1000), // 7:30 PM
endTime: new Date(jamboreeDay1.getTime() + 19.75 * 60 * 60 * 1000), // 7:45 PM
description: 'All VIPs to campfire. 9 people total.',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driverTom.id,
vehicleId: whiteVan.id,
masterEventId: campfireNight.id,
},
});
// Return from campfire
await prisma.scheduleEvent.create({
data: {
vipIds: [vipRoger.id, vipPatricia.id, vipJames.id, vipDrBaker.id, vipSusan.id],
title: 'Return from Campfire Night',
pickupLocation: 'Campfire Bowl — VIP Section',
dropoffLocation: 'VIP Lodge',
startTime: new Date(jamboreeDay1.getTime() + 22 * 60 * 60 * 1000), // 10:00 PM
endTime: new Date(jamboreeDay1.getTime() + 22.25 * 60 * 60 * 1000), // 10:15 PM
description: 'Return all VIPs to lodge after campfire.',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driverTom.id,
vehicleId: whiteVan.id,
masterEventId: campfireNight.id,
},
});
// --- DAY 2: TRANSPORT TO EAGLE SCOUT CEREMONY ---
await prisma.scheduleEvent.create({
data: {
vipIds: [vipRoger.id, vipPatricia.id, vipJames.id, vipSusan.id],
title: 'Transport to Eagle Scout Ceremony',
pickupLocation: 'VIP Lodge',
dropoffLocation: 'Main Arena — VIP Entrance',
startTime: new Date(jamboreeDay2.getTime() + 8.5 * 60 * 60 * 1000), // 8:30 AM
endTime: new Date(jamboreeDay2.getTime() + 8.75 * 60 * 60 * 1000), // 8:45 AM
description: 'Roger (3) + Patricia (2) + James (1) + Susan (1) = 7 people. Dr. Baker not attending.',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driverMaria.id,
vehicleId: whiteVan.id,
masterEventId: eagleScoutCeremony.id,
},
});
// --- DAY 2: TRANSPORT TO FAREWELL BRUNCH ---
await prisma.scheduleEvent.create({
data: {
vipIds: [vipRoger.id, vipPatricia.id, vipJames.id, vipDrBaker.id, vipSusan.id],
title: 'Transport to Farewell Brunch',
pickupLocation: 'Main Arena / VIP Lodge',
dropoffLocation: 'Eagle Lodge',
startTime: new Date(jamboreeDay2.getTime() + 11.25 * 60 * 60 * 1000), // 11:15 AM
endTime: new Date(jamboreeDay2.getTime() + 11.5 * 60 * 60 * 1000), // 11:30 AM
description: 'Final group transport. 9 people total.',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driverTom.id,
vehicleId: whiteVan.id,
masterEventId: farewellBrunch.id,
},
});
// --- DAY 2: AIRPORT DEPARTURES ---
await prisma.scheduleEvent.create({
data: {
vipIds: [vipRoger.id],
title: 'Airport Drop-off — Roger Mosby',
pickupLocation: 'VIP Lodge',
dropoffLocation: 'DEN Terminal West',
startTime: new Date(jamboreeDay2.getTime() + 14 * 60 * 60 * 1000), // 2:00 PM
endTime: new Date(jamboreeDay2.getTime() + 15 * 60 * 60 * 1000), // 3:00 PM
description: 'Roger + 2 handlers (3 people) to airport.',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driverKevin.id,
vehicleId: suburban1.id,
},
});
await prisma.scheduleEvent.create({
data: {
vipIds: [vipPatricia.id, vipJames.id, vipDrBaker.id],
title: 'Airport Drop-off — Hawkins, Whitfield, Baker',
pickupLocation: 'VIP Lodge',
dropoffLocation: 'DEN Terminal East',
startTime: new Date(jamboreeDay2.getTime() + 14.5 * 60 * 60 * 1000), // 2:30 PM
endTime: new Date(jamboreeDay2.getTime() + 15.5 * 60 * 60 * 1000), // 3:30 PM
description: 'Patricia (2) + James (1) + Dr. Baker (2) = 5 people to airport.',
type: EventType.TRANSPORT,
status: EventStatus.SCHEDULED,
driverId: driverMaria.id,
vehicleId: suburban2.id, // 5 people in 6-seat SUV
},
});
console.log('✅ Created 15 transport legs linked to master events');
// =============================================
// SUMMARY
// =============================================
console.log('\n🎉 BSA Jamboree seed data created successfully!\n');
console.log('VIPs (6):');
console.log(' Roger Mosby — Chief Scout Exec (party: 3 = VIP + 2 handlers)');
console.log(' Patricia Hawkins — Board Chair (party: 2 = VIP + spouse)');
console.log(' James Whitfield III — Major Donor (party: 1 = solo)');
console.log(' Dr. Angela Baker — Keynote Speaker (party: 2 = VIP + assistant)');
console.log(' Gov. Carlos Martinez — Governor (party: 4 = VIP + security/aide/advance)');
console.log(' Susan O\'Malley — Council President (party: 1 = solo)');
console.log('\nShared Events (7): Opening Ceremony, VIP Luncheon, Keynote, Donor Meeting, Campfire Night, Eagle Scout Ceremony, Farewell Brunch');
console.log('Transport Legs (15): Airport pickups/dropoffs + shuttles to/from each event');
console.log('Vehicles (6): 2 Suburbans, 1 Van, 2 Golf Carts, 1 Charter Bus');
console.log('Drivers (4): Tom Bradley, Maria Gonzalez, Kevin Park, Lisa Chen');
}
main()

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';
@@ -11,6 +12,11 @@ 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 { GpsModule } from './gps/gps.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
@Module({
@@ -21,6 +27,12 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
envFilePath: '.env',
}),
// Rate limiting: 100 requests per 60 seconds per IP
ThrottlerModule.forRoot([{
ttl: 60000,
limit: 100,
}]),
// Core modules
PrismaModule,
AuthModule,
@@ -32,6 +44,11 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
VehiclesModule,
EventsModule,
FlightsModule,
CopilotModule,
SignalModule,
SettingsModule,
SeedModule,
GpsModule,
],
controllers: [AppController],
providers: [
@@ -41,6 +58,11 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
// Apply rate limiting globally
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}

View File

@@ -1,4 +1,4 @@
import { AbilityBuilder, PureAbility, AbilityClass, ExtractSubjectType, InferSubjects } from '@casl/ability';
import { AbilityBuilder, PureAbility, AbilityClass, ExtractSubjectType } from '@casl/ability';
import { Injectable } from '@nestjs/common';
import { Role, User, VIP, Driver, ScheduleEvent, Flight, Vehicle } from '@prisma/client';
@@ -25,6 +25,7 @@ export type Subjects =
| 'ScheduleEvent'
| 'Flight'
| 'Vehicle'
| 'Settings'
| 'all';
/**

View File

@@ -18,51 +18,55 @@ export class AuthService {
const name = payload[`${namespace}/name`] || payload.name || 'Unknown User';
const picture = payload[`${namespace}/picture`] || payload.picture;
// Check if user exists
let user = await this.prisma.user.findUnique({
// Check if user exists (soft-deleted users automatically excluded by middleware)
let user = await this.prisma.user.findFirst({
where: { auth0Id },
include: { driver: true },
});
if (!user) {
// Check if this is the first user (auto-approve as admin)
const approvedUserCount = await this.prisma.user.count({
where: { isApproved: true, deletedAt: null },
});
const isFirstUser = approvedUserCount === 0;
// Use serializable transaction to prevent race condition
// where two simultaneous registrations both become admin
user = await this.prisma.$transaction(async (tx) => {
const approvedUserCount = await tx.user.count({
where: { isApproved: true },
});
const isFirstUser = approvedUserCount === 0;
this.logger.log(
`Creating new user: ${email} (approvedUserCount: ${approvedUserCount}, isFirstUser: ${isFirstUser})`,
);
this.logger.log(
`Creating new user: ${email} (approvedUserCount: ${approvedUserCount}, isFirstUser: ${isFirstUser})`,
);
// Create new user
// First user is auto-approved as ADMINISTRATOR
// Subsequent users default to DRIVER and require approval
user = await this.prisma.user.create({
data: {
auth0Id,
email,
name,
picture,
role: isFirstUser ? Role.ADMINISTRATOR : Role.DRIVER,
isApproved: isFirstUser, // Auto-approve first user only
},
include: { driver: true },
});
// First user is auto-approved as ADMINISTRATOR
// Subsequent users default to DRIVER and require approval
const newUser = await tx.user.create({
data: {
auth0Id,
email,
name,
picture,
role: isFirstUser ? Role.ADMINISTRATOR : Role.DRIVER,
isApproved: isFirstUser,
},
include: { driver: true },
});
this.logger.log(
`User created: ${user.email} with role ${user.role} (approved: ${user.isApproved})`,
);
this.logger.log(
`User created: ${newUser.email} with role ${newUser.role} (approved: ${newUser.isApproved})`,
);
return newUser;
}, { isolationLevel: 'Serializable' });
}
return user;
}
/**
* Get current user profile
* Get current user profile (soft-deleted users automatically excluded by middleware)
*/
async getCurrentUser(auth0Id: string) {
return this.prisma.user.findUnique({
return this.prisma.user.findFirst({
where: { auth0Id },
include: { driver: true },
});

View File

@@ -0,0 +1 @@
export * from './parse-boolean.pipe';

View File

@@ -0,0 +1,49 @@
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
/**
* Transforms query string values to proper booleans.
*
* Handles common boolean string representations:
* - 'true', '1', 'yes', 'on' → true
* - 'false', '0', 'no', 'off' → false
* - undefined, null, '' → false (default)
* - Any other value → BadRequestException
*
* @example
* ```typescript
* @Delete(':id')
* async remove(
* @Param('id') id: string,
* @Query('hard', ParseBooleanPipe) hard: boolean,
* ) {
* return this.service.remove(id, hard);
* }
* ```
*/
@Injectable()
export class ParseBooleanPipe implements PipeTransform<string | undefined, boolean> {
transform(value: string | undefined): boolean {
// Handle undefined, null, or empty string as false (default)
if (value === undefined || value === null || value === '') {
return false;
}
// Normalize to lowercase for comparison
const normalized = value.toLowerCase().trim();
// True values
if (['true', '1', 'yes', 'on'].includes(normalized)) {
return true;
}
// False values
if (['false', '0', 'no', 'off'].includes(normalized)) {
return false;
}
// Invalid value
throw new BadRequestException(
`Invalid boolean value: "${value}". Expected: true, false, 1, 0, yes, no, on, off`,
);
}
}

View File

@@ -0,0 +1,99 @@
/**
* Date utility functions to consolidate common date manipulation patterns
* across the VIP Coordinator application.
*/
/**
* Converts a Date object to ISO date string format (YYYY-MM-DD).
* Replaces the repetitive pattern: date.toISOString().split('T')[0]
*
* @param date - The date to convert
* @returns ISO date string in YYYY-MM-DD format
*
* @example
* const dateStr = toDateString(new Date('2024-01-15T10:30:00Z'));
* // Returns: '2024-01-15'
*/
export function toDateString(date: Date): string {
return date.toISOString().split('T')[0];
}
/**
* Normalizes a Date object to the start of the day (00:00:00.000).
* Replaces the pattern: date.setHours(0, 0, 0, 0)
*
* @param date - The date to normalize
* @returns A new Date object set to the start of the day
*
* @example
* const dayStart = startOfDay(new Date('2024-01-15T15:45:30Z'));
* // Returns: Date object at 2024-01-15T00:00:00.000
*/
export function startOfDay(date: Date): Date {
const normalized = new Date(date);
normalized.setHours(0, 0, 0, 0);
return normalized;
}
/**
* Normalizes a Date object to the end of the day (23:59:59.999).
*
* @param date - The date to normalize
* @returns A new Date object set to the end of the day
*
* @example
* const dayEnd = endOfDay(new Date('2024-01-15T10:30:00Z'));
* // Returns: Date object at 2024-01-15T23:59:59.999
*/
export function endOfDay(date: Date): Date {
const normalized = new Date(date);
normalized.setHours(23, 59, 59, 999);
return normalized;
}
/**
* Converts optional date string fields to Date objects for multiple fields at once.
* Useful for DTO to Prisma data transformation where only provided fields should be converted.
*
* @param obj - The object containing date string fields
* @param fields - Array of field names that should be converted to Date objects if present
* @returns New object with specified fields converted to Date objects
*
* @example
* const dto = {
* name: 'Flight 123',
* scheduledDeparture: '2024-01-15T10:00:00Z',
* scheduledArrival: '2024-01-15T12:00:00Z',
* actualDeparture: undefined,
* };
*
* const data = convertOptionalDates(dto, [
* 'scheduledDeparture',
* 'scheduledArrival',
* 'actualDeparture',
* 'actualArrival'
* ]);
*
* // Result: {
* // name: 'Flight 123',
* // scheduledDeparture: Date object,
* // scheduledArrival: Date object,
* // actualDeparture: undefined,
* // actualArrival: undefined
* // }
*/
export function convertOptionalDates<T extends Record<string, any>>(
obj: T,
fields: (keyof T)[],
): T {
const result = { ...obj };
for (const field of fields) {
const value = obj[field];
if (value !== undefined && value !== null) {
result[field] = new Date(value as any) as any;
}
}
return result;
}

View File

@@ -0,0 +1,78 @@
import { ForbiddenException, Logger } from '@nestjs/common';
/**
* Enforces hard-delete authorization and executes the appropriate delete operation.
*
* @param options Configuration object
* @param options.id Entity ID to delete
* @param options.hardDelete Whether to perform hard delete (true) or soft delete (false)
* @param options.userRole User's role (required for hard delete authorization)
* @param options.findOne Function to find and verify entity exists
* @param options.performHardDelete Function to perform hard delete (e.g., prisma.model.delete)
* @param options.performSoftDelete Function to perform soft delete (e.g., prisma.model.update)
* @param options.entityName Name of entity for logging (e.g., 'VIP', 'Driver')
* @param options.logger Logger instance for the service
* @returns Promise resolving to the deleted entity
* @throws {ForbiddenException} If non-admin attempts hard delete
*
* @example
* ```typescript
* async remove(id: string, hardDelete = false, userRole?: string) {
* return executeHardDelete({
* id,
* hardDelete,
* userRole,
* findOne: async (id) => this.findOne(id),
* performHardDelete: async (id) => this.prisma.vIP.delete({ where: { id } }),
* performSoftDelete: async (id) => this.prisma.vIP.update({
* where: { id },
* data: { deletedAt: new Date() },
* }),
* entityName: 'VIP',
* logger: this.logger,
* });
* }
* ```
*/
export async function executeHardDelete<T>(options: {
id: string;
hardDelete: boolean;
userRole?: string;
findOne: (id: string) => Promise<T & { id: string; name?: string }>;
performHardDelete: (id: string) => Promise<any>;
performSoftDelete: (id: string) => Promise<any>;
entityName: string;
logger: Logger;
}): Promise<any> {
const {
id,
hardDelete,
userRole,
findOne,
performHardDelete,
performSoftDelete,
entityName,
logger,
} = options;
// Authorization check: only administrators can hard delete
if (hardDelete && userRole !== 'ADMINISTRATOR') {
throw new ForbiddenException(
'Only administrators can permanently delete records',
);
}
// Verify entity exists
const entity = await findOne(id);
// Perform the appropriate delete operation
if (hardDelete) {
const entityLabel = entity.name || entity.id;
logger.log(`Hard deleting ${entityName}: ${entityLabel}`);
return performHardDelete(entity.id);
}
const entityLabel = entity.name || entity.id;
logger.log(`Soft deleting ${entityName}: ${entityLabel}`);
return performSoftDelete(entity.id);
}

View File

@@ -0,0 +1,7 @@
/**
* Common utility functions used throughout the application.
* Export all utilities from this central location for easier imports.
*/
export * from './date.utils';
export * from './hard-delete.utils';

View File

@@ -0,0 +1,462 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { toDateString, startOfDay } from '../common/utils/date.utils';
interface ToolResult {
success: boolean;
data?: any;
error?: string;
message?: string;
}
@Injectable()
export class CopilotFleetService {
private readonly logger = new Logger(CopilotFleetService.name);
constructor(private readonly prisma: PrismaService) {}
async getAvailableVehicles(filters: Record<string, any>): Promise<ToolResult> {
const where: any = { deletedAt: null, status: 'AVAILABLE' };
if (filters.type) {
where.type = filters.type;
}
if (filters.minSeats) {
where.seatCapacity = { gte: filters.minSeats };
}
const vehicles = await this.prisma.vehicle.findMany({
where,
orderBy: [{ type: 'asc' }, { seatCapacity: 'desc' }],
});
return {
success: true,
data: vehicles,
message: `Found ${vehicles.length} available vehicle(s).`,
};
}
async assignVehicleToEvent(eventId: string, vehicleId: string): Promise<ToolResult> {
const event = await this.prisma.scheduleEvent.findFirst({
where: { id: eventId, deletedAt: null },
});
if (!event) {
return { success: false, error: `Event with ID ${eventId} not found.` };
}
// If vehicleId is null, we're unassigning
if (vehicleId === null || vehicleId === 'null') {
const updatedEvent = await this.prisma.scheduleEvent.update({
where: { id: eventId },
data: { vehicleId: null },
include: {
driver: true,
},
});
return {
success: true,
data: updatedEvent,
message: `Vehicle unassigned from event "${updatedEvent.title}"`,
};
}
// Verify vehicle exists
const vehicle = await this.prisma.vehicle.findFirst({
where: { id: vehicleId, deletedAt: null },
});
if (!vehicle) {
return { success: false, error: `Vehicle with ID ${vehicleId} not found.` };
}
const updatedEvent = await this.prisma.scheduleEvent.update({
where: { id: eventId },
data: { vehicleId },
include: {
driver: true,
vehicle: true,
},
});
return {
success: true,
data: updatedEvent,
message: `Vehicle ${vehicle.name} assigned to event "${updatedEvent.title}"`,
};
}
async suggestVehicleForEvent(input: Record<string, any>): Promise<ToolResult> {
const { eventId } = input;
const event = await this.prisma.scheduleEvent.findFirst({
where: { id: eventId, deletedAt: null },
});
if (!event) {
return { success: false, error: `Event with ID ${eventId} not found.` };
}
// Fetch VIP info to determine party size
const vips = await this.prisma.vIP.findMany({
where: { id: { in: event.vipIds } },
select: { id: true, name: true, partySize: true },
});
// Determine required capacity based on total party size
const requiredSeats = vips.reduce((sum, v) => sum + (v.partySize || 1), 0);
// Find vehicles not in use during this event time
const busyVehicleIds = await this.prisma.scheduleEvent.findMany({
where: {
deletedAt: null,
id: { not: eventId },
status: { not: 'CANCELLED' },
vehicleId: { not: null },
OR: [
{
startTime: { lte: event.startTime },
endTime: { gt: event.startTime },
},
{
startTime: { lt: event.endTime },
endTime: { gte: event.endTime },
},
],
},
select: { vehicleId: true },
});
const busyIds = busyVehicleIds.map((e) => e.vehicleId).filter((id): id is string => id !== null);
// Find available vehicles with sufficient capacity
const suitableVehicles = await this.prisma.vehicle.findMany({
where: {
deletedAt: null,
status: 'AVAILABLE',
seatCapacity: { gte: requiredSeats },
id: { notIn: busyIds },
},
orderBy: [
{ seatCapacity: 'asc' }, // Prefer smallest suitable vehicle
],
});
return {
success: true,
data: {
eventId,
eventTitle: event.title,
vipNames: vips.map((v) => v.name),
requiredSeats,
suggestions: suitableVehicles.map((v) => ({
id: v.id,
name: v.name,
type: v.type,
seatCapacity: v.seatCapacity,
})),
},
message:
suitableVehicles.length > 0
? `Found ${suitableVehicles.length} suitable vehicle(s) for this event (requires ${requiredSeats} seat(s)).`
: `No available vehicles found with capacity for ${requiredSeats} passenger(s) during this time.`,
};
}
async getVehicleSchedule(input: Record<string, any>): Promise<ToolResult> {
const { vehicleName, vehicleId, startDate, endDate } = input;
let vehicle;
if (vehicleId) {
vehicle = await this.prisma.vehicle.findFirst({
where: { id: vehicleId, deletedAt: null },
});
} else if (vehicleName) {
const vehicles = await this.prisma.vehicle.findMany({
where: {
deletedAt: null,
name: { contains: vehicleName, mode: 'insensitive' },
},
});
if (vehicles.length === 0) {
return { success: false, error: `No vehicle found matching "${vehicleName}".` };
}
if (vehicles.length > 1) {
return {
success: false,
error: `Multiple vehicles match "${vehicleName}": ${vehicles.map((v) => v.name).join(', ')}. Please be more specific.`,
};
}
vehicle = vehicles[0];
} else {
return { success: false, error: 'Either vehicleName or vehicleId is required.' };
}
if (!vehicle) {
return { success: false, error: 'Vehicle not found.' };
}
const dateStart = startOfDay(new Date(startDate));
const dateEnd = new Date(endDate);
dateEnd.setHours(23, 59, 59, 999);
const events = await this.prisma.scheduleEvent.findMany({
where: {
deletedAt: null,
vehicleId: vehicle.id,
startTime: { gte: dateStart, lte: dateEnd },
status: { not: 'CANCELLED' },
},
include: {
driver: true,
},
orderBy: { startTime: 'asc' },
});
// Fetch VIP names for all events
const allVipIds = events.flatMap((e) => e.vipIds);
const uniqueVipIds = [...new Set(allVipIds)];
const vips = await this.prisma.vIP.findMany({
where: { id: { in: uniqueVipIds } },
select: { id: true, name: true },
});
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
const totalHours =
events.reduce((sum, e) => {
return sum + (e.endTime.getTime() - e.startTime.getTime());
}, 0) / 3600000;
return {
success: true,
data: {
vehicle: {
id: vehicle.id,
name: vehicle.name,
type: vehicle.type,
seatCapacity: vehicle.seatCapacity,
status: vehicle.status,
},
dateRange: {
start: toDateString(dateStart),
end: toDateString(dateEnd),
},
eventCount: events.length,
totalHours: Math.round(totalHours * 10) / 10,
events: events.map((e) => ({
eventId: e.id,
title: e.title,
type: e.type,
startTime: e.startTime,
endTime: e.endTime,
vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
driverName: e.driver?.name || null,
pickupLocation: e.pickupLocation,
dropoffLocation: e.dropoffLocation,
location: e.location,
})),
},
message: `Vehicle ${vehicle.name} has ${events.length} scheduled event(s) (${Math.round(totalHours * 10) / 10} hours total).`,
};
}
async searchDrivers(filters: Record<string, any>): Promise<ToolResult> {
const where: any = { deletedAt: null };
if (filters.name) {
where.name = { contains: filters.name, mode: 'insensitive' };
}
if (filters.department) {
where.department = filters.department;
}
if (filters.availableOnly) {
where.isAvailable = true;
}
const drivers = await this.prisma.driver.findMany({
where,
orderBy: { name: 'asc' },
});
return {
success: true,
data: drivers,
message: `Found ${drivers.length} driver(s) matching the criteria.`,
};
}
async getDriverSchedule(
driverId: string,
startDate?: string,
endDate?: string,
): Promise<ToolResult> {
const driver = await this.prisma.driver.findFirst({
where: { id: driverId, deletedAt: null },
});
if (!driver) {
return { success: false, error: `Driver with ID ${driverId} not found.` };
}
const where: any = {
deletedAt: null,
driverId,
status: { not: 'CANCELLED' },
};
if (startDate) {
where.startTime = { gte: new Date(startDate) };
}
if (endDate) {
where.endTime = { lte: new Date(endDate) };
}
const events = await this.prisma.scheduleEvent.findMany({
where,
include: {
vehicle: true,
},
orderBy: { startTime: 'asc' },
});
// Fetch VIP names for all events
const allVipIds = events.flatMap((e) => e.vipIds);
const uniqueVipIds = [...new Set(allVipIds)];
const vips = await this.prisma.vIP.findMany({
where: { id: { in: uniqueVipIds } },
select: { id: true, name: true },
});
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
const eventsWithVipNames = events.map((event) => ({
...event,
vipNames: event.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
}));
return {
success: true,
data: {
driver,
events: eventsWithVipNames,
eventCount: events.length,
},
message: `Driver ${driver.name} has ${events.length} scheduled event(s).`,
};
}
async listAllDrivers(input: Record<string, any>): Promise<ToolResult> {
const { includeUnavailable = true } = input;
const where: any = { deletedAt: null };
if (!includeUnavailable) {
where.isAvailable = true;
}
const drivers = await this.prisma.driver.findMany({
where,
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
phone: true,
department: true,
isAvailable: true,
},
});
return {
success: true,
data: drivers,
message: `Found ${drivers.length} driver(s) in the system.`,
};
}
async findAvailableDriversForTimerange(input: Record<string, any>): Promise<ToolResult> {
const { startTime, endTime, preferredDepartment } = input;
// Get all drivers
const where: any = { deletedAt: null, isAvailable: true };
if (preferredDepartment) {
where.department = preferredDepartment;
}
const allDrivers = await this.prisma.driver.findMany({
where,
});
// Find drivers with conflicting events
const busyDriverIds = await this.prisma.scheduleEvent.findMany({
where: {
deletedAt: null,
driverId: { not: null },
status: { not: 'CANCELLED' },
OR: [
{
startTime: { lte: new Date(startTime) },
endTime: { gt: new Date(startTime) },
},
{
startTime: { lt: new Date(endTime) },
endTime: { gte: new Date(endTime) },
},
],
},
select: { driverId: true },
});
const busyIds = new Set(busyDriverIds.map((e) => e.driverId));
const availableDrivers = allDrivers.filter((d) => !busyIds.has(d.id));
return {
success: true,
data: availableDrivers,
message: `Found ${availableDrivers.length} available driver(s) for the specified time range.`,
};
}
async updateDriver(input: Record<string, any>): Promise<ToolResult> {
const { driverId, ...updates } = input;
const existingDriver = await this.prisma.driver.findFirst({
where: { id: driverId, deletedAt: null },
});
if (!existingDriver) {
return { success: false, error: `Driver with ID ${driverId} not found.` };
}
const updateData: any = {};
if (updates.name !== undefined) updateData.name = updates.name;
if (updates.phone !== undefined) updateData.phone = updates.phone;
if (updates.department !== undefined) updateData.department = updates.department;
if (updates.isAvailable !== undefined) updateData.isAvailable = updates.isAvailable;
if (updates.shiftStartTime !== undefined) updateData.shiftStartTime = updates.shiftStartTime;
if (updates.shiftEndTime !== undefined) updateData.shiftEndTime = updates.shiftEndTime;
const driver = await this.prisma.driver.update({
where: { id: driverId },
data: updateData,
});
this.logger.log(`Driver updated: ${driverId}`);
return {
success: true,
data: driver,
message: `Driver ${driver.name} updated successfully.`,
};
}
}

View File

@@ -0,0 +1,304 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { toDateString, startOfDay } from '../common/utils/date.utils';
interface ToolResult {
success: boolean;
data?: any;
error?: string;
message?: string;
}
@Injectable()
export class CopilotReportsService {
private readonly logger = new Logger(CopilotReportsService.name);
constructor(private readonly prisma: PrismaService) {}
async getDriverWorkloadSummary(input: Record<string, any>): Promise<ToolResult> {
const { startDate, endDate } = input;
const dateStart = startOfDay(new Date(startDate));
const dateEnd = new Date(endDate);
dateEnd.setHours(23, 59, 59, 999);
// Get all drivers
const drivers = await this.prisma.driver.findMany({
where: { deletedAt: null },
orderBy: { name: 'asc' },
});
// Get all events in range
const events = await this.prisma.scheduleEvent.findMany({
where: {
deletedAt: null,
startTime: { gte: dateStart, lte: dateEnd },
status: { not: 'CANCELLED' },
driverId: { not: null },
},
include: {
driver: true,
},
});
// Calculate workload for each driver
const workloadData = drivers.map((driver) => {
const driverEvents = events.filter((e) => e.driverId === driver.id);
const totalHours =
driverEvents.reduce((sum, e) => {
return sum + (e.endTime.getTime() - e.startTime.getTime());
}, 0) / 3600000;
const totalDays = Math.ceil(
(dateEnd.getTime() - dateStart.getTime()) / (1000 * 60 * 60 * 24),
);
const eventsByType = driverEvents.reduce(
(acc, e) => {
acc[e.type] = (acc[e.type] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
return {
driverId: driver.id,
driverName: driver.name,
department: driver.department,
isAvailable: driver.isAvailable,
eventCount: driverEvents.length,
totalHours: Math.round(totalHours * 10) / 10,
averageHoursPerDay: Math.round((totalHours / totalDays) * 10) / 10,
eventsByType,
};
});
// Sort by total hours descending
workloadData.sort((a, b) => b.totalHours - a.totalHours);
const totalEvents = events.length;
const totalHours =
events.reduce((sum, e) => {
return sum + (e.endTime.getTime() - e.startTime.getTime());
}, 0) / 3600000;
return {
success: true,
data: {
dateRange: {
start: toDateString(dateStart),
end: toDateString(dateEnd),
},
summary: {
totalDrivers: drivers.length,
totalEvents,
totalHours: Math.round(totalHours * 10) / 10,
averageEventsPerDriver: Math.round((totalEvents / drivers.length) * 10) / 10,
},
driverWorkloads: workloadData,
},
message: `Workload summary for ${drivers.length} driver(s) from ${toDateString(dateStart)} to ${toDateString(dateEnd)}.`,
};
}
async getCurrentSystemStatus(): Promise<ToolResult> {
const now = new Date();
const today = startOfDay(now);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const nextWeek = new Date(today);
nextWeek.setDate(nextWeek.getDate() + 7);
const [
vipCount,
vehicleCount,
driverCount,
todaysEvents,
upcomingEvents,
unassignedEvents,
availableDrivers,
availableVehicles,
] = await Promise.all([
this.prisma.vIP.count({ where: { deletedAt: null } }),
this.prisma.vehicle.count({ where: { deletedAt: null } }),
this.prisma.driver.count({ where: { deletedAt: null } }),
this.prisma.scheduleEvent.count({
where: {
deletedAt: null,
startTime: { gte: today, lt: tomorrow },
status: { not: 'CANCELLED' },
},
}),
this.prisma.scheduleEvent.count({
where: {
deletedAt: null,
startTime: { gte: tomorrow, lt: nextWeek },
status: { not: 'CANCELLED' },
},
}),
this.prisma.scheduleEvent.count({
where: {
deletedAt: null,
startTime: { gte: now },
status: { in: ['SCHEDULED'] },
OR: [{ driverId: null }, { vehicleId: null }],
},
}),
this.prisma.driver.count({ where: { deletedAt: null, isAvailable: true } }),
this.prisma.vehicle.count({ where: { deletedAt: null, status: 'AVAILABLE' } }),
]);
const status = {
timestamp: now.toISOString(),
resources: {
vips: vipCount,
drivers: { total: driverCount, available: availableDrivers },
vehicles: { total: vehicleCount, available: availableVehicles },
},
events: {
today: todaysEvents,
next7Days: upcomingEvents,
needingAttention: unassignedEvents,
},
alerts: [] as string[],
};
// Add alerts for issues
if (unassignedEvents > 0) {
status.alerts.push(`${unassignedEvents} upcoming event(s) need driver/vehicle assignment`);
}
if (availableDrivers === 0) {
status.alerts.push('No drivers currently marked as available');
}
if (availableVehicles === 0) {
status.alerts.push('No vehicles currently available');
}
return {
success: true,
data: status,
message:
status.alerts.length > 0
? `System status retrieved. ATTENTION: ${status.alerts.length} alert(s) require attention.`
: 'System status retrieved. No immediate issues.',
};
}
async getTodaysSummary(): Promise<ToolResult> {
const today = startOfDay(new Date());
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// Get today's events
const events = await this.prisma.scheduleEvent.findMany({
where: {
deletedAt: null,
startTime: { gte: today, lt: tomorrow },
status: { not: 'CANCELLED' },
},
include: {
driver: true,
vehicle: true,
},
orderBy: { startTime: 'asc' },
});
// Fetch VIP names for all events
const allVipIds = events.flatMap((e) => e.vipIds);
const uniqueVipIds = [...new Set(allVipIds)];
const vips = await this.prisma.vIP.findMany({
where: { id: { in: uniqueVipIds } },
select: { id: true, name: true },
});
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
// Get VIPs arriving today (flights or self-driving)
const arrivingVips = await this.prisma.vIP.findMany({
where: {
deletedAt: null,
OR: [
{
expectedArrival: { gte: today, lt: tomorrow },
},
{
flights: {
some: {
scheduledArrival: { gte: today, lt: tomorrow },
},
},
},
],
},
include: {
flights: {
where: {
scheduledArrival: { gte: today, lt: tomorrow },
},
orderBy: { scheduledArrival: 'asc' },
},
},
});
// Get driver assignments
const driversOnDuty = events
.filter((e) => e.driver)
.reduce((acc, e) => {
if (e.driver && !acc.find((d) => d.id === e.driver!.id)) {
acc.push(e.driver);
}
return acc;
}, [] as NonNullable<typeof events[0]['driver']>[]);
// Unassigned events
const unassigned = events.filter((e) => !e.driverId || !e.vehicleId);
return {
success: true,
data: {
date: toDateString(today),
summary: {
totalEvents: events.length,
arrivingVips: arrivingVips.length,
driversOnDuty: driversOnDuty.length,
unassignedEvents: unassigned.length,
},
events: events.map((e) => ({
id: e.id,
time: e.startTime,
title: e.title,
type: e.type,
vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
driverName: e.driver?.name || 'UNASSIGNED',
vehicleName: e.vehicle?.name || 'UNASSIGNED',
location: e.location || e.pickupLocation,
})),
arrivingVips: arrivingVips.map((v) => ({
id: v.id,
name: v.name,
arrivalMode: v.arrivalMode,
expectedArrival: v.expectedArrival,
flights: v.flights.map((f) => ({
flightNumber: f.flightNumber,
scheduledArrival: f.scheduledArrival,
arrivalAirport: f.arrivalAirport,
})),
})),
driversOnDuty: driversOnDuty.map((d) => ({
id: d.id,
name: d.name,
eventCount: events.filter((e) => e.driverId === d.id).length,
})),
unassignedEvents: unassigned.map((e) => ({
id: e.id,
time: e.startTime,
title: e.title,
vipNames: e.vipIds.map((id) => vipMap.get(id) || 'Unknown'),
needsDriver: !e.driverId,
needsVehicle: !e.vehicleId,
})),
},
message: `Today's summary: ${events.length} event(s), ${arrivingVips.length} VIP(s) arriving, ${unassigned.length} unassigned.`,
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,275 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
interface ToolResult {
success: boolean;
data?: any;
error?: string;
message?: string;
}
@Injectable()
export class CopilotVipService {
private readonly logger = new Logger(CopilotVipService.name);
constructor(private readonly prisma: PrismaService) {}
async searchVips(filters: Record<string, any>): Promise<ToolResult> {
const where: any = { deletedAt: null };
if (filters.name) {
where.name = { contains: filters.name, mode: 'insensitive' };
}
if (filters.organization) {
where.organization = { contains: filters.organization, mode: 'insensitive' };
}
if (filters.department) {
where.department = filters.department;
}
if (filters.arrivalMode) {
where.arrivalMode = filters.arrivalMode;
}
const vips = await this.prisma.vIP.findMany({
where,
include: {
flights: true,
},
take: 20,
});
// Fetch events for these VIPs
const vipIds = vips.map((v) => v.id);
const events = await this.prisma.scheduleEvent.findMany({
where: {
deletedAt: null,
vipIds: { hasSome: vipIds },
},
orderBy: { startTime: 'asc' },
});
// Attach events to VIPs
const vipsWithEvents = vips.map((vip) => ({
...vip,
events: events.filter((e) => e.vipIds.includes(vip.id)).slice(0, 5),
}));
return { success: true, data: vipsWithEvents };
}
async getVipDetails(vipId: string): Promise<ToolResult> {
const vip = await this.prisma.vIP.findUnique({
where: { id: vipId },
include: {
flights: true,
},
});
if (!vip) {
return { success: false, error: 'VIP not found' };
}
// Fetch events for this VIP
const events = await this.prisma.scheduleEvent.findMany({
where: {
deletedAt: null,
vipIds: { has: vipId },
},
include: {
driver: true,
vehicle: true,
},
orderBy: { startTime: 'asc' },
});
return { success: true, data: { ...vip, events } };
}
async createVip(input: Record<string, any>): Promise<ToolResult> {
const vip = await this.prisma.vIP.create({
data: {
name: input.name,
organization: input.organization,
department: input.department,
arrivalMode: input.arrivalMode,
expectedArrival: input.expectedArrival ? new Date(input.expectedArrival) : null,
airportPickup: input.airportPickup ?? false,
venueTransport: input.venueTransport ?? false,
partySize: input.partySize ?? 1,
notes: input.notes,
isRosterOnly: input.isRosterOnly ?? false,
phone: input.phone || null,
email: input.email || null,
emergencyContactName: input.emergencyContactName || null,
emergencyContactPhone: input.emergencyContactPhone || null,
},
});
return { success: true, data: vip };
}
async updateVip(input: Record<string, any>): Promise<ToolResult> {
const { vipId, ...updateData } = input;
const data: any = {};
if (updateData.name !== undefined) data.name = updateData.name;
if (updateData.organization !== undefined) data.organization = updateData.organization;
if (updateData.department !== undefined) data.department = updateData.department;
if (updateData.arrivalMode !== undefined) data.arrivalMode = updateData.arrivalMode;
if (updateData.expectedArrival !== undefined)
data.expectedArrival = updateData.expectedArrival
? new Date(updateData.expectedArrival)
: null;
if (updateData.airportPickup !== undefined) data.airportPickup = updateData.airportPickup;
if (updateData.venueTransport !== undefined)
data.venueTransport = updateData.venueTransport;
if (updateData.partySize !== undefined) data.partySize = updateData.partySize;
if (updateData.notes !== undefined) data.notes = updateData.notes;
if (updateData.isRosterOnly !== undefined) data.isRosterOnly = updateData.isRosterOnly;
if (updateData.phone !== undefined) data.phone = updateData.phone || null;
if (updateData.email !== undefined) data.email = updateData.email || null;
if (updateData.emergencyContactName !== undefined)
data.emergencyContactName = updateData.emergencyContactName || null;
if (updateData.emergencyContactPhone !== undefined)
data.emergencyContactPhone = updateData.emergencyContactPhone || null;
const vip = await this.prisma.vIP.update({
where: { id: vipId },
data,
include: { flights: true },
});
return { success: true, data: vip };
}
async getVipItinerary(input: Record<string, any>): Promise<ToolResult> {
const { vipId, startDate, endDate } = input;
const vip = await this.prisma.vIP.findUnique({
where: { id: vipId },
});
if (!vip) {
return { success: false, error: 'VIP not found' };
}
// Build date filters
const dateFilter: any = {};
if (startDate) dateFilter.gte = new Date(startDate);
if (endDate) dateFilter.lte = new Date(endDate);
// Get flights
const flightsWhere: any = { vipId };
if (startDate || endDate) {
flightsWhere.flightDate = dateFilter;
}
const flights = await this.prisma.flight.findMany({
where: flightsWhere,
orderBy: { scheduledDeparture: 'asc' },
});
// Get events
const eventsWhere: any = {
deletedAt: null,
vipIds: { has: vipId },
};
if (startDate || endDate) {
eventsWhere.startTime = dateFilter;
}
const events = await this.prisma.scheduleEvent.findMany({
where: eventsWhere,
include: {
driver: true,
vehicle: true,
},
orderBy: { startTime: 'asc' },
});
// Combine and sort chronologically
const itineraryItems: any[] = [
...flights.map((f) => ({
type: 'FLIGHT',
time: f.scheduledDeparture || f.flightDate,
data: f,
})),
...events.map((e) => ({
type: 'EVENT',
time: e.startTime,
data: e,
})),
].sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
return {
success: true,
data: {
vip,
itinerary: itineraryItems,
summary: {
totalFlights: flights.length,
totalEvents: events.length,
},
},
};
}
async getFlightsForVip(vipId: string): Promise<ToolResult> {
const flights = await this.prisma.flight.findMany({
where: { vipId },
orderBy: { flightDate: 'asc' },
});
return { success: true, data: flights };
}
async createFlight(input: Record<string, any>): Promise<ToolResult> {
const flight = await this.prisma.flight.create({
data: {
vipId: input.vipId,
flightNumber: input.flightNumber,
flightDate: new Date(input.flightDate),
departureAirport: input.departureAirport,
arrivalAirport: input.arrivalAirport,
scheduledDeparture: input.scheduledDeparture
? new Date(input.scheduledDeparture)
: null,
scheduledArrival: input.scheduledArrival ? new Date(input.scheduledArrival) : null,
segment: input.segment || 1,
},
include: { vip: true },
});
return { success: true, data: flight };
}
async updateFlight(input: Record<string, any>): Promise<ToolResult> {
const { flightId, ...updateData } = input;
const flight = await this.prisma.flight.update({
where: { id: flightId },
data: updateData,
include: { vip: true },
});
return { success: true, data: flight };
}
async deleteFlight(flightId: string): Promise<ToolResult> {
const flight = await this.prisma.flight.findUnique({
where: { id: flightId },
include: { vip: true },
});
if (!flight) {
return { success: false, error: 'Flight not found' };
}
await this.prisma.flight.delete({
where: { id: flightId },
});
return {
success: true,
data: { deleted: true, flight },
};
}
}

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,23 @@
import { Module } from '@nestjs/common';
import { CopilotController } from './copilot.controller';
import { CopilotService } from './copilot.service';
import { CopilotVipService } from './copilot-vip.service';
import { CopilotScheduleService } from './copilot-schedule.service';
import { CopilotFleetService } from './copilot-fleet.service';
import { CopilotReportsService } from './copilot-reports.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,
CopilotVipService,
CopilotScheduleService,
CopilotFleetService,
CopilotReportsService,
],
})
export class CopilotModule {}

View File

@@ -0,0 +1,900 @@
import { Injectable, Logger } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { CopilotVipService } from './copilot-vip.service';
import { CopilotScheduleService } from './copilot-schedule.service';
import { CopilotFleetService } from './copilot-fleet.service';
import { CopilotReportsService } from './copilot-reports.service';
interface ChatMessage {
role: 'user' | 'assistant';
content: string | any[];
}
interface ToolResult {
success: boolean;
data?: any;
error?: string;
message?: string;
}
@Injectable()
export class CopilotService {
private readonly logger = new Logger(CopilotService.name);
private readonly anthropic: Anthropic;
// Define available tools for Claude
private readonly tools: Anthropic.Tool[] = [
{
name: 'search_vips',
description:
'Search for VIPs by name, organization, department, or arrival mode. Returns a list of matching VIPs with their details.',
input_schema: {
type: 'object' as const,
properties: {
name: { type: 'string', description: 'VIP name to search for (partial match)' },
organization: { type: 'string', description: 'Organization name to filter by' },
department: {
type: 'string',
enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'],
description: 'Department to filter by',
},
arrivalMode: {
type: 'string',
enum: ['FLIGHT', 'SELF_DRIVING'],
description: 'Arrival mode to filter by',
},
},
required: [],
},
},
{
name: 'get_vip_details',
description:
'Get detailed information about a specific VIP including their flights and scheduled events.',
input_schema: {
type: 'object' as const,
properties: {
vipId: { type: 'string', description: 'The VIP ID' },
},
required: ['vipId'],
},
},
{
name: 'search_drivers',
description:
'Search for drivers by name, phone, or department. Returns a list of drivers with their availability.',
input_schema: {
type: 'object' as const,
properties: {
name: { type: 'string', description: 'Driver name to search for' },
department: {
type: 'string',
enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'],
description: 'Department to filter by',
},
availableOnly: { type: 'boolean', description: 'Only return available drivers' },
},
required: [],
},
},
{
name: 'get_driver_schedule',
description: "Get a driver's schedule for a specific date range.",
input_schema: {
type: 'object' as const,
properties: {
driverId: { type: 'string', description: 'The driver ID' },
startDate: { type: 'string', description: 'Start date (ISO format)' },
endDate: { type: 'string', description: 'End date (ISO format)' },
},
required: ['driverId'],
},
},
{
name: 'search_events',
description:
'Search for scheduled events/activities. Can filter by VIP name, event title, driver name, date, or status.',
input_schema: {
type: 'object' as const,
properties: {
vipId: { type: 'string', description: 'Filter by VIP ID' },
vipName: { type: 'string', description: 'Filter by VIP name (partial match)' },
title: { type: 'string', description: 'Filter by event title (partial match)' },
driverId: { type: 'string', description: 'Filter by driver ID' },
driverName: {
type: 'string',
description:
'Filter by driver name (partial match) - will find events assigned to drivers matching this name',
},
date: { type: 'string', description: 'Filter by date (ISO format or YYYY-MM-DD)' },
status: {
type: 'string',
enum: ['SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'],
description: 'Filter by status',
},
type: {
type: 'string',
enum: ['TRANSPORT', 'MEETING', 'EVENT', 'MEAL', 'ACCOMMODATION'],
description: 'Filter by event type',
},
},
required: [],
},
},
{
name: 'get_available_vehicles',
description: 'Get a list of available vehicles, optionally filtered by type or seat capacity.',
input_schema: {
type: 'object' as const,
properties: {
type: {
type: 'string',
enum: ['VAN', 'SUV', 'SEDAN', 'BUS', 'GOLF_CART', 'TRUCK'],
description: 'Vehicle type',
},
minSeats: { type: 'number', description: 'Minimum seat capacity required' },
},
required: [],
},
},
{
name: 'get_flights_for_vip',
description: 'Get all flights associated with a VIP.',
input_schema: {
type: 'object' as const,
properties: {
vipId: { type: 'string', description: 'The VIP ID' },
},
required: ['vipId'],
},
},
{
name: 'update_flight',
description: 'Update flight information for a VIP. Use this when flight times change.',
input_schema: {
type: 'object' as const,
properties: {
flightId: { type: 'string', description: 'The flight ID to update' },
scheduledDeparture: {
type: 'string',
description: 'New scheduled departure time (ISO format)',
},
scheduledArrival: { type: 'string', description: 'New scheduled arrival time (ISO format)' },
status: { type: 'string', description: 'New flight status' },
},
required: ['flightId'],
},
},
{
name: 'create_event',
description:
'Create a new scheduled event/activity for a VIP. Only use this for NEW events, not to modify existing ones.',
input_schema: {
type: 'object' as const,
properties: {
vipId: { type: 'string', description: 'The VIP ID' },
title: { type: 'string', description: 'Event title' },
type: {
type: 'string',
enum: ['TRANSPORT', 'MEETING', 'EVENT', 'MEAL', 'ACCOMMODATION'],
description: 'Event type',
},
startTime: { type: 'string', description: 'Start time (ISO format)' },
endTime: { type: 'string', description: 'End time (ISO format)' },
location: { type: 'string', description: 'Event location' },
pickupLocation: { type: 'string', description: 'Pickup location (for transport)' },
dropoffLocation: { type: 'string', description: 'Dropoff location (for transport)' },
driverId: { type: 'string', description: 'Assigned driver ID (optional)' },
vehicleId: { type: 'string', description: 'Assigned vehicle ID (optional)' },
description: { type: 'string', description: 'Additional notes' },
},
required: ['vipId', 'title', 'type', 'startTime', 'endTime'],
},
},
{
name: 'assign_driver_to_event',
description: 'Assign or change the driver for an existing event.',
input_schema: {
type: 'object' as const,
properties: {
eventId: { type: 'string', description: 'The event ID' },
driverId: { type: 'string', description: 'The driver ID to assign' },
},
required: ['eventId', 'driverId'],
},
},
{
name: 'update_event',
description:
'Update an existing event. Use this to change event time, location, title, status, or other details.',
input_schema: {
type: 'object' as const,
properties: {
eventId: { type: 'string', description: 'The event ID to update' },
title: { type: 'string', description: 'New event title' },
startTime: { type: 'string', description: 'New start time (ISO format)' },
endTime: { type: 'string', description: 'New end time (ISO format)' },
location: { type: 'string', description: 'New location' },
pickupLocation: { type: 'string', description: 'New pickup location' },
dropoffLocation: { type: 'string', description: 'New dropoff location' },
status: {
type: 'string',
enum: ['SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'],
description: 'New status',
},
driverId: { type: 'string', description: 'New driver ID (use null to unassign)' },
vehicleId: { type: 'string', description: 'New vehicle ID (use null to unassign)' },
description: { type: 'string', description: 'New description/notes' },
},
required: ['eventId'],
},
},
{
name: 'delete_event',
description:
'Delete (soft delete) an event. Use this when an event is cancelled or no longer needed.',
input_schema: {
type: 'object' as const,
properties: {
eventId: { type: 'string', description: 'The event ID to delete' },
},
required: ['eventId'],
},
},
{
name: 'get_todays_summary',
description:
"Get a summary of today's activities including upcoming events, arriving VIPs, and driver assignments.",
input_schema: {
type: 'object' as const,
properties: {},
required: [],
},
},
{
name: 'create_flight',
description: 'Create a new flight for a VIP.',
input_schema: {
type: 'object' as const,
properties: {
vipId: { type: 'string', description: 'The VIP ID' },
flightNumber: { type: 'string', description: 'Flight number (e.g., AA1234)' },
flightDate: { type: 'string', description: 'Flight date (ISO format or YYYY-MM-DD)' },
departureAirport: {
type: 'string',
description: 'Departure airport IATA code (e.g., JFK)',
},
arrivalAirport: { type: 'string', description: 'Arrival airport IATA code (e.g., LAX)' },
scheduledDeparture: {
type: 'string',
description: 'Scheduled departure time (ISO format)',
},
scheduledArrival: { type: 'string', description: 'Scheduled arrival time (ISO format)' },
segment: {
type: 'number',
description: 'Flight segment number for multi-leg trips (default 1)',
},
},
required: ['vipId', 'flightNumber', 'flightDate', 'departureAirport', 'arrivalAirport'],
},
},
{
name: 'delete_flight',
description: 'Delete a flight record.',
input_schema: {
type: 'object' as const,
properties: {
flightId: { type: 'string', description: 'The flight ID to delete' },
},
required: ['flightId'],
},
},
{
name: 'create_vip',
description: 'Create a new VIP in the system.',
input_schema: {
type: 'object' as const,
properties: {
name: { type: 'string', description: 'VIP full name' },
organization: { type: 'string', description: 'Organization/company name' },
department: {
type: 'string',
enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'],
description: 'Department',
},
arrivalMode: {
type: 'string',
enum: ['FLIGHT', 'SELF_DRIVING'],
description: 'How VIP will arrive',
},
expectedArrival: {
type: 'string',
description: 'Expected arrival time for self-driving (ISO format)',
},
airportPickup: { type: 'boolean', description: 'Whether VIP needs airport pickup' },
venueTransport: { type: 'boolean', description: 'Whether VIP needs venue transport' },
partySize: {
type: 'number',
description: 'Total party size including VIP plus companions/entourage (default 1)',
},
notes: { type: 'string', description: 'Additional notes about the VIP' },
isRosterOnly: {
type: 'boolean',
description:
'True if VIP is roster-only (accountability tracking, no active transport coordination)',
},
phone: { type: 'string', description: 'VIP phone number' },
email: { type: 'string', description: 'VIP email address' },
emergencyContactName: { type: 'string', description: 'Emergency contact name' },
emergencyContactPhone: { type: 'string', description: 'Emergency contact phone' },
},
required: ['name', 'department', 'arrivalMode'],
},
},
{
name: 'update_vip',
description: 'Update VIP information including party size, contact info, and roster status.',
input_schema: {
type: 'object' as const,
properties: {
vipId: { type: 'string', description: 'The VIP ID to update' },
name: { type: 'string', description: 'New name' },
organization: { type: 'string', description: 'New organization' },
department: {
type: 'string',
enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'],
description: 'New department',
},
arrivalMode: {
type: 'string',
enum: ['FLIGHT', 'SELF_DRIVING'],
description: 'New arrival mode',
},
expectedArrival: { type: 'string', description: 'New expected arrival time' },
airportPickup: { type: 'boolean', description: 'Whether VIP needs airport pickup' },
venueTransport: { type: 'boolean', description: 'Whether VIP needs venue transport' },
partySize: {
type: 'number',
description: 'Total party size including VIP plus companions/entourage',
},
notes: { type: 'string', description: 'New notes' },
isRosterOnly: {
type: 'boolean',
description: 'True if VIP is roster-only (no active transport coordination)',
},
phone: { type: 'string', description: 'VIP phone number' },
email: { type: 'string', description: 'VIP email address' },
emergencyContactName: { type: 'string', description: 'Emergency contact name' },
emergencyContactPhone: { type: 'string', description: 'Emergency contact phone' },
},
required: ['vipId'],
},
},
{
name: 'assign_vehicle_to_event',
description: 'Assign or change the vehicle for an existing event.',
input_schema: {
type: 'object' as const,
properties: {
eventId: { type: 'string', description: 'The event ID' },
vehicleId: { type: 'string', description: 'The vehicle ID to assign (use null to unassign)' },
},
required: ['eventId', 'vehicleId'],
},
},
{
name: 'update_driver',
description: 'Update driver information like availability, contact info, or shift times.',
input_schema: {
type: 'object' as const,
properties: {
driverId: { type: 'string', description: 'The driver ID to update' },
name: { type: 'string', description: 'New name' },
phone: { type: 'string', description: 'New phone number' },
department: {
type: 'string',
enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'],
description: 'New department',
},
isAvailable: { type: 'boolean', description: 'Whether driver is available' },
shiftStartTime: { type: 'string', description: 'Shift start time (HH:MM format)' },
shiftEndTime: { type: 'string', description: 'Shift end time (HH:MM format)' },
},
required: ['driverId'],
},
},
{
name: 'check_driver_conflicts',
description: 'Check if a driver has any scheduling conflicts for a given time period.',
input_schema: {
type: 'object' as const,
properties: {
driverId: { type: 'string', description: 'The driver ID to check' },
startTime: { type: 'string', description: 'Start time to check (ISO format)' },
endTime: { type: 'string', description: 'End time to check (ISO format)' },
excludeEventId: {
type: 'string',
description: 'Event ID to exclude from conflict check (for updates)',
},
},
required: ['driverId', 'startTime', 'endTime'],
},
},
{
name: 'reassign_driver_events',
description:
'Reassign all events from one driver to another. Use this when a driver is sick, unavailable, or needs to swap schedules. Searches by driver NAME - you do not need IDs.',
input_schema: {
type: 'object' as const,
properties: {
fromDriverName: {
type: 'string',
description: 'Name of the driver to reassign FROM (the one who is sick/unavailable)',
},
toDriverName: {
type: 'string',
description: 'Name of the driver to reassign TO (the replacement driver)',
},
date: {
type: 'string',
description:
'Optional: only reassign events on this date (YYYY-MM-DD). If not provided, reassigns all future events.',
},
onlyStatus: {
type: 'string',
enum: ['SCHEDULED', 'IN_PROGRESS'],
description: 'Optional: only reassign events with this status',
},
},
required: ['fromDriverName', 'toDriverName'],
},
},
{
name: 'list_all_drivers',
description:
'List ALL drivers in the system with their basic info. Use this when you need to see available driver names or find the correct spelling of a driver name.',
input_schema: {
type: 'object' as const,
properties: {
includeUnavailable: {
type: 'boolean',
description: 'Include unavailable drivers (default true)',
},
},
required: [],
},
},
{
name: 'get_vip_itinerary',
description:
'Get the complete itinerary for a VIP including all flights and events in chronological order.',
input_schema: {
type: 'object' as const,
properties: {
vipId: { type: 'string', description: 'The VIP ID' },
startDate: { type: 'string', description: 'Start date for itinerary (optional)' },
endDate: { type: 'string', description: 'End date for itinerary (optional)' },
},
required: ['vipId'],
},
},
{
name: 'find_available_drivers_for_timerange',
description:
'Find drivers who have no conflicting events during a specific time range. Returns a list of available drivers with their info.',
input_schema: {
type: 'object' as const,
properties: {
startTime: { type: 'string', description: 'Start time of the time range (ISO format)' },
endTime: { type: 'string', description: 'End time of the time range (ISO format)' },
preferredDepartment: {
type: 'string',
enum: ['OFFICE_OF_DEVELOPMENT', 'ADMIN', 'OTHER'],
description: 'Optional: filter by department',
},
},
required: ['startTime', 'endTime'],
},
},
{
name: 'get_daily_driver_manifest',
description:
"Get a driver's complete schedule for a specific day with all event details including VIP names, locations, vehicles, and gaps between events.",
input_schema: {
type: 'object' as const,
properties: {
driverName: { type: 'string', description: 'Driver name (partial match works)' },
driverId: { type: 'string', description: 'Driver ID (use this if you already have the ID)' },
date: {
type: 'string',
description: 'Date in YYYY-MM-DD format (optional, defaults to today)',
},
},
required: [],
},
},
{
name: 'send_driver_notification_via_signal',
description:
'Send a message to a driver via Signal messaging. Use this to notify drivers about schedule changes, reminders, or important updates.',
input_schema: {
type: 'object' as const,
properties: {
driverName: { type: 'string', description: 'Driver name (partial match works)' },
driverId: { type: 'string', description: 'Driver ID (use this if you already have the ID)' },
message: { type: 'string', description: 'The message content to send to the driver' },
relatedEventId: {
type: 'string',
description: 'Optional: Event ID if this message relates to a specific event',
},
},
required: ['message'],
},
},
{
name: 'bulk_send_driver_schedules',
description:
'Send daily schedules to multiple drivers or all drivers via Signal. Automatically generates and sends PDF/ICS schedule files.',
input_schema: {
type: 'object' as const,
properties: {
date: {
type: 'string',
description: 'Date in YYYY-MM-DD format for which to send schedules',
},
driverNames: {
type: 'array',
items: { type: 'string' },
description:
'Optional: array of driver names. If empty or not provided, sends to all drivers with events on that date.',
},
},
required: ['date'],
},
},
{
name: 'find_unassigned_events',
description:
'Find events that are missing driver and/or vehicle assignments. Useful for identifying scheduling gaps that need attention.',
input_schema: {
type: 'object' as const,
properties: {
startDate: { type: 'string', description: 'Start date to search (ISO format or YYYY-MM-DD)' },
endDate: { type: 'string', description: 'End date to search (ISO format or YYYY-MM-DD)' },
missingDriver: {
type: 'boolean',
description: 'Find events missing driver assignment (default true)',
},
missingVehicle: {
type: 'boolean',
description: 'Find events missing vehicle assignment (default true)',
},
},
required: ['startDate', 'endDate'],
},
},
{
name: 'check_vip_conflicts',
description:
'Check if a VIP has overlapping events in a time range. Useful for preventing double-booking VIPs.',
input_schema: {
type: 'object' as const,
properties: {
vipName: { type: 'string', description: 'VIP name (partial match works)' },
vipId: { type: 'string', description: 'VIP ID (use this if you already have the ID)' },
startTime: { type: 'string', description: 'Start time to check (ISO format)' },
endTime: { type: 'string', description: 'End time to check (ISO format)' },
excludeEventId: {
type: 'string',
description: 'Optional: event ID to exclude from conflict check (for updates)',
},
},
required: ['startTime', 'endTime'],
},
},
{
name: 'get_weekly_lookahead',
description:
'Get a week-by-week summary of upcoming events, VIP arrivals, and unassigned events for planning purposes.',
input_schema: {
type: 'object' as const,
properties: {
startDate: {
type: 'string',
description: 'Start date (optional, defaults to today, YYYY-MM-DD format)',
},
weeksAhead: { type: 'number', description: 'Number of weeks to look ahead (default 1)' },
},
required: [],
},
},
{
name: 'identify_scheduling_gaps',
description:
'Audit the upcoming schedule for problems including unassigned events, driver conflicts, VIP conflicts, and capacity issues.',
input_schema: {
type: 'object' as const,
properties: {
lookaheadDays: { type: 'number', description: 'Number of days ahead to audit (default 7)' },
},
required: [],
},
},
{
name: 'suggest_vehicle_for_event',
description:
'Recommend vehicles for an event based on capacity requirements and availability during the event time.',
input_schema: {
type: 'object' as const,
properties: {
eventId: { type: 'string', description: 'The event ID to find vehicle suggestions for' },
},
required: ['eventId'],
},
},
{
name: 'get_vehicle_schedule',
description:
"Get a vehicle's schedule for a date range, showing all events using this vehicle with driver and VIP details.",
input_schema: {
type: 'object' as const,
properties: {
vehicleName: { type: 'string', description: 'Vehicle name (partial match works)' },
vehicleId: { type: 'string', description: 'Vehicle ID (use this if you already have the ID)' },
startDate: { type: 'string', description: 'Start date (ISO format or YYYY-MM-DD)' },
endDate: { type: 'string', description: 'End date (ISO format or YYYY-MM-DD)' },
},
required: ['startDate', 'endDate'],
},
},
{
name: 'get_driver_workload_summary',
description:
'Get workload statistics for all drivers including event count, total hours, and availability percentage for a date range.',
input_schema: {
type: 'object' as const,
properties: {
startDate: { type: 'string', description: 'Start date (ISO format or YYYY-MM-DD)' },
endDate: { type: 'string', description: 'End date (ISO format or YYYY-MM-DD)' },
},
required: ['startDate', 'endDate'],
},
},
{
name: 'get_current_system_status',
description: "Get system overview: VIP/driver/vehicle counts, today's events, alerts.",
input_schema: {
type: 'object' as const,
properties: {},
required: [],
},
},
];
constructor(
private readonly vipService: CopilotVipService,
private readonly scheduleService: CopilotScheduleService,
private readonly fleetService: CopilotFleetService,
private readonly reportsService: CopilotReportsService,
) {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
this.logger.warn('ANTHROPIC_API_KEY not set - Copilot features will be disabled');
}
this.anthropic = new Anthropic({ apiKey: apiKey || 'dummy' });
}
async chat(
messages: ChatMessage[],
userId: string,
userRole: string,
): Promise<{ response: string; toolResults?: any[] }> {
try {
const systemPrompt = this.buildSystemPrompt(userRole);
const anthropicMessages: Anthropic.MessageParam[] = messages.map((msg) => ({
role: msg.role,
content: msg.content,
}));
let response = await this.anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 8096,
system: systemPrompt,
messages: anthropicMessages,
tools: this.tools,
});
const toolResults: any[] = [];
// Tool use loop
while (response.stop_reason === 'tool_use') {
const toolUseBlocks = response.content.filter(
(block) => block.type === 'tool_use',
) as Anthropic.ToolUseBlock[];
const toolResultBlocks: Anthropic.ToolResultBlockParam[] = [];
for (const toolUse of toolUseBlocks) {
this.logger.log(`Executing tool: ${toolUse.name}`);
const result = await this.executeTool(toolUse.name, toolUse.input as Record<string, any>);
toolResults.push({ tool: toolUse.name, result });
toolResultBlocks.push({
type: 'tool_result',
tool_use_id: toolUse.id,
content: JSON.stringify(result),
});
}
// Add assistant's tool use and user's tool results to the conversation
anthropicMessages.push(
{
role: 'assistant',
content: response.content,
},
{
role: 'user',
content: toolResultBlocks,
},
);
// Continue conversation
response = await this.anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 8096,
system: systemPrompt,
messages: anthropicMessages,
tools: this.tools,
});
}
// Extract final text response
const textContent = response.content.find((block) => block.type === 'text') as
| Anthropic.TextBlock
| undefined;
return {
response: textContent?.text || 'No response generated',
toolResults: toolResults.length > 0 ? toolResults : undefined,
};
} catch (error) {
this.logger.error('Copilot chat error:', error);
throw error;
}
}
private buildSystemPrompt(userRole: string): string {
const today = new Date().toISOString().split('T')[0];
return `You are a VIP Transportation Coordinator AI assistant. Today's date is ${today}.
Your role is to help manage VIP transportation logistics including:
- VIP profiles and itineraries
- Driver schedules and assignments
- Vehicle fleet management
- Event scheduling and conflict detection
- Flight tracking
User Role: ${userRole}
${
userRole === 'DRIVER'
? '\nNote: This user is a DRIVER. They have read-only access and can only view schedules and events. Do not offer to create, update, or delete data for driver users.'
: ''
}
Guidelines:
1. Always provide clear, actionable information
2. When conflicts are detected, explain them clearly and suggest solutions
3. Use tools to fetch real-time data rather than making assumptions
4. For scheduling tasks, always check for conflicts before confirming
5. Be proactive in identifying potential issues (unassigned events, double-bookings)
6. When dates/times are ambiguous, ask for clarification
7. Keep responses concise but comprehensive
Communication Style:
- Professional and efficient
- Use bullet points for lists
- Highlight important warnings or conflicts
- Provide context when suggesting changes
Available Tools:
You have access to tools for searching VIPs, drivers, events, managing schedules, checking conflicts, and generating reports. Use them to provide accurate, up-to-date information.`;
}
private async executeTool(name: string, input: Record<string, any>): Promise<ToolResult> {
try {
switch (name) {
// VIP Operations
case 'search_vips':
return await this.vipService.searchVips(input);
case 'get_vip_details':
return await this.vipService.getVipDetails(input.vipId);
case 'create_vip':
return await this.vipService.createVip(input);
case 'update_vip':
return await this.vipService.updateVip(input);
case 'get_vip_itinerary':
return await this.vipService.getVipItinerary(input);
// Flight Operations
case 'get_flights_for_vip':
return await this.vipService.getFlightsForVip(input.vipId);
case 'create_flight':
return await this.vipService.createFlight(input);
case 'update_flight':
return await this.vipService.updateFlight(input);
case 'delete_flight':
return await this.vipService.deleteFlight(input.flightId);
// Event/Schedule Operations
case 'search_events':
return await this.scheduleService.searchEvents(input);
case 'create_event':
return await this.scheduleService.createEvent(input);
case 'update_event':
return await this.scheduleService.updateEvent(input);
case 'delete_event':
return await this.scheduleService.deleteEvent(input.eventId);
case 'assign_driver_to_event':
return await this.scheduleService.assignDriverToEvent(input.eventId, input.driverId);
case 'check_driver_conflicts':
return await this.scheduleService.checkDriverConflicts(input);
case 'reassign_driver_events':
return await this.scheduleService.reassignDriverEvents(input);
case 'get_daily_driver_manifest':
return await this.scheduleService.getDailyDriverManifest(input);
case 'find_unassigned_events':
return await this.scheduleService.findUnassignedEvents(input);
case 'check_vip_conflicts':
return await this.scheduleService.checkVipConflicts(input);
case 'get_weekly_lookahead':
return await this.scheduleService.getWeeklyLookahead(input);
case 'identify_scheduling_gaps':
return await this.scheduleService.identifySchedulingGaps(input);
case 'send_driver_notification_via_signal':
return await this.scheduleService.sendDriverNotificationViaSignal(input);
case 'bulk_send_driver_schedules':
return await this.scheduleService.bulkSendDriverSchedules(input);
// Fleet Operations (Drivers & Vehicles)
case 'search_drivers':
return await this.fleetService.searchDrivers(input);
case 'get_driver_schedule':
return await this.fleetService.getDriverSchedule(
input.driverId,
input.startDate,
input.endDate,
);
case 'list_all_drivers':
return await this.fleetService.listAllDrivers(input);
case 'find_available_drivers_for_timerange':
return await this.fleetService.findAvailableDriversForTimerange(input);
case 'update_driver':
return await this.fleetService.updateDriver(input);
case 'get_available_vehicles':
return await this.fleetService.getAvailableVehicles(input);
case 'assign_vehicle_to_event':
return await this.fleetService.assignVehicleToEvent(input.eventId, input.vehicleId);
case 'suggest_vehicle_for_event':
return await this.fleetService.suggestVehicleForEvent(input);
case 'get_vehicle_schedule':
return await this.fleetService.getVehicleSchedule(input);
// Reports Operations
case 'get_todays_summary':
return await this.reportsService.getTodaysSummary();
case 'get_driver_workload_summary':
return await this.reportsService.getDriverWorkloadSummary(input);
case 'get_current_system_status':
return await this.reportsService.getCurrentSystemStatus();
default:
return { success: false, error: `Unknown tool: ${name}` };
}
} catch (error) {
this.logger.error(`Tool execution error (${name}):`, error);
return { success: false, error: error.message };
}
}
}

View File

@@ -0,0 +1,13 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* Parameter decorator that extracts the current driver from the request.
* Should be used in conjunction with @UseInterceptors(ResolveDriverInterceptor)
* to ensure the driver is pre-resolved and attached to the request.
*/
export const CurrentDriver = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.driver;
},
);

View File

@@ -0,0 +1 @@
export * from './current-driver.decorator';

View File

@@ -8,18 +8,29 @@ import {
Param,
Query,
UseGuards,
UseInterceptors,
NotFoundException,
} from '@nestjs/common';
import { DriversService } from './drivers.service';
import { ScheduleExportService } from './schedule-export.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CurrentDriver } from './decorators';
import { ResolveDriverInterceptor } from './interceptors';
import { Role } from '@prisma/client';
import { CreateDriverDto, UpdateDriverDto } from './dto';
import { toDateString } from '../common/utils/date.utils';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('drivers')
@UseGuards(JwtAuthGuard, RolesGuard)
export class DriversController {
constructor(private readonly driversService: DriversService) {}
constructor(
private readonly driversService: DriversService,
private readonly scheduleExportService: ScheduleExportService,
) {}
@Post()
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
@@ -33,6 +44,135 @@ export class DriversController {
return this.driversService.findAll();
}
@Get('me')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
@UseInterceptors(ResolveDriverInterceptor)
getMyDriverProfile(@CurrentDriver() driver: any) {
return driver;
}
/**
* Get ICS calendar file for driver's own schedule
* By default, returns full upcoming schedule. Pass fullSchedule=false for single day.
*/
@Get('me/schedule/ics')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
@UseInterceptors(ResolveDriverInterceptor)
async getMyScheduleICS(
@CurrentDriver() driver: any,
@Query('date') dateStr?: string,
@Query('fullSchedule') fullScheduleStr?: string,
) {
const date = dateStr ? new Date(dateStr) : new Date();
// Default to full schedule (true) unless explicitly set to false
const fullSchedule = fullScheduleStr !== 'false';
const icsContent = await this.scheduleExportService.generateICS(driver.id, date, fullSchedule);
const filename = fullSchedule
? `full-schedule-${toDateString(new Date())}.ics`
: `schedule-${toDateString(date)}.ics`;
return { ics: icsContent, filename };
}
/**
* Get PDF schedule for driver's own schedule
* By default, returns full upcoming schedule. Pass fullSchedule=false for single day.
*/
@Get('me/schedule/pdf')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
@UseInterceptors(ResolveDriverInterceptor)
async getMySchedulePDF(
@CurrentDriver() driver: any,
@Query('date') dateStr?: string,
@Query('fullSchedule') fullScheduleStr?: string,
) {
const date = dateStr ? new Date(dateStr) : new Date();
// Default to full schedule (true) unless explicitly set to false
const fullSchedule = fullScheduleStr !== 'false';
const pdfBuffer = await this.scheduleExportService.generatePDF(driver.id, date, fullSchedule);
const filename = fullSchedule
? `full-schedule-${toDateString(new Date())}.pdf`
: `schedule-${toDateString(date)}.pdf`;
return { pdf: pdfBuffer.toString('base64'), filename };
}
/**
* Send schedule to driver's own phone via Signal
* By default, sends full upcoming schedule. Pass fullSchedule=false for single day.
*/
@Post('me/send-schedule')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
@UseInterceptors(ResolveDriverInterceptor)
async sendMySchedule(
@CurrentDriver() driver: any,
@Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both'; fullSchedule?: boolean },
) {
const date = body.date ? new Date(body.date) : new Date();
const format = body.format || 'both';
// Default to full schedule (true) unless explicitly set to false
const fullSchedule = body.fullSchedule !== false;
return this.scheduleExportService.sendScheduleToDriver(driver.id, date, format, fullSchedule);
}
@Patch('me')
@Roles(Role.DRIVER)
@UseInterceptors(ResolveDriverInterceptor)
updateMyProfile(@CurrentDriver() driver: any, @Body() updateDriverDto: UpdateDriverDto) {
return this.driversService.update(driver.id, updateDriverDto);
}
/**
* Send schedule to all drivers with events on a given date
* NOTE: This static route MUST come before :id routes to avoid matching issues
*/
@Post('send-all-schedules')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
async sendAllSchedules(
@Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both' },
) {
const date = body.date ? new Date(body.date) : new Date();
const format = body.format || 'both';
// Get all drivers with events on this date
const drivers = await this.driversService.findAll();
const results: Array<{ driverId: string; driverName: string; success: boolean; message: string }> = [];
for (const driver of drivers) {
try {
const result = await this.scheduleExportService.sendScheduleToDriver(
driver.id,
date,
format,
);
results.push({
driverId: driver.id,
driverName: driver.name,
success: result.success,
message: result.message,
});
} catch (error: any) {
// Skip drivers without events or phone numbers
if (!error.message?.includes('No events')) {
results.push({
driverId: driver.id,
driverName: driver.name,
success: false,
message: error.message,
});
}
}
}
const successCount = results.filter((r) => r.success).length;
return {
success: true,
sent: successCount,
total: results.length,
results,
};
}
// === Routes with :id parameter MUST come AFTER all static routes ===
@Get(':id')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR, Role.DRIVER)
findOne(@Param('id') id: string) {
@@ -45,6 +185,20 @@ export class DriversController {
return this.driversService.getSchedule(id);
}
/**
* Send schedule to driver via Signal (ICS and/or PDF)
*/
@Post(':id/send-schedule')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
async sendSchedule(
@Param('id') id: string,
@Body() body: { date?: string; format?: 'ics' | 'pdf' | 'both' },
) {
const date = body.date ? new Date(body.date) : new Date();
const format = body.format || 'both';
return this.scheduleExportService.sendScheduleToDriver(id, date, format);
}
@Patch(':id')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
update(@Param('id') id: string, @Body() updateDriverDto: UpdateDriverDto) {
@@ -55,9 +209,9 @@ export class DriversController {
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
@CurrentUser() user?: any,
) {
const isHardDelete = hard === 'true';
return this.driversService.remove(id, isHardDelete);
return this.driversService.remove(id, hard, user?.role);
}
}

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

@@ -1,11 +1,20 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateDriverDto, UpdateDriverDto } from './dto';
import { executeHardDelete } from '../common/utils';
@Injectable()
export class DriversService {
private readonly logger = new Logger(DriversService.name);
private readonly driverInclude = {
user: true,
events: {
include: { vehicle: true, driver: true },
orderBy: { startTime: 'asc' as const },
},
} as const;
constructor(private prisma: PrismaService) {}
async create(createDriverDto: CreateDriverDto) {
@@ -19,30 +28,15 @@ export class DriversService {
async findAll() {
return this.prisma.driver.findMany({
where: { deletedAt: null },
include: {
user: true,
events: {
where: { deletedAt: null },
include: { vehicle: true, driver: true },
orderBy: { startTime: 'asc' },
},
},
include: this.driverInclude,
orderBy: { name: 'asc' },
});
}
async findOne(id: string) {
const driver = await this.prisma.driver.findFirst({
where: { id, deletedAt: null },
include: {
user: true,
events: {
where: { deletedAt: null },
include: { vehicle: true, driver: true },
orderBy: { startTime: 'asc' },
},
},
where: { id },
include: this.driverInclude,
});
if (!driver) {
@@ -52,6 +46,13 @@ export class DriversService {
return driver;
}
async findByUserId(userId: string) {
return this.prisma.driver.findFirst({
where: { userId },
include: this.driverInclude,
});
}
async update(id: string, updateDriverDto: UpdateDriverDto) {
const driver = await this.findOne(id);
@@ -64,20 +65,20 @@ export class DriversService {
});
}
async remove(id: string, hardDelete = false) {
const driver = await this.findOne(id);
if (hardDelete) {
this.logger.log(`Hard deleting driver: ${driver.name}`);
return this.prisma.driver.delete({
where: { id: driver.id },
});
}
this.logger.log(`Soft deleting driver: ${driver.name}`);
return this.prisma.driver.update({
where: { id: driver.id },
data: { deletedAt: new Date() },
async remove(id: string, hardDelete = false, userRole?: string) {
return executeHardDelete({
id,
hardDelete,
userRole,
findOne: (id) => this.findOne(id),
performHardDelete: (id) => this.prisma.driver.delete({ where: { id } }),
performSoftDelete: (id) =>
this.prisma.driver.update({
where: { id },
data: { deletedAt: new Date() },
}),
entityName: 'Driver',
logger: this.logger,
});
}

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 @@
export * from './resolve-driver.interceptor';

View File

@@ -0,0 +1,40 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
NotFoundException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { DriversService } from '../drivers.service';
/**
* Interceptor that resolves the current driver from the authenticated user
* and attaches it to the request object for /me routes.
* This prevents multiple calls to findByUserId() in each route handler.
*/
@Injectable()
export class ResolveDriverInterceptor implements NestInterceptor {
constructor(private readonly driversService: DriversService) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new NotFoundException('User not authenticated');
}
// Resolve driver from user ID and attach to request
const driver = await this.driversService.findByUserId(user.id);
if (!driver) {
throw new NotFoundException('Driver profile not found for current user');
}
// Attach driver to request for use in route handlers
request.driver = driver;
return next.handle();
}
}

View File

@@ -0,0 +1,462 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SignalService } from '../signal/signal.service';
import * as ics from 'ics';
import * as PDFDocument from 'pdfkit';
import { toDateString, startOfDay } from '../common/utils/date.utils';
interface ScheduleEventWithDetails {
id: string;
title: string;
startTime: Date;
endTime: Date;
pickupLocation: string | null;
dropoffLocation: string | null;
location: string | null;
notes: string | null;
type: string;
status: string;
vipIds: string[];
vipNames: string[];
vehicle: { name: string; licensePlate: string | null } | null;
}
@Injectable()
export class ScheduleExportService {
private readonly logger = new Logger(ScheduleExportService.name);
constructor(
private readonly prisma: PrismaService,
private readonly signalService: SignalService,
) {}
/**
* Get a driver's schedule for a specific date
*/
async getDriverSchedule(
driverId: string,
date: Date,
): Promise<ScheduleEventWithDetails[]> {
const dayStart = startOfDay(date);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
const events = await this.prisma.scheduleEvent.findMany({
where: {
driverId,
startTime: {
gte: dayStart,
lte: endOfDay,
},
status: {
not: 'CANCELLED',
},
},
include: {
vehicle: {
select: { name: true, licensePlate: true },
},
},
orderBy: { startTime: 'asc' },
});
return this.mapEventsWithVipNames(events);
}
/**
* Get a driver's full upcoming schedule (all future events)
*/
async getDriverFullSchedule(
driverId: string,
): Promise<ScheduleEventWithDetails[]> {
const now = startOfDay(new Date()); // Start of today
const events = await this.prisma.scheduleEvent.findMany({
where: {
driverId,
endTime: {
gte: now, // Include events that haven't ended yet
},
status: {
not: 'CANCELLED',
},
},
include: {
vehicle: {
select: { name: true, licensePlate: true },
},
},
orderBy: { startTime: 'asc' },
});
return this.mapEventsWithVipNames(events);
}
/**
* Helper to map events with VIP names
*/
private async mapEventsWithVipNames(
events: any[],
): Promise<ScheduleEventWithDetails[]> {
// Fetch VIP names for all events
const allVipIds = [...new Set(events.flatMap((e) => e.vipIds))];
const vips = await this.prisma.vIP.findMany({
where: { id: { in: allVipIds } },
select: { id: true, name: true },
});
const vipMap = new Map(vips.map((v) => [v.id, v.name]));
// Map events with VIP names
return events.map((event) => ({
id: event.id,
title: event.title,
startTime: event.startTime,
endTime: event.endTime,
pickupLocation: event.pickupLocation,
dropoffLocation: event.dropoffLocation,
location: event.location,
notes: event.notes,
type: event.type,
status: event.status,
vipIds: event.vipIds,
vipNames: event.vipIds.map((id: string) => vipMap.get(id) || 'Unknown VIP'),
vehicle: event.vehicle,
}));
}
/**
* Generate ICS calendar file for a driver's schedule
* @param fullSchedule If true, includes all upcoming events. If false, only the specified date.
*/
async generateICS(driverId: string, date: Date, fullSchedule = false): Promise<string> {
const driver = await this.prisma.driver.findFirst({
where: { id: driverId },
});
if (!driver) {
throw new NotFoundException(`Driver with ID ${driverId} not found`);
}
const events = fullSchedule
? await this.getDriverFullSchedule(driverId)
: await this.getDriverSchedule(driverId, date);
if (events.length === 0) {
throw new NotFoundException(fullSchedule ? 'No upcoming events scheduled' : 'No events scheduled for this date');
}
const icsEvents: ics.EventAttributes[] = events.map((event) => {
const start = new Date(event.startTime);
const end = new Date(event.endTime);
const vipNames = event.vipNames.join(', ');
const location =
event.pickupLocation && event.dropoffLocation
? `${event.pickupLocation}${event.dropoffLocation}`
: event.location || 'TBD';
let description = `VIP: ${vipNames}\n`;
if (event.vehicle) {
description += `Vehicle: ${event.vehicle.name}`;
if (event.vehicle.licensePlate) {
description += ` (${event.vehicle.licensePlate})`;
}
description += '\n';
}
if (event.notes) {
description += `Notes: ${event.notes}\n`;
}
return {
start: [
start.getFullYear(),
start.getMonth() + 1,
start.getDate(),
start.getHours(),
start.getMinutes(),
] as [number, number, number, number, number],
end: [
end.getFullYear(),
end.getMonth() + 1,
end.getDate(),
end.getHours(),
end.getMinutes(),
] as [number, number, number, number, number],
title: `${event.title} - ${vipNames}`,
description,
location,
status: 'CONFIRMED' as const,
busyStatus: 'BUSY' as const,
organizer: { name: 'VIP Coordinator', email: 'noreply@vipcoordinator.app' },
};
});
const { error, value } = ics.createEvents(icsEvents);
if (error) {
this.logger.error('Failed to generate ICS:', error);
throw new Error('Failed to generate calendar file');
}
return value || '';
}
/**
* Generate PDF schedule for a driver
* @param fullSchedule If true, includes all upcoming events. If false, only the specified date.
*/
async generatePDF(driverId: string, date: Date, fullSchedule = false): Promise<Buffer> {
const driver = await this.prisma.driver.findFirst({
where: { id: driverId },
});
if (!driver) {
throw new NotFoundException(`Driver with ID ${driverId} not found`);
}
const events = fullSchedule
? await this.getDriverFullSchedule(driverId)
: await this.getDriverSchedule(driverId, date);
if (events.length === 0) {
throw new NotFoundException(fullSchedule ? 'No upcoming events scheduled' : 'No events scheduled for this date');
}
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
const doc = new PDFDocument({ margin: 50, size: 'LETTER' });
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
const dateStr = fullSchedule
? 'Full Upcoming Schedule'
: date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
// Header
doc
.fontSize(24)
.font('Helvetica-Bold')
.text('VIP Coordinator', { align: 'center' });
doc.moveDown(0.5);
doc
.fontSize(16)
.font('Helvetica')
.text(`Driver Schedule: ${driver.name}`, { align: 'center' });
doc.fontSize(12).text(dateStr, { align: 'center' });
doc.moveDown(1);
// Divider line
doc
.moveTo(50, doc.y)
.lineTo(doc.page.width - 50, doc.y)
.stroke();
doc.moveDown(1);
// Events
events.forEach((event, index) => {
const startTime = new Date(event.startTime).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
const endTime = new Date(event.endTime).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
const vipNames = event.vipNames.join(', ');
// Event header with time
doc
.fontSize(14)
.font('Helvetica-Bold')
.text(`${startTime} - ${endTime}`, { continued: false });
// Event title
doc.fontSize(12).font('Helvetica-Bold').text(event.title);
// VIP
doc.fontSize(11).font('Helvetica').text(`VIP: ${vipNames}`);
// Location
if (event.pickupLocation && event.dropoffLocation) {
doc.text(`Pickup: ${event.pickupLocation}`);
doc.text(`Dropoff: ${event.dropoffLocation}`);
} else if (event.location) {
doc.text(`Location: ${event.location}`);
}
// Vehicle
if (event.vehicle) {
let vehicleText = `Vehicle: ${event.vehicle.name}`;
if (event.vehicle.licensePlate) {
vehicleText += ` (${event.vehicle.licensePlate})`;
}
doc.text(vehicleText);
}
// Notes
if (event.notes) {
doc
.fontSize(10)
.fillColor('#666666')
.text(`Notes: ${event.notes}`)
.fillColor('#000000');
}
// Status badge
doc
.fontSize(9)
.fillColor(event.status === 'COMPLETED' ? '#22c55e' : '#3b82f6')
.text(`Status: ${event.status}`)
.fillColor('#000000');
// Spacing between events
if (index < events.length - 1) {
doc.moveDown(0.5);
doc
.moveTo(50, doc.y)
.lineTo(doc.page.width - 50, doc.y)
.strokeColor('#cccccc')
.stroke()
.strokeColor('#000000');
doc.moveDown(0.5);
}
});
// Footer
doc.moveDown(2);
doc
.fontSize(9)
.fillColor('#999999')
.text(
`Generated on ${new Date().toLocaleString('en-US')} by VIP Coordinator`,
{ align: 'center' },
);
doc.end();
});
}
/**
* Send schedule to driver via Signal
* @param fullSchedule If true, sends all upcoming events. If false, only the specified date.
*/
async sendScheduleToDriver(
driverId: string,
date: Date,
format: 'ics' | 'pdf' | 'both' = 'both',
fullSchedule = false,
): Promise<{ success: boolean; message: string }> {
const driver = await this.prisma.driver.findFirst({
where: { id: driverId },
});
if (!driver) {
throw new NotFoundException(`Driver with ID ${driverId} not found`);
}
if (!driver.phone) {
throw new Error('Driver does not have a phone number configured');
}
const fromNumber = await this.signalService.getLinkedNumber();
if (!fromNumber) {
throw new Error('No Signal account linked');
}
const toNumber = this.signalService.formatPhoneNumber(driver.phone);
const dateStr = fullSchedule
? 'your full upcoming schedule'
: date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
const results: string[] = [];
// Send text message first
const events = fullSchedule
? await this.getDriverFullSchedule(driverId)
: await this.getDriverSchedule(driverId, date);
if (events.length === 0) {
await this.signalService.sendMessage(
fromNumber,
toNumber,
fullSchedule ? 'No upcoming events scheduled.' : `No events scheduled for ${dateStr}.`,
);
return { success: true, message: 'No events to send' };
}
await this.signalService.sendMessage(
fromNumber,
toNumber,
`Your ${fullSchedule ? 'full upcoming' : ''} schedule${fullSchedule ? '' : ` for ${dateStr}`} (${events.length} event${events.length > 1 ? 's' : ''}):`,
);
// Send ICS
if (format === 'ics' || format === 'both') {
try {
const icsContent = await this.generateICS(driverId, date, fullSchedule);
const icsBase64 = Buffer.from(icsContent).toString('base64');
const filename = fullSchedule
? `full-schedule-${toDateString(new Date())}.ics`
: `schedule-${toDateString(date)}.ics`;
await this.signalService.sendMessageWithAttachment(
fromNumber,
toNumber,
'Calendar file - add to your calendar app:',
icsBase64,
filename,
'text/calendar',
);
results.push('ICS');
this.logger.log(`ICS sent to driver ${driver.name}`);
} catch (error: any) {
this.logger.error(`Failed to send ICS: ${error.message}`);
}
}
// Send PDF
if (format === 'pdf' || format === 'both') {
try {
const pdfBuffer = await this.generatePDF(driverId, date, fullSchedule);
const pdfBase64 = pdfBuffer.toString('base64');
const filename = fullSchedule
? `full-schedule-${toDateString(new Date())}.pdf`
: `schedule-${toDateString(date)}.pdf`;
await this.signalService.sendMessageWithAttachment(
fromNumber,
toNumber,
fullSchedule ? 'Full schedule PDF:' : 'PDF schedule:',
pdfBase64,
filename,
'application/pdf',
);
results.push('PDF');
this.logger.log(`PDF sent to driver ${driver.name}`);
} catch (error: any) {
this.logger.error(`Failed to send PDF: ${error.message}`);
}
}
if (results.length === 0) {
throw new Error('Failed to send any schedule files');
}
return {
success: true,
message: `Sent ${results.join(' and ')} schedule to ${driver.name}`,
};
}
}

View File

@@ -55,4 +55,8 @@ export class CreateEventDto {
@IsUUID()
@IsOptional()
vehicleId?: string;
@IsUUID()
@IsOptional()
masterEventId?: string;
}

View File

@@ -0,0 +1,417 @@
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 },
},
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 },
},
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 },
},
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 },
},
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) },
},
});
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,
},
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

@@ -13,8 +13,10 @@ import { EventsService } from './events.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Role } from '@prisma/client';
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('events')
@UseGuards(JwtAuthGuard, RolesGuard)
@@ -58,9 +60,9 @@ export class EventsController {
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
@CurrentUser() user?: any,
) {
const isHardDelete = hard === 'true';
return this.eventsService.remove(id, isHardDelete);
return this.eventsService.remove(id, hard, user?.role);
}
}

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

@@ -2,15 +2,28 @@ import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateEventDto, UpdateEventDto, UpdateEventStatusDto } from './dto';
import { executeHardDelete } from '../common/utils';
@Injectable()
export class EventsService {
private readonly logger = new Logger(EventsService.name);
private readonly eventInclude = {
driver: true,
vehicle: true,
masterEvent: {
select: { id: true, title: true, type: true, startTime: true, endTime: true },
},
childEvents: {
select: { id: true, title: true, type: true },
},
} as const;
constructor(private prisma: PrismaService) {}
async create(createEventDto: CreateEventDto) {
@@ -21,7 +34,6 @@ export class EventsService {
const vips = await this.prisma.vIP.findMany({
where: {
id: { in: createEventDto.vipIds },
deletedAt: null,
},
});
@@ -34,7 +46,7 @@ export class EventsService {
if (createEventDto.vehicleId && createEventDto.vipIds) {
await this.checkVehicleCapacity(
createEventDto.vehicleId,
createEventDto.vipIds.length,
createEventDto.vipIds,
);
}
@@ -68,10 +80,7 @@ export class EventsService {
startTime: new Date(createEventDto.startTime),
endTime: new Date(createEventDto.endTime),
},
include: {
driver: true,
vehicle: true,
},
include: this.eventInclude,
});
return this.enrichEventWithVips(event);
@@ -79,24 +88,45 @@ export class EventsService {
async findAll() {
const events = await this.prisma.scheduleEvent.findMany({
where: { deletedAt: null },
include: {
driver: true,
vehicle: true,
},
include: this.eventInclude,
orderBy: { startTime: 'asc' },
});
return Promise.all(events.map((event) => this.enrichEventWithVips(event)));
// Collect all unique VIP IDs from all events
const allVipIds = new Set<string>();
events.forEach((event) => {
event.vipIds?.forEach((vipId) => allVipIds.add(vipId));
});
// Fetch all VIPs in a single query (eliminates N+1)
const vipsMap = new Map();
if (allVipIds.size > 0) {
const vips = await this.prisma.vIP.findMany({
where: {
id: { in: Array.from(allVipIds) },
},
});
vips.forEach((vip) => vipsMap.set(vip.id, vip));
}
// Enrich each event with its VIPs from the map (no additional queries)
return events.map((event) => {
if (!event.vipIds || event.vipIds.length === 0) {
return { ...event, vips: [], vip: null };
}
const vips = event.vipIds
.map((vipId) => vipsMap.get(vipId))
.filter((vip) => vip !== undefined);
return { ...event, vips, vip: vips[0] || null };
});
}
async findOne(id: string) {
const event = await this.prisma.scheduleEvent.findFirst({
where: { id, deletedAt: null },
include: {
driver: true,
vehicle: true,
},
where: { id },
include: this.eventInclude,
});
if (!event) {
@@ -114,7 +144,6 @@ export class EventsService {
const vips = await this.prisma.vIP.findMany({
where: {
id: { in: updateEventDto.vipIds },
deletedAt: null,
},
});
@@ -125,12 +154,10 @@ export class EventsService {
// Check vehicle capacity if vehicle or VIPs are being updated
const vehicleId = updateEventDto.vehicleId || event.vehicleId;
const vipCount = updateEventDto.vipIds
? updateEventDto.vipIds.length
: event.vipIds.length;
const vipIds = updateEventDto.vipIds || event.vipIds;
if (vehicleId && vipCount > 0 && !updateEventDto.forceAssign) {
await this.checkVehicleCapacity(vehicleId, vipCount);
if (vehicleId && vipIds.length > 0 && !updateEventDto.forceAssign) {
await this.checkVehicleCapacity(vehicleId, vipIds);
}
// Check for conflicts if driver or times are being updated (unless forceAssign is true)
@@ -187,10 +214,7 @@ export class EventsService {
const updatedEvent = await this.prisma.scheduleEvent.update({
where: { id: event.id },
data: updateData,
include: {
driver: true,
vehicle: true,
},
include: this.eventInclude,
});
return this.enrichEventWithVips(updatedEvent);
@@ -206,52 +230,56 @@ export class EventsService {
const updatedEvent = await this.prisma.scheduleEvent.update({
where: { id: event.id },
data: { status: updateEventStatusDto.status },
include: {
driver: true,
vehicle: true,
},
include: this.eventInclude,
});
return this.enrichEventWithVips(updatedEvent);
}
async remove(id: string, hardDelete = false) {
const event = await this.findOne(id);
if (hardDelete) {
this.logger.log(`Hard deleting event: ${event.title}`);
return this.prisma.scheduleEvent.delete({
where: { id: event.id },
});
}
this.logger.log(`Soft deleting event: ${event.title}`);
return this.prisma.scheduleEvent.update({
where: { id: event.id },
data: { deletedAt: new Date() },
async remove(id: string, hardDelete = false, userRole?: string) {
return executeHardDelete({
id,
hardDelete,
userRole,
findOne: (id) => this.findOne(id),
performHardDelete: (id) =>
this.prisma.scheduleEvent.delete({ where: { id } }),
performSoftDelete: (id) =>
this.prisma.scheduleEvent.update({
where: { id },
data: { deletedAt: new Date() },
}),
entityName: 'Event',
logger: this.logger,
});
}
/**
* Check vehicle capacity
* Check vehicle capacity using sum of VIP party sizes
*/
private async checkVehicleCapacity(vehicleId: string, vipCount: number) {
private async checkVehicleCapacity(vehicleId: string, vipIds: string[]) {
const vehicle = await this.prisma.vehicle.findFirst({
where: { id: vehicleId, deletedAt: null },
where: { id: vehicleId },
});
if (!vehicle) {
throw new NotFoundException('Vehicle not found');
}
if (vipCount > vehicle.seatCapacity) {
const vips = await this.prisma.vIP.findMany({
where: { id: { in: vipIds } },
select: { partySize: true },
});
const totalPeople = vips.reduce((sum, v) => sum + v.partySize, 0);
if (totalPeople > vehicle.seatCapacity) {
this.logger.warn(
`Vehicle capacity exceeded: ${vipCount} VIPs > ${vehicle.seatCapacity} seats`,
`Vehicle capacity exceeded: ${totalPeople} people > ${vehicle.seatCapacity} seats`,
);
throw new BadRequestException({
message: `Vehicle capacity exceeded: ${vipCount} VIPs require more than ${vehicle.seatCapacity} available seats`,
message: `Vehicle capacity exceeded: ${totalPeople} people require more than ${vehicle.seatCapacity} available seats`,
capacity: vehicle.seatCapacity,
requested: vipCount,
requested: totalPeople,
exceeded: true,
});
}
@@ -269,7 +297,6 @@ export class EventsService {
return this.prisma.scheduleEvent.findMany({
where: {
driverId,
deletedAt: null,
id: excludeEventId ? { not: excludeEventId } : undefined,
OR: [
{
@@ -300,19 +327,20 @@ 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({
where: {
id: { in: event.vipIds },
deletedAt: null,
},
});
return { ...event, vips };
// Return both vips array and vip (first one) for backwards compatibility
return { ...event, vips, vip: vips[0] || null };
}
}

View File

@@ -0,0 +1,466 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { firstValueFrom } from 'rxjs';
import { Flight } from '@prisma/client';
import { toDateString } from '../common/utils/date.utils';
// Tracking phases - determines polling priority
const PHASE = {
FAR_OUT: 'FAR_OUT', // >24h before departure - no auto-poll
PRE_DEPARTURE: 'PRE_DEPARTURE', // 6-24h before departure
DEPARTURE_WINDOW: 'DEPARTURE_WINDOW', // 0-6h before departure
ACTIVE: 'ACTIVE', // In flight
ARRIVAL_WINDOW: 'ARRIVAL_WINDOW', // Within 1h of ETA
LANDED: 'LANDED', // Flight has landed
TERMINAL: 'TERMINAL', // Cancelled/diverted/incident - terminal state
} as const;
// Priority scores for each phase (higher = more urgent)
const PHASE_PRIORITY: Record<string, number> = {
[PHASE.ARRIVAL_WINDOW]: 100,
[PHASE.ACTIVE]: 60,
[PHASE.DEPARTURE_WINDOW]: 40,
[PHASE.PRE_DEPARTURE]: 10,
[PHASE.FAR_OUT]: 0,
[PHASE.LANDED]: 0,
[PHASE.TERMINAL]: 0,
};
// Minimum minutes between polls per phase (to prevent wasting budget)
const MIN_POLL_INTERVAL: Record<string, number> = {
[PHASE.ARRIVAL_WINDOW]: 20,
[PHASE.ACTIVE]: 45,
[PHASE.DEPARTURE_WINDOW]: 60,
[PHASE.PRE_DEPARTURE]: 180,
[PHASE.FAR_OUT]: Infinity,
[PHASE.LANDED]: Infinity,
[PHASE.TERMINAL]: Infinity,
};
// Map AviationStack status to our tracking phase
const STATUS_TO_TERMINAL: string[] = ['cancelled', 'incident', 'diverted'];
@Injectable()
export class FlightTrackingService {
private readonly logger = new Logger(FlightTrackingService.name);
private readonly apiKey: string;
private readonly baseUrl = 'http://api.aviationstack.com/v1';
constructor(
private prisma: PrismaService,
private httpService: HttpService,
private configService: ConfigService,
) {
this.apiKey = this.configService.get('AVIATIONSTACK_API_KEY') || '';
if (this.apiKey) {
this.logger.log('AviationStack API key configured - flight tracking enabled');
} else {
this.logger.warn('AviationStack API key not configured - flight tracking disabled');
}
}
// ============================================
// Cron Job: Smart Flight Polling (every 5 min)
// ============================================
@Cron('*/5 * * * *')
async pollFlightsCron(): Promise<void> {
if (!this.apiKey) return;
try {
// 1. Check budget
const budget = await this.getOrCreateBudget();
const budgetPercent = (budget.requestsUsed / budget.requestLimit) * 100;
if (budgetPercent >= 95) {
this.logger.debug('Flight API budget exhausted (>=95%) - skipping auto-poll');
return;
}
// 2. Get all trackable flights (not in terminal states)
const flights = await this.prisma.flight.findMany({
where: {
autoTrackEnabled: true,
trackingPhase: {
notIn: [PHASE.LANDED, PHASE.TERMINAL, PHASE.FAR_OUT],
},
},
include: { vip: true },
});
if (flights.length === 0) return;
// 3. Recalculate phases and score each flight
const candidates: { flight: Flight; phase: string; priority: number }[] = [];
for (const flight of flights) {
const phase = this.calculateTrackingPhase(flight);
// Update phase in DB if changed
if (phase !== flight.trackingPhase) {
await this.prisma.flight.update({
where: { id: flight.id },
data: { trackingPhase: phase },
});
}
// Skip phases that shouldn't be polled
if (PHASE_PRIORITY[phase] === 0) continue;
// Budget conservation: if >80% used, only poll high-priority
if (budgetPercent > 80 && PHASE_PRIORITY[phase] < 60) continue;
// Check minimum polling interval
if (!this.shouldPoll(flight, phase)) continue;
candidates.push({
flight,
phase,
priority: PHASE_PRIORITY[phase],
});
}
if (candidates.length === 0) return;
// 4. Pick the highest-priority candidate
candidates.sort((a, b) => b.priority - a.priority);
const best = candidates[0];
this.logger.log(
`Auto-polling flight ${best.flight.flightNumber} (phase: ${best.phase}, priority: ${best.priority}, budget: ${budget.requestsUsed}/${budget.requestLimit})`,
);
// 5. Poll it
await this.callAviationStackAndUpdate(best.flight);
} catch (error) {
this.logger.error(`Flight polling cron error: ${error.message}`, error.stack);
}
}
// ============================================
// Manual Refresh (coordinator-triggered)
// ============================================
async refreshFlight(flightId: string) {
const flight = await this.prisma.flight.findUnique({
where: { id: flightId },
include: { vip: true },
});
if (!flight) {
throw new NotFoundException(`Flight ${flightId} not found`);
}
if (!this.apiKey) {
return {
message: 'Flight tracking API not configured',
flight,
};
}
const updated = await this.callAviationStackAndUpdate(flight);
return updated;
}
async refreshActiveFlights() {
if (!this.apiKey) {
return { refreshed: 0, skipped: 0, budgetRemaining: 0, message: 'API key not configured' };
}
const budget = await this.getOrCreateBudget();
const remaining = budget.requestLimit - budget.requestsUsed;
// Get active flights that would benefit from refresh
const flights = await this.prisma.flight.findMany({
where: {
trackingPhase: {
in: [PHASE.ACTIVE, PHASE.ARRIVAL_WINDOW, PHASE.DEPARTURE_WINDOW],
},
},
include: { vip: true },
orderBy: { scheduledDeparture: 'asc' },
});
let refreshed = 0;
let skipped = 0;
for (const flight of flights) {
if (refreshed >= remaining) {
skipped += flights.length - refreshed - skipped;
break;
}
try {
await this.callAviationStackAndUpdate(flight);
refreshed++;
} catch (error) {
this.logger.error(`Failed to refresh flight ${flight.flightNumber}: ${error.message}`);
skipped++;
}
}
const updatedBudget = await this.getOrCreateBudget();
return {
refreshed,
skipped,
budgetRemaining: updatedBudget.requestLimit - updatedBudget.requestsUsed,
};
}
// ============================================
// Budget Management
// ============================================
async getBudgetStatus() {
const budget = await this.getOrCreateBudget();
return {
used: budget.requestsUsed,
limit: budget.requestLimit,
remaining: budget.requestLimit - budget.requestsUsed,
month: budget.monthYear,
};
}
private async getOrCreateBudget() {
const monthYear = this.getCurrentMonthYear();
let budget = await this.prisma.flightApiBudget.findUnique({
where: { monthYear },
});
if (!budget) {
budget = await this.prisma.flightApiBudget.create({
data: { monthYear, requestLimit: 100 },
});
}
return budget;
}
private async incrementBudget() {
const monthYear = this.getCurrentMonthYear();
return this.prisma.flightApiBudget.upsert({
where: { monthYear },
update: {
requestsUsed: { increment: 1 },
lastRequestAt: new Date(),
},
create: {
monthYear,
requestsUsed: 1,
requestLimit: 100,
lastRequestAt: new Date(),
},
});
}
private getCurrentMonthYear(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
}
// ============================================
// Phase Calculation
// ============================================
calculateTrackingPhase(flight: Flight): string {
const now = new Date();
const status = flight.status?.toLowerCase();
// Terminal states
if (status === 'landed' || flight.actualArrival) return PHASE.LANDED;
if (STATUS_TO_TERMINAL.includes(status || '')) return PHASE.TERMINAL;
// Active in flight
if (status === 'active') {
// Check if within arrival window
const eta = flight.estimatedArrival || flight.scheduledArrival;
if (eta) {
const minutesToArrival = (new Date(eta).getTime() - now.getTime()) / 60000;
if (minutesToArrival <= 60) return PHASE.ARRIVAL_WINDOW;
}
return PHASE.ACTIVE;
}
// Pre-departure phases based on scheduled departure
const departure = flight.estimatedDeparture || flight.scheduledDeparture;
if (!departure) return PHASE.FAR_OUT;
const hoursUntilDeparture = (new Date(departure).getTime() - now.getTime()) / 3600000;
if (hoursUntilDeparture <= 0) {
// Past scheduled departure but no "active" status from API
// Could be delayed at gate - treat as departure window
return PHASE.DEPARTURE_WINDOW;
}
if (hoursUntilDeparture <= 6) return PHASE.DEPARTURE_WINDOW;
if (hoursUntilDeparture <= 24) return PHASE.PRE_DEPARTURE;
return PHASE.FAR_OUT;
}
// ============================================
// Polling Decision
// ============================================
private shouldPoll(flight: Flight, phase: string): boolean {
const minInterval = MIN_POLL_INTERVAL[phase];
if (!isFinite(minInterval)) return false;
if (!flight.lastPolledAt) return true; // Never polled
const minutesSincePoll = (Date.now() - new Date(flight.lastPolledAt).getTime()) / 60000;
return minutesSincePoll >= minInterval;
}
// ============================================
// AviationStack API Integration
// ============================================
private async callAviationStackAndUpdate(flight: Flight & { vip?: any }): Promise<Flight> {
const flightDate = flight.flightDate
? toDateString(new Date(flight.flightDate))
: undefined;
try {
const params: any = {
access_key: this.apiKey,
flight_iata: flight.flightNumber,
};
if (flightDate) {
params.flight_date = flightDate;
}
const response = await firstValueFrom(
this.httpService.get(`${this.baseUrl}/flights`, {
params,
timeout: 15000,
}),
);
// Increment budget after successful call
await this.incrementBudget();
const data = response.data as any;
if (data?.error) {
this.logger.warn(`AviationStack API error for ${flight.flightNumber}: ${data.error.message || JSON.stringify(data.error)}`);
// Still update lastPolledAt so we don't spam on errors
return this.prisma.flight.update({
where: { id: flight.id },
data: { lastPolledAt: new Date(), pollCount: { increment: 1 } },
include: { vip: true },
});
}
if (data?.data && data.data.length > 0) {
const apiResult = data.data[0];
const updateData = this.parseAviationStackResponse(apiResult);
// Calculate new phase based on updated data
const tempFlight = { ...flight, ...updateData };
const newPhase = this.calculateTrackingPhase(tempFlight as Flight);
const updated = await this.prisma.flight.update({
where: { id: flight.id },
data: {
...updateData,
trackingPhase: newPhase,
lastPolledAt: new Date(),
pollCount: { increment: 1 },
lastApiResponse: apiResult,
},
include: { vip: true },
});
this.logger.log(
`Updated flight ${flight.flightNumber}: status=${updated.status}, phase=${newPhase}, delay=${updated.arrivalDelay || 0}min`,
);
return updated;
}
// Flight not found in API
this.logger.warn(`Flight ${flight.flightNumber} not found in AviationStack API`);
return this.prisma.flight.update({
where: { id: flight.id },
data: { lastPolledAt: new Date(), pollCount: { increment: 1 } },
include: { vip: true },
});
} catch (error) {
this.logger.error(`AviationStack API call failed for ${flight.flightNumber}: ${error.message}`);
// Still update lastPolledAt on error to prevent rapid retries
return this.prisma.flight.update({
where: { id: flight.id },
data: { lastPolledAt: new Date() },
include: { vip: true },
});
}
}
// ============================================
// Response Parser
// ============================================
private parseAviationStackResponse(apiData: any): Partial<Flight> {
const update: any = {};
// Flight status
if (apiData.flight_status) {
update.status = apiData.flight_status;
}
// Departure info
if (apiData.departure) {
const dep = apiData.departure;
if (dep.terminal) update.departureTerminal = dep.terminal;
if (dep.gate) update.departureGate = dep.gate;
if (dep.delay != null) update.departureDelay = dep.delay;
if (dep.scheduled) update.scheduledDeparture = new Date(dep.scheduled);
if (dep.estimated) update.estimatedDeparture = new Date(dep.estimated);
if (dep.actual) update.actualDeparture = new Date(dep.actual);
// Store departure airport name if we only had IATA code
if (dep.iata && !update.departureAirport) update.departureAirport = dep.iata;
}
// Arrival info
if (apiData.arrival) {
const arr = apiData.arrival;
if (arr.terminal) update.arrivalTerminal = arr.terminal;
if (arr.gate) update.arrivalGate = arr.gate;
if (arr.baggage) update.arrivalBaggage = arr.baggage;
if (arr.delay != null) update.arrivalDelay = arr.delay;
if (arr.scheduled) update.scheduledArrival = new Date(arr.scheduled);
if (arr.estimated) update.estimatedArrival = new Date(arr.estimated);
if (arr.actual) update.actualArrival = new Date(arr.actual);
if (arr.iata && !update.arrivalAirport) update.arrivalAirport = arr.iata;
}
// Airline info
if (apiData.airline) {
if (apiData.airline.name) update.airlineName = apiData.airline.name;
if (apiData.airline.iata) update.airlineIata = apiData.airline.iata;
}
// Aircraft info
if (apiData.aircraft?.iata) {
update.aircraftType = apiData.aircraft.iata;
}
// Live tracking data (may not be available on free tier)
if (apiData.live) {
const live = apiData.live;
if (live.latitude != null) update.liveLatitude = live.latitude;
if (live.longitude != null) update.liveLongitude = live.longitude;
if (live.altitude != null) update.liveAltitude = live.altitude;
if (live.speed_horizontal != null) update.liveSpeed = live.speed_horizontal;
if (live.direction != null) update.liveDirection = live.direction;
if (live.is_ground != null) update.liveIsGround = live.is_ground;
if (live.updated) update.liveUpdatedAt = new Date(live.updated);
}
return update;
}
}

View File

@@ -10,16 +10,21 @@ import {
UseGuards,
} from '@nestjs/common';
import { FlightsService } from './flights.service';
import { FlightTrackingService } from './flight-tracking.service';
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 { CreateFlightDto, UpdateFlightDto } from './dto';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('flights')
@UseGuards(JwtAuthGuard, RolesGuard)
export class FlightsController {
constructor(private readonly flightsService: FlightsService) {}
constructor(
private readonly flightsService: FlightsService,
private readonly flightTrackingService: FlightTrackingService,
) {}
@Post()
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
@@ -33,6 +38,20 @@ export class FlightsController {
return this.flightsService.findAll();
}
// --- Tracking Endpoints (must come before :id param routes) ---
@Get('tracking/budget')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
getBudgetStatus() {
return this.flightTrackingService.getBudgetStatus();
}
@Post('refresh-active')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
refreshActiveFlights() {
return this.flightTrackingService.refreshActiveFlights();
}
@Get('status/:flightNumber')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
getFlightStatus(
@@ -54,6 +73,12 @@ export class FlightsController {
return this.flightsService.findOne(id);
}
@Post(':id/refresh')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
refreshFlight(@Param('id') id: string) {
return this.flightTrackingService.refreshFlight(id);
}
@Patch(':id')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
update(@Param('id') id: string, @Body() updateFlightDto: UpdateFlightDto) {
@@ -64,9 +89,8 @@ export class FlightsController {
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
) {
const isHardDelete = hard === 'true';
return this.flightsService.remove(id, isHardDelete);
return this.flightsService.remove(id, hard);
}
}

View File

@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { FlightsController } from './flights.controller';
import { FlightsService } from './flights.service';
import { FlightTrackingService } from './flight-tracking.service';
@Module({
imports: [HttpModule],
controllers: [FlightsController],
providers: [FlightsService],
exports: [FlightsService],
providers: [FlightsService, FlightTrackingService],
exports: [FlightsService, FlightTrackingService],
})
export class FlightsModule {}

View File

@@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { CreateFlightDto, UpdateFlightDto } from './dto';
import { firstValueFrom } from 'rxjs';
import { convertOptionalDates } from '../common/utils/date.utils';
@Injectable()
export class FlightsService {
@@ -24,17 +25,16 @@ export class FlightsService {
`Creating flight: ${createFlightDto.flightNumber} for VIP ${createFlightDto.vipId}`,
);
return this.prisma.flight.create({
data: {
const data = convertOptionalDates(
{
...createFlightDto,
flightDate: new Date(createFlightDto.flightDate),
scheduledDeparture: createFlightDto.scheduledDeparture
? new Date(createFlightDto.scheduledDeparture)
: undefined,
scheduledArrival: createFlightDto.scheduledArrival
? new Date(createFlightDto.scheduledArrival)
: undefined,
},
['scheduledDeparture', 'scheduledArrival'],
);
return this.prisma.flight.create({
data,
include: { vip: true },
});
}
@@ -71,24 +71,13 @@ export class FlightsService {
this.logger.log(`Updating flight ${id}: ${flight.flightNumber}`);
const updateData: any = { ...updateFlightDto };
const dto = updateFlightDto as any; // Type assertion to work around PartialType
if (dto.flightDate) {
updateData.flightDate = new Date(dto.flightDate);
}
if (dto.scheduledDeparture) {
updateData.scheduledDeparture = new Date(dto.scheduledDeparture);
}
if (dto.scheduledArrival) {
updateData.scheduledArrival = new Date(dto.scheduledArrival);
}
if (dto.actualDeparture) {
updateData.actualDeparture = new Date(dto.actualDeparture);
}
if (dto.actualArrival) {
updateData.actualArrival = new Date(dto.actualArrival);
}
const updateData = convertOptionalDates(updateFlightDto, [
'flightDate',
'scheduledDeparture',
'scheduledArrival',
'actualDeparture',
'actualArrival',
]);
return this.prisma.flight.update({
where: { id: flight.id },

View File

@@ -0,0 +1,21 @@
import { IsBoolean, IsOptional } from 'class-validator';
export class EnrollDriverDto {
@IsOptional()
@IsBoolean()
sendSignalMessage?: boolean = true;
}
export class EnrollmentResponseDto {
success: boolean;
deviceIdentifier: string;
serverUrl: string;
port: number;
instructions: string;
signalMessageSent?: boolean;
}
export class ConfirmConsentDto {
@IsBoolean()
consentGiven: boolean;
}

View File

@@ -0,0 +1,3 @@
export * from './enroll-driver.dto';
export * from './update-gps-settings.dto';
export * from './location-response.dto';

View File

@@ -0,0 +1,52 @@
export class DriverLocationDto {
driverId: string;
driverName: string;
driverPhone: string | null;
deviceIdentifier: string;
isActive: boolean;
lastActive: Date | null;
location: LocationDataDto | null;
}
export class LocationDataDto {
latitude: number;
longitude: number;
altitude: number | null;
speed: number | null; // mph
course: number | null;
accuracy: number | null;
battery: number | null;
timestamp: Date;
}
export class DriverStatsDto {
driverId: string;
driverName: string;
period: {
from: Date;
to: Date;
};
stats: {
totalMiles: number;
topSpeedMph: number;
topSpeedTimestamp: Date | null;
averageSpeedMph: number;
totalTrips: number;
totalDrivingMinutes: number;
distanceMethod?: string; // 'osrm' or 'haversine'
};
recentLocations: LocationDataDto[];
}
export class GpsStatusDto {
traccarAvailable: boolean;
traccarVersion: string | null;
enrolledDrivers: number;
activeDrivers: number;
settings: {
updateIntervalSeconds: number;
shiftStartTime: string;
shiftEndTime: string;
retentionDays: number;
};
}

View File

@@ -0,0 +1,47 @@
import { IsInt, IsOptional, IsString, Min, Max } from 'class-validator';
export class UpdateGpsSettingsDto {
@IsOptional()
@IsInt()
@Min(10)
@Max(600)
updateIntervalSeconds?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(23)
shiftStartHour?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(59)
shiftStartMinute?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(23)
shiftEndHour?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(59)
shiftEndMinute?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(365)
retentionDays?: number;
@IsOptional()
@IsString()
traccarAdminUser?: string;
@IsOptional()
@IsString()
traccarAdminPassword?: string;
}

View File

@@ -0,0 +1,316 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { GpsService } from './gps.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Role } from '@prisma/client';
import { EnrollDriverDto, ConfirmConsentDto } from './dto/enroll-driver.dto';
import { UpdateGpsSettingsDto } from './dto/update-gps-settings.dto';
import { PrismaService } from '../prisma/prisma.service';
@Controller('gps')
@UseGuards(JwtAuthGuard, RolesGuard)
export class GpsController {
constructor(
private readonly gpsService: GpsService,
private readonly prisma: PrismaService,
) {}
// ============================================
// Admin-only endpoints
// ============================================
/**
* Get GPS system status
*/
@Get('status')
@Roles(Role.ADMINISTRATOR)
async getStatus() {
return this.gpsService.getStatus();
}
/**
* Get GPS settings
*/
@Get('settings')
@Roles(Role.ADMINISTRATOR)
async getSettings() {
const settings = await this.gpsService.getSettings();
// Don't return the password
return {
...settings,
traccarAdminPassword: settings.traccarAdminPassword ? '********' : null,
};
}
/**
* Update GPS settings
*/
@Patch('settings')
@Roles(Role.ADMINISTRATOR)
async updateSettings(@Body() dto: UpdateGpsSettingsDto) {
const settings = await this.gpsService.updateSettings(dto);
return {
...settings,
traccarAdminPassword: settings.traccarAdminPassword ? '********' : null,
};
}
/**
* Get all enrolled devices
*/
@Get('devices')
@Roles(Role.ADMINISTRATOR)
async getEnrolledDevices() {
return this.gpsService.getEnrolledDevices();
}
/**
* Get QR code info for an enrolled device
*/
@Get('devices/:driverId/qr')
@Roles(Role.ADMINISTRATOR)
async getDeviceQr(@Param('driverId') driverId: string) {
return this.gpsService.getDeviceQrInfo(driverId);
}
/**
* Enroll a driver for GPS tracking
*/
@Post('enroll/:driverId')
@Roles(Role.ADMINISTRATOR)
async enrollDriver(
@Param('driverId') driverId: string,
@Body() dto: EnrollDriverDto,
) {
return this.gpsService.enrollDriver(driverId, dto.sendSignalMessage ?? true);
}
/**
* Unenroll a driver from GPS tracking
*/
@Delete('devices/:driverId')
@Roles(Role.ADMINISTRATOR)
async unenrollDriver(@Param('driverId') driverId: string) {
return this.gpsService.unenrollDriver(driverId);
}
/**
* Get all active driver locations (used by CommandCenter)
*/
@Get('locations')
@Roles(Role.ADMINISTRATOR)
async getActiveDriverLocations() {
return this.gpsService.getActiveDriverLocations();
}
// ============================================
// Traccar Admin Access
// ============================================
/**
* Check Traccar setup status
*/
@Get('traccar/status')
@Roles(Role.ADMINISTRATOR)
async getTraccarSetupStatus() {
return this.gpsService.checkTraccarSetup();
}
/**
* Perform initial Traccar setup
*/
@Post('traccar/setup')
@Roles(Role.ADMINISTRATOR)
async performTraccarSetup(@CurrentUser() user: any) {
const success = await this.gpsService.performTraccarSetup(user.email);
if (!success) {
throw new NotFoundException('Failed to setup Traccar. It may already be configured.');
}
return { success: true, message: 'Traccar setup complete' };
}
/**
* Sync all VIP admins to Traccar
*/
@Post('traccar/sync-admins')
@Roles(Role.ADMINISTRATOR)
async syncAdminsToTraccar() {
return this.gpsService.syncAllAdminsToTraccar();
}
/**
* Get Traccar admin URL (auto-login for current user)
*/
@Get('traccar/admin-url')
@Roles(Role.ADMINISTRATOR)
async getTraccarAdminUrl(@CurrentUser() user: any) {
// Get full user from database
const fullUser = await this.prisma.user.findUnique({
where: { id: user.id },
});
if (!fullUser) {
throw new NotFoundException('User not found');
}
return this.gpsService.getTraccarAutoLoginUrl(fullUser);
}
/**
* Get Traccar session for iframe/proxy access
*/
@Get('traccar/session')
@Roles(Role.ADMINISTRATOR)
async getTraccarSession(@CurrentUser() user: any) {
const fullUser = await this.prisma.user.findUnique({
where: { id: user.id },
});
if (!fullUser) {
throw new NotFoundException('User not found');
}
const session = await this.gpsService.getTraccarSessionForUser(fullUser);
if (!session) {
throw new NotFoundException('Could not create Traccar session');
}
return { session };
}
// ============================================
// Driver self-service endpoints
// ============================================
/**
* Get my GPS enrollment status
*/
@Get('me')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
async getMyGpsStatus(@CurrentUser() user: any) {
// Find driver linked to this user
const driver = await this.prisma.driver.findFirst({
where: {
userId: user.id,
deletedAt: null,
},
include: {
gpsDevice: true,
},
});
if (!driver) {
return { enrolled: false, message: 'No driver profile linked to your account' };
}
if (!driver.gpsDevice) {
return { enrolled: false, driverId: driver.id };
}
return {
enrolled: true,
driverId: driver.id,
deviceIdentifier: driver.gpsDevice.deviceIdentifier,
consentGiven: driver.gpsDevice.consentGiven,
consentGivenAt: driver.gpsDevice.consentGivenAt,
isActive: driver.gpsDevice.isActive,
lastActive: driver.gpsDevice.lastActive,
};
}
/**
* Confirm GPS tracking consent (Driver accepting tracking)
*/
@Post('me/consent')
@Roles(Role.DRIVER)
async confirmMyConsent(
@CurrentUser() user: any,
@Body() dto: ConfirmConsentDto,
) {
const driver = await this.prisma.driver.findFirst({
where: {
userId: user.id,
deletedAt: null,
},
});
if (!driver) {
throw new NotFoundException('No driver profile linked to your account');
}
await this.gpsService.confirmConsent(driver.id, dto.consentGiven);
return {
success: true,
message: dto.consentGiven
? 'GPS tracking consent confirmed. Your location will be tracked during shift hours.'
: 'GPS tracking consent revoked. Your location will not be tracked.',
};
}
/**
* Get my GPS stats (Driver viewing own stats)
*/
@Get('me/stats')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
async getMyStats(
@CurrentUser() user: any,
@Query('from') fromStr?: string,
@Query('to') toStr?: string,
) {
const driver = await this.prisma.driver.findFirst({
where: {
userId: user.id,
deletedAt: null,
},
});
if (!driver) {
throw new NotFoundException('No driver profile linked to your account');
}
const from = fromStr ? new Date(fromStr) : undefined;
const to = toStr ? new Date(toStr) : undefined;
return this.gpsService.getDriverStats(driver.id, from, to);
}
/**
* Get my current location
*/
@Get('me/location')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
async getMyLocation(@CurrentUser() user: any) {
const driver = await this.prisma.driver.findFirst({
where: {
userId: user.id,
deletedAt: null,
},
});
if (!driver) {
throw new NotFoundException('No driver profile linked to your account');
}
const location = await this.gpsService.getDriverLocation(driver.id);
if (!location) {
throw new NotFoundException('You are not enrolled for GPS tracking');
}
return location;
}
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { GpsController } from './gps.controller';
import { GpsService } from './gps.service';
import { TraccarClientService } from './traccar-client.service';
import { PrismaModule } from '../prisma/prisma.module';
import { SignalModule } from '../signal/signal.module';
@Module({
imports: [
PrismaModule,
SignalModule,
ScheduleModule.forRoot(),
],
controllers: [GpsController],
providers: [GpsService, TraccarClientService],
exports: [GpsService, TraccarClientService],
})
export class GpsModule {}

View File

@@ -0,0 +1,927 @@
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
OnModuleInit,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { SignalService } from '../signal/signal.service';
import { TraccarClientService } from './traccar-client.service';
import {
DriverLocationDto,
DriverStatsDto,
GpsStatusDto,
LocationDataDto,
} from './dto/location-response.dto';
import { UpdateGpsSettingsDto } from './dto/update-gps-settings.dto';
import { GpsSettings, User } from '@prisma/client';
import * as crypto from 'crypto';
@Injectable()
export class GpsService implements OnModuleInit {
private readonly logger = new Logger(GpsService.name);
constructor(
private prisma: PrismaService,
private traccarClient: TraccarClientService,
private signalService: SignalService,
private configService: ConfigService,
) {}
async onModuleInit() {
// Ensure GPS settings exist and load Traccar credentials
const settings = await this.getSettings();
// Set Traccar credentials from database settings
if (settings.traccarAdminUser && settings.traccarAdminPassword) {
this.traccarClient.setCredentials(
settings.traccarAdminUser,
settings.traccarAdminPassword,
);
this.logger.log(`Loaded Traccar credentials for user: ${settings.traccarAdminUser}`);
}
}
/**
* Get or create GPS settings (singleton pattern)
*/
async getSettings(): Promise<GpsSettings> {
let settings = await this.prisma.gpsSettings.findFirst();
if (!settings) {
this.logger.log('Creating default GPS settings');
settings = await this.prisma.gpsSettings.create({
data: {
updateIntervalSeconds: 60,
shiftStartHour: 4,
shiftStartMinute: 0,
shiftEndHour: 1,
shiftEndMinute: 0,
retentionDays: 30,
traccarAdminUser: 'admin',
traccarAdminPassword: 'admin', // Default - should be changed!
},
});
}
return settings;
}
/**
* Update GPS settings
*/
async updateSettings(dto: UpdateGpsSettingsDto): Promise<GpsSettings> {
const settings = await this.getSettings();
const updated = await this.prisma.gpsSettings.update({
where: { id: settings.id },
data: dto,
});
// Update Traccar client credentials if changed
if (dto.traccarAdminUser || dto.traccarAdminPassword) {
this.traccarClient.setCredentials(
dto.traccarAdminUser || settings.traccarAdminUser,
dto.traccarAdminPassword || settings.traccarAdminPassword || 'admin',
);
}
return updated;
}
/**
* Get GPS system status
*/
async getStatus(): Promise<GpsStatusDto> {
const settings = await this.getSettings();
const traccarAvailable = await this.traccarClient.isAvailable();
let traccarVersion: string | null = null;
if (traccarAvailable) {
try {
const serverInfo = await this.traccarClient.getServerInfo();
traccarVersion = serverInfo.version;
} catch {
// Ignore
}
}
const enrolledDrivers = await this.prisma.gpsDevice.count();
const activeDrivers = await this.prisma.gpsDevice.count({
where: {
isActive: true,
lastActive: {
gte: new Date(Date.now() - 5 * 60 * 1000), // Active in last 5 minutes
},
},
});
return {
traccarAvailable,
traccarVersion,
enrolledDrivers,
activeDrivers,
settings: {
updateIntervalSeconds: settings.updateIntervalSeconds,
shiftStartTime: `${settings.shiftStartHour.toString().padStart(2, '0')}:${settings.shiftStartMinute.toString().padStart(2, '0')}`,
shiftEndTime: `${settings.shiftEndHour.toString().padStart(2, '0')}:${settings.shiftEndMinute.toString().padStart(2, '0')}`,
retentionDays: settings.retentionDays,
},
};
}
/**
* Enroll a driver for GPS tracking
*/
async enrollDriver(
driverId: string,
sendSignalMessage: boolean = true,
): Promise<{
success: boolean;
deviceIdentifier: string;
serverUrl: string;
qrCodeUrl: string;
instructions: string;
signalMessageSent?: boolean;
}> {
// Check if driver exists
const driver = await this.prisma.driver.findUnique({
where: { id: driverId },
include: { gpsDevice: true },
});
if (!driver) {
throw new NotFoundException('Driver not found');
}
if (driver.deletedAt) {
throw new BadRequestException('Cannot enroll deleted driver');
}
if (driver.gpsDevice) {
throw new BadRequestException('Driver is already enrolled for GPS tracking');
}
// Generate unique device identifier (lowercase alphanumeric only for compatibility)
const deviceIdentifier = `vipdriver${driverId.replace(/-/g, '').slice(0, 8)}`.toLowerCase();
this.logger.log(`Enrolling driver ${driver.name} with device identifier: ${deviceIdentifier}`);
// Create device in Traccar
const traccarDevice = await this.traccarClient.createDevice(
driver.name,
deviceIdentifier,
driver.phone || undefined,
);
// Use the uniqueId returned by Traccar (in case it was modified)
const actualDeviceId = traccarDevice.uniqueId;
this.logger.log(`Traccar returned device with uniqueId: ${actualDeviceId}`);
// Create GPS device record (consent pre-approved by HR at hiring)
await this.prisma.gpsDevice.create({
data: {
driverId,
traccarDeviceId: traccarDevice.id,
deviceIdentifier: actualDeviceId, // Use what Traccar actually stored
consentGiven: true,
consentGivenAt: new Date(),
},
});
const serverUrl = this.traccarClient.getDeviceServerUrl();
const settings = await this.getSettings();
// Build QR code URL for Traccar Client app
const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055;
const traccarPublicUrl = this.traccarClient.getTraccarUrl();
const qrUrl = new URL(traccarPublicUrl);
qrUrl.port = String(devicePort);
qrUrl.searchParams.set('id', actualDeviceId);
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
qrUrl.searchParams.set('accuracy', 'highest');
qrUrl.searchParams.set('distance', '0');
qrUrl.searchParams.set('angle', '30');
qrUrl.searchParams.set('heartbeat', '300');
qrUrl.searchParams.set('stop_detection', 'false');
qrUrl.searchParams.set('buffer', 'true');
const qrCodeUrl = qrUrl.toString();
this.logger.log(`QR code URL for driver: ${qrCodeUrl}`);
const instructions = `
GPS Tracking Setup Instructions for ${driver.name}:
1. Download "Traccar Client" app:
- iOS: https://apps.apple.com/app/traccar-client/id843156974
- Android: https://play.google.com/store/apps/details?id=org.traccar.client
2. Open the app and scan the QR code (or configure manually):
- Device identifier: ${actualDeviceId}
- Server URL: ${serverUrl}
- Location accuracy: Highest
- Frequency: ${settings.updateIntervalSeconds} seconds
- Distance: 0
- Angle: 30
3. IMPORTANT iPhone Settings:
- Settings > Privacy > Location Services > Traccar Client > "Always"
- Settings > General > Background App Refresh > ON for Traccar Client
- Do NOT swipe the app away from the app switcher
- Low Power Mode should be OFF while driving
4. Tap "Service Status" to start tracking.
`.trim();
let signalMessageSent = false;
// Send Signal message if requested and driver has phone
if (sendSignalMessage && driver.phone) {
try {
const linkedNumber = await this.signalService.getLinkedNumber();
if (linkedNumber) {
const formattedPhone = this.signalService.formatPhoneNumber(driver.phone);
const result = await this.signalService.sendMessage(
linkedNumber,
formattedPhone,
instructions,
);
signalMessageSent = result.success;
}
} catch (error) {
this.logger.warn(`Failed to send Signal message to driver: ${error}`);
}
}
return {
success: true,
deviceIdentifier: actualDeviceId,
serverUrl,
qrCodeUrl,
instructions,
signalMessageSent,
};
}
/**
* Get QR code info for an already-enrolled device
*/
async getDeviceQrInfo(driverId: string): Promise<{
driverName: string;
deviceIdentifier: string;
serverUrl: string;
qrCodeUrl: string;
updateIntervalSeconds: number;
}> {
const device = await this.prisma.gpsDevice.findUnique({
where: { driverId },
include: { driver: { select: { id: true, name: true } } },
});
if (!device) {
throw new NotFoundException('Driver is not enrolled for GPS tracking');
}
const settings = await this.getSettings();
const serverUrl = this.traccarClient.getDeviceServerUrl();
const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055;
const traccarPublicUrl = this.traccarClient.getTraccarUrl();
const qrUrl = new URL(traccarPublicUrl);
qrUrl.port = String(devicePort);
qrUrl.searchParams.set('id', device.deviceIdentifier);
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
qrUrl.searchParams.set('accuracy', 'highest');
qrUrl.searchParams.set('distance', '0');
qrUrl.searchParams.set('angle', '30');
qrUrl.searchParams.set('heartbeat', '300');
qrUrl.searchParams.set('stop_detection', 'false');
qrUrl.searchParams.set('buffer', 'true');
return {
driverName: device.driver.name,
deviceIdentifier: device.deviceIdentifier,
serverUrl,
qrCodeUrl: qrUrl.toString(),
updateIntervalSeconds: settings.updateIntervalSeconds,
};
}
/**
* Unenroll a driver from GPS tracking
*/
async unenrollDriver(driverId: string): Promise<{ success: boolean; message: string }> {
const gpsDevice = await this.prisma.gpsDevice.findUnique({
where: { driverId },
});
if (!gpsDevice) {
throw new NotFoundException('Driver is not enrolled for GPS tracking');
}
// Delete from Traccar
try {
await this.traccarClient.deleteDevice(gpsDevice.traccarDeviceId);
} catch (error) {
this.logger.warn(`Failed to delete device from Traccar: ${error}`);
}
// Delete location history
await this.prisma.gpsLocationHistory.deleteMany({
where: { deviceId: gpsDevice.id },
});
// Delete GPS device record
await this.prisma.gpsDevice.delete({
where: { id: gpsDevice.id },
});
return {
success: true,
message: 'Driver unenrolled from GPS tracking. All location history has been deleted.',
};
}
/**
* Confirm driver consent for GPS tracking
*/
async confirmConsent(driverId: string, consentGiven: boolean): Promise<void> {
const gpsDevice = await this.prisma.gpsDevice.findUnique({
where: { driverId },
});
if (!gpsDevice) {
throw new NotFoundException('Driver is not enrolled for GPS tracking');
}
await this.prisma.gpsDevice.update({
where: { id: gpsDevice.id },
data: {
consentGiven,
consentGivenAt: consentGiven ? new Date() : null,
},
});
}
/**
* Get all enrolled devices
*/
async getEnrolledDevices(): Promise<any[]> {
return this.prisma.gpsDevice.findMany({
include: {
driver: {
select: {
id: true,
name: true,
phone: true,
},
},
},
orderBy: { enrolledAt: 'desc' },
});
}
/**
* Get all active driver locations (used by CommandCenter + GPS page)
*/
async getActiveDriverLocations(): Promise<DriverLocationDto[]> {
const devices = await this.prisma.gpsDevice.findMany({
where: {
isActive: true,
},
include: {
driver: {
select: {
id: true,
name: true,
phone: true,
},
},
},
});
// Get all positions from Traccar
let positions: any[] = [];
try {
positions = await this.traccarClient.getAllPositions();
} catch (error) {
this.logger.warn(`Failed to fetch positions from Traccar: ${error}`);
}
return devices.map((device) => {
const position = positions.find((p) => p.deviceId === device.traccarDeviceId);
return {
driverId: device.driverId,
driverName: device.driver.name,
driverPhone: device.driver.phone,
deviceIdentifier: device.deviceIdentifier,
isActive: device.isActive,
lastActive: device.lastActive,
location: position
? {
latitude: position.latitude,
longitude: position.longitude,
altitude: position.altitude || null,
speed: this.traccarClient.knotsToMph(position.speed || 0),
course: position.course || null,
accuracy: position.accuracy || null,
battery: position.attributes?.batteryLevel || null,
timestamp: new Date(position.deviceTime),
}
: null,
};
});
}
/**
* Get a specific driver's location (used by driver self-service)
*/
async getDriverLocation(driverId: string): Promise<DriverLocationDto | null> {
const device = await this.prisma.gpsDevice.findUnique({
where: { driverId },
include: {
driver: {
select: {
id: true,
name: true,
phone: true,
},
},
},
});
if (!device) {
return null;
}
let position = null;
try {
position = await this.traccarClient.getDevicePosition(device.traccarDeviceId);
} catch (error) {
this.logger.warn(`Failed to fetch position for driver ${driverId}: ${error}`);
}
return {
driverId: device.driverId,
driverName: device.driver.name,
driverPhone: device.driver.phone,
deviceIdentifier: device.deviceIdentifier,
isActive: device.isActive,
lastActive: device.lastActive,
location: position
? {
latitude: position.latitude,
longitude: position.longitude,
altitude: position.altitude || null,
speed: this.traccarClient.knotsToMph(position.speed || 0),
course: position.course || null,
accuracy: position.accuracy || null,
battery: position.attributes?.batteryLevel || null,
timestamp: new Date(position.deviceTime),
}
: null,
};
}
/**
* Calculate distance between two GPS coordinates using Haversine formula
* Returns distance in miles
*/
private calculateHaversineDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number {
const R = 3958.8; // Earth's radius in miles
const dLat = this.toRadians(lat2 - lat1);
const dLon = this.toRadians(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRadians(lat1)) *
Math.cos(this.toRadians(lat2)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
private toRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}
/**
* Calculate total distance from position history
*/
private async calculateDistanceFromHistory(
deviceId: string,
from: Date,
to: Date,
): Promise<number> {
const positions = await this.prisma.gpsLocationHistory.findMany({
where: {
deviceId,
timestamp: {
gte: from,
lte: to,
},
},
orderBy: { timestamp: 'asc' },
select: {
latitude: true,
longitude: true,
timestamp: true,
speed: true,
accuracy: true,
},
});
if (positions.length < 2) {
return 0;
}
let totalMiles = 0;
for (let i = 1; i < positions.length; i++) {
const prev = positions[i - 1];
const curr = positions[i];
const timeDiffMs = curr.timestamp.getTime() - prev.timestamp.getTime();
const timeDiffMinutes = timeDiffMs / 60000;
// Skip if gap is too large (more than 10 minutes)
if (timeDiffMinutes > 10) continue;
const distance = this.calculateHaversineDistance(
prev.latitude,
prev.longitude,
curr.latitude,
curr.longitude,
);
// Sanity check: skip unrealistic distances (> 100 mph equivalent)
const maxPossibleDistance = (timeDiffMinutes / 60) * 100;
if (distance > maxPossibleDistance) continue;
// Filter out GPS jitter (movements < 0.01 miles / ~50 feet)
if (distance < 0.01) continue;
totalMiles += distance;
}
return totalMiles;
}
/**
* Get driver stats (used by driver self-service via me/stats)
*/
async getDriverStats(
driverId: string,
fromDate?: Date,
toDate?: Date,
): Promise<DriverStatsDto> {
const device = await this.prisma.gpsDevice.findUnique({
where: { driverId },
include: {
driver: {
select: {
id: true,
name: true,
},
},
},
});
if (!device) {
throw new NotFoundException('Driver is not enrolled for GPS tracking');
}
// Default to last 7 days if no date range specified
const to = toDate || new Date();
const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000);
const totalMiles = await this.calculateDistanceFromHistory(device.id, from, to);
// Get all positions for speed/time analysis
const allPositions = await this.prisma.gpsLocationHistory.findMany({
where: {
deviceId: device.id,
timestamp: {
gte: from,
lte: to,
},
},
orderBy: { timestamp: 'asc' },
});
let topSpeedMph = 0;
let topSpeedTimestamp: Date | null = null;
let totalDrivingMinutes = 0;
let currentTripStart: Date | null = null;
let totalTrips = 0;
for (const pos of allPositions) {
const speedMph = pos.speed || 0;
if (speedMph > topSpeedMph) {
topSpeedMph = speedMph;
topSpeedTimestamp = pos.timestamp;
}
if (speedMph > 5) {
if (!currentTripStart) {
currentTripStart = pos.timestamp;
totalTrips++;
}
} else if (currentTripStart) {
const tripDurationMs = pos.timestamp.getTime() - currentTripStart.getTime();
totalDrivingMinutes += tripDurationMs / 60000;
currentTripStart = null;
}
}
// Close last trip if still driving
if (currentTripStart && allPositions.length > 0) {
const lastPos = allPositions[allPositions.length - 1];
const tripDurationMs = lastPos.timestamp.getTime() - currentTripStart.getTime();
totalDrivingMinutes += tripDurationMs / 60000;
}
// Get recent locations for display (last 100)
const recentLocations = await this.prisma.gpsLocationHistory.findMany({
where: {
deviceId: device.id,
timestamp: {
gte: from,
lte: to,
},
},
orderBy: { timestamp: 'desc' },
take: 100,
});
const averageSpeedMph =
totalDrivingMinutes > 0
? totalMiles / (totalDrivingMinutes / 60)
: 0;
return {
driverId,
driverName: device.driver.name,
period: {
from,
to,
},
stats: {
totalMiles: Math.round(totalMiles * 10) / 10,
topSpeedMph: Math.round(topSpeedMph),
topSpeedTimestamp,
averageSpeedMph: Math.round(averageSpeedMph * 10) / 10,
totalTrips,
totalDrivingMinutes: Math.round(totalDrivingMinutes),
},
recentLocations: recentLocations.map((loc) => ({
latitude: loc.latitude,
longitude: loc.longitude,
altitude: loc.altitude,
speed: loc.speed,
course: loc.course,
accuracy: loc.accuracy,
battery: loc.battery,
timestamp: loc.timestamp,
})),
};
}
/**
* Sync positions from Traccar to our database (for history/stats)
* Called periodically via cron job
*/
@Cron(CronExpression.EVERY_30_SECONDS)
async syncPositions(): Promise<void> {
const devices = await this.prisma.gpsDevice.findMany({
where: {
isActive: true,
},
});
if (devices.length === 0) {
this.logger.debug('[GPS Sync] No active devices to sync');
return;
}
const now = new Date();
this.logger.log(`[GPS Sync] Starting sync for ${devices.length} active devices`);
for (const device of devices) {
try {
const since = device.lastActive
? new Date(device.lastActive.getTime() - 30000)
: new Date(now.getTime() - 120000);
const positions = await this.traccarClient.getPositionHistory(
device.traccarDeviceId,
since,
now,
);
this.logger.log(`[GPS Sync] Device ${device.traccarDeviceId}: Retrieved ${positions.length} positions from Traccar`);
if (positions.length === 0) continue;
const insertResult = await this.prisma.gpsLocationHistory.createMany({
data: positions.map((p) => ({
deviceId: device.id,
latitude: p.latitude,
longitude: p.longitude,
altitude: p.altitude || null,
speed: this.traccarClient.knotsToMph(p.speed || 0),
course: p.course || null,
accuracy: p.accuracy || null,
battery: p.attributes?.batteryLevel || null,
timestamp: new Date(p.deviceTime),
})),
skipDuplicates: true,
});
const inserted = insertResult.count;
const skipped = positions.length - inserted;
this.logger.log(
`[GPS Sync] Device ${device.traccarDeviceId}: ` +
`Inserted ${inserted} new positions, skipped ${skipped} duplicates`
);
const latestPosition = positions.reduce((latest, p) =>
new Date(p.deviceTime) > new Date(latest.deviceTime) ? p : latest
);
await this.prisma.gpsDevice.update({
where: { id: device.id },
data: { lastActive: new Date(latestPosition.deviceTime) },
});
} catch (error) {
this.logger.error(`[GPS Sync] Failed to sync positions for device ${device.traccarDeviceId}: ${error}`);
}
}
this.logger.log('[GPS Sync] Sync completed');
}
/**
* Clean up old location history (runs daily at 2 AM)
*/
@Cron('0 2 * * *')
async cleanupOldLocations(): Promise<void> {
const settings = await this.getSettings();
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - settings.retentionDays);
const result = await this.prisma.gpsLocationHistory.deleteMany({
where: {
timestamp: { lt: cutoffDate },
},
});
if (result.count > 0) {
this.logger.log(`Cleaned up ${result.count} old GPS location records`);
}
}
// ============================================
// Traccar User Sync (VIP Admin -> Traccar Admin)
// ============================================
private generateTraccarPassword(userId: string): string {
const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-sync';
return crypto
.createHmac('sha256', secret)
.update(userId)
.digest('hex')
.substring(0, 24);
}
private generateTraccarToken(userId: string): string {
const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-token';
return crypto
.createHmac('sha256', secret + '-token')
.update(userId)
.digest('hex')
.substring(0, 32);
}
async syncUserToTraccar(user: User): Promise<boolean> {
if (!user.email) return false;
try {
const isAdmin = user.role === 'ADMINISTRATOR';
const password = this.generateTraccarPassword(user.id);
const token = this.generateTraccarToken(user.id);
await this.traccarClient.createOrUpdateUser(
user.email,
user.name || user.email,
password,
isAdmin,
token,
);
this.logger.log(`Synced user ${user.email} to Traccar (admin: ${isAdmin})`);
return true;
} catch (error) {
this.logger.error(`Failed to sync user ${user.email} to Traccar:`, error);
return false;
}
}
async syncAllAdminsToTraccar(): Promise<{ synced: number; failed: number }> {
const admins = await this.prisma.user.findMany({
where: {
role: 'ADMINISTRATOR',
isApproved: true,
},
});
let synced = 0;
let failed = 0;
for (const admin of admins) {
const success = await this.syncUserToTraccar(admin);
if (success) synced++;
else failed++;
}
this.logger.log(`Admin sync complete: ${synced} synced, ${failed} failed`);
return { synced, failed };
}
async getTraccarAutoLoginUrl(user: User): Promise<{
url: string;
directAccess: boolean;
}> {
if (user.role !== 'ADMINISTRATOR') {
throw new BadRequestException('Only administrators can access Traccar admin');
}
await this.syncUserToTraccar(user);
const token = this.generateTraccarToken(user.id);
const baseUrl = this.traccarClient.getTraccarUrl();
return {
url: `${baseUrl}?token=${token}`,
directAccess: true,
};
}
async getTraccarSessionForUser(user: User): Promise<string | null> {
if (user.role !== 'ADMINISTRATOR') {
return null;
}
await this.syncUserToTraccar(user);
const password = this.generateTraccarPassword(user.id);
const session = await this.traccarClient.createUserSession(user.email, password);
return session?.cookie || null;
}
async checkTraccarSetup(): Promise<{
needsSetup: boolean;
isAvailable: boolean;
}> {
const isAvailable = await this.traccarClient.isAvailable();
if (!isAvailable) {
return { needsSetup: false, isAvailable: false };
}
const needsSetup = await this.traccarClient.needsInitialSetup();
return { needsSetup, isAvailable };
}
async performTraccarSetup(adminEmail: string): Promise<boolean> {
const servicePassword = crypto.randomBytes(16).toString('hex');
const success = await this.traccarClient.performInitialSetup(
adminEmail,
servicePassword,
);
if (success) {
await this.updateSettings({
traccarAdminUser: adminEmail,
traccarAdminPassword: servicePassword,
});
this.logger.log('Traccar initial setup complete');
return true;
}
return false;
}
}

View File

@@ -0,0 +1,593 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
export interface TraccarDevice {
id: number;
name: string;
uniqueId: string;
status: string;
disabled: boolean;
lastUpdate: string | null;
positionId: number | null;
groupId: number | null;
phone: string | null;
model: string | null;
contact: string | null;
category: string | null;
attributes: Record<string, any>;
}
export interface TraccarPosition {
id: number;
deviceId: number;
protocol: string;
deviceTime: string;
fixTime: string;
serverTime: string;
outdated: boolean;
valid: boolean;
latitude: number;
longitude: number;
altitude: number;
speed: number; // knots
course: number;
address: string | null;
accuracy: number;
network: any;
attributes: {
batteryLevel?: number;
distance?: number;
totalDistance?: number;
motion?: boolean;
};
}
export interface TraccarSession {
cookie: string;
}
@Injectable()
export class TraccarClientService implements OnModuleInit {
private readonly logger = new Logger(TraccarClientService.name);
private client: AxiosInstance;
private readonly baseUrl: string;
private sessionCookie: string | null = null;
private adminUser: string = '';
private adminPassword: string = '';
constructor(private configService: ConfigService) {
this.baseUrl = this.configService.get<string>('TRACCAR_API_URL') || 'http://localhost:8082';
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
}
async onModuleInit() {
// Don't authenticate on startup - wait for GpsService to load credentials from database
// The first request will trigger authentication with the correct credentials
this.logger.log('Traccar client initialized - waiting for credentials from database');
}
/**
* Set admin credentials (called from GpsService after loading from DB)
*/
setCredentials(username: string, password: string) {
this.adminUser = username;
this.adminPassword = password;
this.sessionCookie = null; // Force re-authentication
}
/**
* Authenticate with Traccar and get session cookie
*/
async authenticate(): Promise<boolean> {
if (!this.adminUser || !this.adminPassword) {
this.logger.warn('Traccar credentials not configured - skipping authentication');
return false;
}
try {
const response = await this.client.post(
'/api/session',
new URLSearchParams({
email: this.adminUser,
password: this.adminPassword,
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
// Extract session cookie
const setCookie = response.headers['set-cookie'];
if (setCookie && setCookie.length > 0) {
this.sessionCookie = setCookie[0].split(';')[0];
this.logger.log('Traccar authentication successful');
return true;
}
return false;
} catch (error: any) {
this.logger.error('Traccar authentication failed:', error.message);
throw error;
}
}
/**
* Make authenticated request to Traccar API
*/
private async request<T>(
method: 'get' | 'post' | 'put' | 'delete',
path: string,
data?: any,
): Promise<T> {
// Ensure we have a session
if (!this.sessionCookie) {
await this.authenticate();
}
try {
const response = await this.client.request({
method,
url: path,
data,
headers: {
Cookie: this.sessionCookie,
},
});
return response.data;
} catch (error: any) {
// If unauthorized, try to re-authenticate once
if (error.response?.status === 401) {
this.sessionCookie = null;
await this.authenticate();
const response = await this.client.request({
method,
url: path,
data,
headers: {
Cookie: this.sessionCookie,
},
});
return response.data;
}
throw error;
}
}
/**
* Check if Traccar is available
*/
async isAvailable(): Promise<boolean> {
try {
await this.client.get('/api/server');
return true;
} catch {
return false;
}
}
/**
* Get server info
*/
async getServerInfo(): Promise<any> {
return this.request('get', '/api/server');
}
/**
* Create a new device in Traccar
*/
async createDevice(name: string, uniqueId: string, phone?: string): Promise<TraccarDevice> {
// Sanitize uniqueId - trim whitespace, lowercase, remove any non-alphanumeric chars
const sanitizedUniqueId = uniqueId.trim().toLowerCase().replace(/[^a-z0-9]/g, '');
this.logger.log(`Creating Traccar device: name="${name}", uniqueId="${sanitizedUniqueId}"`);
const device = await this.request<TraccarDevice>('post', '/api/devices', {
name,
uniqueId: sanitizedUniqueId,
phone: phone || null,
category: 'person',
disabled: false, // Explicitly enable the device
});
this.logger.log(`Traccar device created: id=${device.id}, uniqueId="${device.uniqueId}", disabled=${device.disabled}`);
// Link device to all admin users so they can see it
await this.linkDeviceToAllAdmins(device.id);
return device;
}
/**
* Link a device to a specific user
*/
async linkDeviceToUser(deviceId: number, userId: number): Promise<boolean> {
try {
await this.request('post', '/api/permissions', {
userId,
deviceId,
});
return true;
} catch (error: any) {
// 400 means permission already exists, which is fine
if (error.response?.status === 400) {
return true;
}
this.logger.warn(`Failed to link device ${deviceId} to user ${userId}: ${error.message}`);
return false;
}
}
/**
* Link a device to all admin users
*/
async linkDeviceToAllAdmins(deviceId: number): Promise<void> {
try {
const users = await this.getAllUsers();
const admins = users.filter(u => u.administrator);
for (const admin of admins) {
await this.linkDeviceToUser(deviceId, admin.id);
}
this.logger.log(`Linked device ${deviceId} to ${admins.length} admin users`);
} catch (error: any) {
this.logger.warn(`Failed to link device to admins: ${error.message}`);
}
}
/**
* Get device by unique ID
*/
async getDeviceByUniqueId(uniqueId: string): Promise<TraccarDevice | null> {
try {
const devices = await this.request<TraccarDevice[]>('get', `/api/devices?uniqueId=${uniqueId}`);
return devices.length > 0 ? devices[0] : null;
} catch {
return null;
}
}
/**
* Get device by Traccar ID
*/
async getDevice(deviceId: number): Promise<TraccarDevice | null> {
try {
const devices = await this.request<TraccarDevice[]>('get', `/api/devices?id=${deviceId}`);
return devices.length > 0 ? devices[0] : null;
} catch {
return null;
}
}
/**
* Get all devices
*/
async getAllDevices(): Promise<TraccarDevice[]> {
return this.request('get', '/api/devices');
}
/**
* Delete a device
*/
async deleteDevice(deviceId: number): Promise<void> {
await this.request('delete', `/api/devices/${deviceId}`);
}
/**
* Get latest position for a device
*/
async getDevicePosition(deviceId: number): Promise<TraccarPosition | null> {
try {
const positions = await this.request<TraccarPosition[]>('get', `/api/positions?deviceId=${deviceId}`);
return positions.length > 0 ? positions[0] : null;
} catch {
return null;
}
}
/**
* Get all current positions
*/
async getAllPositions(): Promise<TraccarPosition[]> {
return this.request('get', '/api/positions');
}
/**
* Get position history for a device
*/
async getPositionHistory(
deviceId: number,
from: Date,
to: Date,
): Promise<TraccarPosition[]> {
const fromStr = from.toISOString();
const toStr = to.toISOString();
return this.request('get', `/api/positions?deviceId=${deviceId}&from=${fromStr}&to=${toStr}`);
}
/**
* Get trip report (includes distance traveled)
*/
async getTripReport(
deviceId: number,
from: Date,
to: Date,
): Promise<TraccarTrip[]> {
const fromStr = from.toISOString();
const toStr = to.toISOString();
return this.request('get', `/api/reports/trips?deviceId=${deviceId}&from=${fromStr}&to=${toStr}`);
}
/**
* Get summary report (includes total distance, max speed, etc.)
*/
async getSummaryReport(
deviceId: number,
from: Date,
to: Date,
): Promise<any[]> {
const fromStr = from.toISOString();
const toStr = to.toISOString();
return this.request('get', `/api/reports/summary?deviceId=${deviceId}&from=${fromStr}&to=${toStr}`);
}
/**
* Convert speed from knots to km/h
*/
knotsToKmh(knots: number): number {
return knots * 1.852;
}
/**
* Convert speed from knots to mph
*/
knotsToMph(knots: number): number {
return knots * 1.15078;
}
/**
* Get the device server URL for mobile app configuration
* Returns the Traccar URL (nginx handles SSL termination and proxies to Traccar)
*/
getDeviceServerUrl(): string {
// Return the base Traccar URL - nginx handles routing OsmAnd protocol
return this.getTraccarUrl();
}
// ============================================
// User Management (for VIP Admin sync)
// ============================================
/**
* Create or update a Traccar user
*/
async createOrUpdateUser(
email: string,
name: string,
password: string,
isAdmin: boolean = false,
token?: string,
): Promise<TraccarUser> {
// Check if user exists
const existingUser = await this.getUserByEmail(email);
if (existingUser) {
// Update existing user
return this.request('put', `/api/users/${existingUser.id}`, {
...existingUser,
name,
password: password || undefined, // Only update if provided
administrator: isAdmin,
token: token || existingUser.token, // Preserve or update token
});
}
// Create new user with token for auto-login
return this.request('post', '/api/users', {
email,
name,
password,
administrator: isAdmin,
token: token || undefined,
});
}
/**
* Get or generate a token for a user (for auto-login)
*/
async ensureUserToken(email: string, token: string): Promise<string | null> {
const user = await this.getUserByEmail(email);
if (!user) return null;
// If user already has a token, return it
if (user.token) {
return user.token;
}
// Set the token on the user
const updatedUser = await this.request<TraccarUser>('put', `/api/users/${user.id}`, {
...user,
token,
});
return updatedUser.token;
}
/**
* Get user's token if they have one
*/
async getUserToken(email: string): Promise<string | null> {
const user = await this.getUserByEmail(email);
return user?.token || null;
}
/**
* Get user by email
*/
async getUserByEmail(email: string): Promise<TraccarUser | null> {
try {
const users = await this.request<TraccarUser[]>('get', '/api/users');
return users.find(u => u.email.toLowerCase() === email.toLowerCase()) || null;
} catch {
return null;
}
}
/**
* Get all users
*/
async getAllUsers(): Promise<TraccarUser[]> {
return this.request('get', '/api/users');
}
/**
* Delete a user
*/
async deleteUser(userId: number): Promise<void> {
await this.request('delete', `/api/users/${userId}`);
}
/**
* Create a session for a user (for auto-login)
* Returns session token that can be used for authentication
*/
async createUserSession(email: string, password: string): Promise<{ token: string; cookie: string } | null> {
try {
const response = await this.client.post(
'/api/session',
new URLSearchParams({
email,
password,
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
const setCookie = response.headers['set-cookie'];
if (setCookie && setCookie.length > 0) {
const cookie = setCookie[0].split(';')[0];
// Extract token if available
const token = response.data?.token || null;
return { token, cookie };
}
return null;
} catch (error) {
this.logger.error('Failed to create user session:', error);
return null;
}
}
/**
* Get Traccar base URL for frontend redirect
* Returns the public URL that users can access via the subdomain
*/
getTraccarUrl(): string {
// Check for explicit Traccar public URL first
const traccarPublicUrl = this.configService.get<string>('TRACCAR_PUBLIC_URL');
if (traccarPublicUrl) {
return traccarPublicUrl;
}
// Default: derive from frontend URL using traccar subdomain
const frontendUrl = this.configService.get<string>('FRONTEND_URL') || 'http://localhost:5173';
try {
const url = new URL(frontendUrl);
// Replace the subdomain/hostname to use traccar subdomain
// e.g., vip.madeamess.online -> traccar.vip.madeamess.online
return `${url.protocol}//traccar.${url.hostname}`;
} catch {
return 'http://localhost:8082';
}
}
/**
* Check if initial setup is needed (no users exist)
*/
async needsInitialSetup(): Promise<boolean> {
try {
// Try to access without auth - if it works, setup is needed
const response = await this.client.get('/api/server', {
validateStatus: (status) => status < 500,
});
// Traccar 6.x uses newServer=true to indicate first-time setup needed
// Also check registration=true for older versions
return response.data?.newServer === true || response.data?.registration === true;
} catch {
return true;
}
}
/**
* Perform initial setup - create first admin user
*/
async performInitialSetup(email: string, password: string): Promise<boolean> {
try {
// Register first user (becomes admin automatically)
const response = await this.client.post('/api/users', {
email,
password,
name: 'VIP Coordinator Admin',
});
if (response.status === 200) {
// Authenticate with the new credentials
this.adminUser = email;
this.adminPassword = password;
await this.authenticate();
return true;
}
return false;
} catch (error: any) {
this.logger.error('Initial setup failed:', error.message);
return false;
}
}
}
export interface TraccarUser {
id: number;
name: string;
email: string;
administrator: boolean;
disabled: boolean;
readonly: boolean;
token: string | null;
attributes: Record<string, any>;
}
export interface TraccarTrip {
deviceId: number;
deviceName: string;
distance: number; // meters
averageSpeed: number; // knots
maxSpeed: number; // knots
spentFuel: number;
startOdometer: number;
endOdometer: number;
startTime: string;
endTime: string;
startPositionId: number;
endPositionId: number;
startLat: number;
startLon: number;
endLat: number;
endLon: number;
startAddress: string | null;
endAddress: string | null;
duration: number; // milliseconds
driverUniqueId: string | null;
driverName: string | null;
}

View File

@@ -1,5 +1,7 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { json, urlencoded } from 'express';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { AllExceptionsFilter, HttpExceptionFilter } from './common/filters';
@@ -8,8 +10,19 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Security headers
app.use(helmet());
// 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
app.setGlobalPrefix('api/v1');
// In production (App Platform), the ingress routes /api to this service
// So we only need /v1 prefix here
// In development, we need the full /api/v1 prefix
const isProduction = process.env.NODE_ENV === 'production';
app.setGlobalPrefix(isProduction ? 'v1' : 'api/v1');
// Enable CORS
app.enableCors({

View File

@@ -1,6 +1,9 @@
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
// Models that have soft delete (deletedAt field)
const SOFT_DELETE_MODELS = ['User', 'VIP', 'Driver', 'ScheduleEvent', 'Vehicle'];
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
@@ -9,18 +12,69 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
super({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
// Apply soft-delete middleware
this.applySoftDeleteMiddleware();
}
async onModuleInit() {
try {
await this.$connect();
this.logger.log('✅ Database connected successfully');
this.logger.log('✅ Soft-delete middleware active for: ' + SOFT_DELETE_MODELS.join(', '));
} catch (error) {
this.logger.error('❌ Database connection failed', error);
throw error;
}
}
/**
* Apply Prisma middleware to automatically filter out soft-deleted records
*
* This middleware automatically adds `deletedAt: null` to where clauses for models
* that have a deletedAt field, preventing soft-deleted records from being returned.
*
* Escape hatches:
* - Pass `{ deletedAt: { not: null } }` to query ONLY deleted records
* - Pass `{ deletedAt: undefined }` or any explicit deletedAt filter to bypass middleware
* - Hard delete operations (delete, deleteMany) are not affected
*/
private applySoftDeleteMiddleware() {
this.$use(async (params, next) => {
// Only apply to models with soft delete
if (!SOFT_DELETE_MODELS.includes(params.model || '')) {
return next(params);
}
// Operations to apply soft-delete filter to
const operations = ['findUnique', 'findFirst', 'findMany', 'count', 'aggregate'];
if (operations.includes(params.action)) {
// Initialize where clause if it doesn't exist
params.args.where = params.args.where || {};
// Only apply filter if deletedAt is not already specified
// This allows explicit queries for deleted records: { deletedAt: { not: null } }
// or to bypass middleware: { deletedAt: undefined }
if (!('deletedAt' in params.args.where)) {
params.args.where.deletedAt = null;
}
}
// For update/updateMany, ensure we don't accidentally update soft-deleted records
if (params.action === 'update' || params.action === 'updateMany') {
params.args.where = params.args.where || {};
// Only apply if not explicitly specified
if (!('deletedAt' in params.args.where)) {
params.args.where.deletedAt = null;
}
}
return next(params);
});
}
async onModuleDestroy() {
await this.$disconnect();
this.logger.log('Database disconnected');

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
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;
// Timezone (IANA format, e.g., "America/New_York")
@IsOptional()
@IsString()
@MaxLength(50)
timezone?: string;
// 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,96 @@
import {
Controller,
Get,
Patch,
Post,
Delete,
Body,
UseGuards,
UseInterceptors,
UploadedFile,
ParseFilePipe,
MaxFileSizeValidator,
FileTypeValidator,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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,
private readonly configService: ConfigService,
) {}
/**
* Feature flags - tells the frontend which optional services are configured.
* No ability decorator = any authenticated user can access.
*/
@Get('features')
getFeatureFlags() {
return {
copilot: !!this.configService.get('ANTHROPIC_API_KEY'),
flightTracking: !!this.configService.get('AVIATIONSTACK_API_KEY'),
signalMessaging: !!this.configService.get('SIGNAL_API_URL'),
gpsTracking: !!this.configService.get('TRACCAR_API_URL'),
};
}
/**
* Get app timezone - any authenticated user can read this
*/
@Get('timezone')
getTimezone() {
return this.settingsService.getTimezone();
}
/**
* Update app timezone - admin only
*/
@Patch('timezone')
@CanUpdate('Settings')
updateTimezone(@Body() dto: { timezone: string }) {
return this.settingsService.updateTimezone(dto.timezone);
}
@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,171 @@
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',
timezone: 'America/New_York',
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');
}
}
/**
* Get the app-wide timezone setting
*/
async getTimezone(): Promise<{ timezone: string }> {
const settings = await this.getPdfSettings();
return { timezone: settings.timezone };
}
/**
* Update the app-wide timezone setting
*/
async updateTimezone(timezone: string): Promise<{ timezone: string }> {
this.logger.log(`Updating timezone to: ${timezone}`);
// Validate the timezone string
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone });
} catch {
throw new BadRequestException(`Invalid timezone: ${timezone}`);
}
const existing = await this.getPdfSettings();
await this.prisma.pdfSettings.update({
where: { id: existing.id },
data: { timezone },
});
return { timezone };
}
/**
* 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,209 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
Req,
UseGuards,
Logger,
Res,
} from '@nestjs/common';
import { Request, 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';
import { toDateString } from '../common/utils/date.utils';
// 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, @Req() req: Request) {
// Validate webhook secret if configured
const secret = process.env.SIGNAL_WEBHOOK_SECRET;
if (secret && req.headers['x-webhook-secret'] !== secret) {
this.logger.warn('Webhook rejected: invalid or missing secret');
return { success: false, error: 'Unauthorized' };
}
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-${toDateString(new Date())}.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,430 @@
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 },
});
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 },
});
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: {
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,
},
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,350 @@
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;
captchaRequired?: boolean;
captchaUrl?: 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) {
const errorMessage = error.response?.data?.error || error.message;
this.logger.error('Failed to register number:', errorMessage);
// Check if CAPTCHA is required
const isCaptchaRequired =
errorMessage.toLowerCase().includes('captcha') ||
error.response?.status === 402; // Signal uses 402 for captcha requirement
if (isCaptchaRequired) {
return {
success: false,
captchaRequired: true,
captchaUrl: 'https://signalcaptchas.org/registration/generate.html',
message:
'CAPTCHA verification required. Please solve the CAPTCHA and submit the token.',
};
}
return {
success: false,
message: errorMessage,
};
}
}
/**
* 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 {
// Use POST /v1/unregister/{number} - the correct Signal API endpoint
await this.client.post(`/v1/unregister/${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,
};
}
}
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsEnum, IsOptional } from 'class-validator';
import { IsString, IsEnum, IsOptional, IsBoolean } from 'class-validator';
import { Role } from '@prisma/client';
export class UpdateUserDto {
@@ -9,4 +9,8 @@ export class UpdateUserDto {
@IsEnum(Role)
@IsOptional()
role?: Role;
@IsBoolean()
@IsOptional()
isAlsoDriver?: boolean;
}

View File

@@ -11,7 +11,6 @@ export class UsersService {
async findAll() {
return this.prisma.user.findMany({
where: { deletedAt: null },
include: { driver: true },
orderBy: { createdAt: 'desc' },
});
@@ -19,7 +18,7 @@ export class UsersService {
async findOne(id: string) {
const user = await this.prisma.user.findFirst({
where: { id, deletedAt: null },
where: { id },
include: { driver: true },
});
@@ -35,36 +34,75 @@ export class UsersService {
this.logger.log(`Updating user ${id}: ${JSON.stringify(updateUserDto)}`);
// Handle role change and Driver record synchronization
if (updateUserDto.role && updateUserDto.role !== user.role) {
// If changing TO DRIVER role, create a Driver record if one doesn't exist
if (updateUserDto.role === Role.DRIVER && !user.driver) {
const { isAlsoDriver, ...prismaData } = updateUserDto;
const effectiveRole = updateUserDto.role || user.role;
const hasActiveDriver = user.driver && !user.driver.deletedAt;
const hasSoftDeletedDriver = user.driver && user.driver.deletedAt;
// Handle role change to DRIVER: auto-create or restore driver record
if (updateUserDto.role === Role.DRIVER && !hasActiveDriver) {
if (hasSoftDeletedDriver) {
this.logger.log(
`Restoring soft-deleted Driver record for user ${user.email} (role change to DRIVER)`,
);
await this.prisma.driver.update({
where: { id: user.driver!.id },
data: { deletedAt: null, name: user.name || user.email },
});
} else {
this.logger.log(
`Creating Driver record for user ${user.email} (role change to DRIVER)`,
);
await this.prisma.driver.create({
data: {
name: user.name || user.email,
phone: user.email, // Use email as placeholder for phone
phone: user.email,
userId: user.id,
},
});
}
}
// If changing FROM DRIVER role to something else, remove the Driver record
if (user.role === Role.DRIVER && updateUserDto.role !== Role.DRIVER && user.driver) {
// When promoting FROM DRIVER to Admin/Coordinator, keep the driver record
// (admin can explicitly uncheck the driver box later if they want)
// Handle "Also a Driver" toggle (independent of role)
if (isAlsoDriver === true && !hasActiveDriver) {
if (hasSoftDeletedDriver) {
this.logger.log(
`Removing Driver record for user ${user.email} (role change from DRIVER to ${updateUserDto.role})`,
`Restoring soft-deleted Driver record for user ${user.email} (isAlsoDriver toggled on)`,
);
await this.prisma.driver.delete({
where: { id: user.driver.id },
await this.prisma.driver.update({
where: { id: user.driver!.id },
data: { deletedAt: null, name: user.name || user.email },
});
} else {
this.logger.log(
`Creating Driver record for user ${user.email} (isAlsoDriver toggled on)`,
);
await this.prisma.driver.create({
data: {
name: user.name || user.email,
phone: user.email,
userId: user.id,
},
});
}
} else if (isAlsoDriver === false && hasActiveDriver && effectiveRole !== Role.DRIVER) {
// Only allow removing driver record if user is NOT in the DRIVER role
this.logger.log(
`Soft-deleting Driver record for user ${user.email} (isAlsoDriver toggled off)`,
);
await this.prisma.driver.update({
where: { id: user.driver!.id },
data: { deletedAt: new Date() },
});
}
return this.prisma.user.update({
where: { id: user.id },
data: updateUserDto,
data: prismaData,
include: { driver: true },
});
}
@@ -97,7 +135,6 @@ export class UsersService {
async getPendingUsers() {
return this.prisma.user.findMany({
where: {
deletedAt: null,
isApproved: false,
},
orderBy: { createdAt: 'asc' },

View File

@@ -13,8 +13,10 @@ import { VehiclesService } from './vehicles.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Role } from '@prisma/client';
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('vehicles')
@UseGuards(JwtAuthGuard, RolesGuard)
@@ -56,8 +58,11 @@ export class VehiclesController {
@Delete(':id')
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
remove(@Param('id') id: string, @Query('hard') hard?: string) {
const isHardDelete = hard === 'true';
return this.vehiclesService.remove(id, isHardDelete);
remove(
@Param('id') id: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
@CurrentUser() user?: any,
) {
return this.vehiclesService.remove(id, hard, user?.role);
}
}

View File

@@ -1,11 +1,20 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateVehicleDto, UpdateVehicleDto } from './dto';
import { executeHardDelete } from '../common/utils';
@Injectable()
export class VehiclesService {
private readonly logger = new Logger(VehiclesService.name);
private readonly vehicleInclude = {
currentDriver: true,
events: {
include: { driver: true, vehicle: true },
orderBy: { startTime: 'asc' as const },
},
} as const;
constructor(private prisma: PrismaService) {}
async create(createVehicleDto: CreateVehicleDto) {
@@ -13,27 +22,13 @@ export class VehiclesService {
return this.prisma.vehicle.create({
data: createVehicleDto,
include: {
currentDriver: true,
events: {
where: { deletedAt: null },
include: { driver: true, vehicle: true },
},
},
include: this.vehicleInclude,
});
}
async findAll() {
return this.prisma.vehicle.findMany({
where: { deletedAt: null },
include: {
currentDriver: true,
events: {
where: { deletedAt: null },
include: { driver: true, vehicle: true },
orderBy: { startTime: 'asc' },
},
},
include: this.vehicleInclude,
orderBy: { name: 'asc' },
});
}
@@ -41,7 +36,6 @@ export class VehiclesService {
async findAvailable() {
return this.prisma.vehicle.findMany({
where: {
deletedAt: null,
status: 'AVAILABLE',
},
include: {
@@ -53,15 +47,8 @@ export class VehiclesService {
async findOne(id: string) {
const vehicle = await this.prisma.vehicle.findFirst({
where: { id, deletedAt: null },
include: {
currentDriver: true,
events: {
where: { deletedAt: null },
include: { driver: true, vehicle: true },
orderBy: { startTime: 'asc' },
},
},
where: { id },
include: this.vehicleInclude,
});
if (!vehicle) {
@@ -79,30 +66,24 @@ export class VehiclesService {
return this.prisma.vehicle.update({
where: { id: vehicle.id },
data: updateVehicleDto,
include: {
currentDriver: true,
events: {
where: { deletedAt: null },
include: { driver: true, vehicle: true },
},
},
include: this.vehicleInclude,
});
}
async remove(id: string, hardDelete = false) {
const vehicle = await this.findOne(id);
if (hardDelete) {
this.logger.log(`Hard deleting vehicle: ${vehicle.name}`);
return this.prisma.vehicle.delete({
where: { id: vehicle.id },
});
}
this.logger.log(`Soft deleting vehicle: ${vehicle.name}`);
return this.prisma.vehicle.update({
where: { id: vehicle.id },
data: { deletedAt: new Date() },
async remove(id: string, hardDelete = false, userRole?: string) {
return executeHardDelete({
id,
hardDelete,
userRole,
findOne: (id) => this.findOne(id),
performHardDelete: (id) => this.prisma.vehicle.delete({ where: { id } }),
performSoftDelete: (id) =>
this.prisma.vehicle.update({
where: { id },
data: { deletedAt: new Date() },
}),
entityName: 'Vehicle',
logger: this.logger,
});
}
@@ -110,24 +91,33 @@ export class VehiclesService {
* Get vehicle utilization statistics
*/
async getUtilization() {
const vehicles = await this.findAll();
const now = new Date();
const stats = vehicles.map((vehicle) => {
const upcomingEvents = vehicle.events.filter(
(event) => new Date(event.startTime) > new Date(),
);
return {
id: vehicle.id,
name: vehicle.name,
type: vehicle.type,
seatCapacity: vehicle.seatCapacity,
status: vehicle.status,
upcomingTrips: upcomingEvents.length,
currentDriver: vehicle.currentDriver?.name,
};
// Fetch vehicles with only upcoming events (filtered at database level)
const vehicles = await this.prisma.vehicle.findMany({
include: {
currentDriver: true,
events: {
where: {
startTime: { gt: now }, // Only fetch upcoming events
},
include: { driver: true, vehicle: true },
orderBy: { startTime: 'asc' },
},
},
orderBy: { name: 'asc' },
});
const stats = vehicles.map((vehicle) => ({
id: vehicle.id,
name: vehicle.name,
type: vehicle.type,
seatCapacity: vehicle.seatCapacity,
status: vehicle.status,
upcomingTrips: vehicle.events.length, // Already filtered at DB level
currentDriver: vehicle.currentDriver?.name,
}));
return {
totalVehicles: vehicles.length,
available: vehicles.filter((v) => v.status === 'AVAILABLE').length,

View File

@@ -4,6 +4,9 @@ import {
IsOptional,
IsBoolean,
IsDateString,
IsInt,
IsEmail,
Min,
} from 'class-validator';
import { Department, ArrivalMode } from '@prisma/client';
@@ -33,7 +36,35 @@ export class CreateVipDto {
@IsOptional()
venueTransport?: boolean;
@IsInt()
@IsOptional()
@Min(1)
partySize?: number;
@IsString()
@IsOptional()
notes?: string;
// Roster-only flag: true = just tracking for accountability, not active coordination
@IsBoolean()
@IsOptional()
isRosterOnly?: boolean;
// VIP contact info
@IsString()
@IsOptional()
phone?: string;
@IsEmail()
@IsOptional()
email?: string;
// Emergency contact info (for accountability reports)
@IsString()
@IsOptional()
emergencyContactName?: string;
@IsString()
@IsOptional()
emergencyContactPhone?: string;
}

View File

@@ -13,7 +13,9 @@ import { VipsService } from './vips.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AbilitiesGuard } from '../auth/guards/abilities.guard';
import { CanCreate, CanRead, CanUpdate, CanDelete } from '../auth/decorators/check-ability.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CreateVipDto, UpdateVipDto } from './dto';
import { ParseBooleanPipe } from '../common/pipes';
@Controller('vips')
@UseGuards(JwtAuthGuard, AbilitiesGuard)
@@ -48,10 +50,9 @@ export class VipsController {
@CanDelete('VIP')
remove(
@Param('id') id: string,
@Query('hard') hard?: string,
@Query('hard', ParseBooleanPipe) hard: boolean,
@CurrentUser() user?: any,
) {
// Only administrators can hard delete
const isHardDelete = hard === 'true';
return this.vipsService.remove(id, isHardDelete);
return this.vipsService.remove(id, hard, user?.role);
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateVipDto, UpdateVipDto } from './dto';
import { executeHardDelete } from '../common/utils';
@Injectable()
export class VipsService {
@@ -21,7 +22,6 @@ export class VipsService {
async findAll() {
return this.prisma.vIP.findMany({
where: { deletedAt: null },
include: {
flights: true,
},
@@ -31,7 +31,7 @@ export class VipsService {
async findOne(id: string) {
const vip = await this.prisma.vIP.findFirst({
where: { id, deletedAt: null },
where: { id },
include: {
flights: true,
},
@@ -58,20 +58,20 @@ export class VipsService {
});
}
async remove(id: string, hardDelete = false) {
const vip = await this.findOne(id);
if (hardDelete) {
this.logger.log(`Hard deleting VIP: ${vip.name}`);
return this.prisma.vIP.delete({
where: { id: vip.id },
});
}
this.logger.log(`Soft deleting VIP: ${vip.name}`);
return this.prisma.vIP.update({
where: { id: vip.id },
data: { deletedAt: new Date() },
async remove(id: string, hardDelete = false, userRole?: string) {
return executeHardDelete({
id,
hardDelete,
userRole,
findOne: (id) => this.findOne(id),
performHardDelete: (id) => this.prisma.vIP.delete({ where: { id } }),
performSoftDelete: (id) =>
this.prisma.vIP.update({
where: { id },
data: { deletedAt: new Date() },
}),
entityName: 'VIP',
logger: this.logger,
});
}
}

253
deploy/setup-droplet.sh Normal file
View File

@@ -0,0 +1,253 @@
#!/bin/bash
# VIP Coordinator Droplet Setup Script
# Run this on a fresh Ubuntu 24.04 droplet
set -e
echo "=== VIP Coordinator Droplet Setup ==="
echo ""
# Update system
echo ">>> Updating system packages..."
apt-get update && apt-get upgrade -y
# Install Docker
echo ">>> Installing Docker..."
apt-get install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Enable Docker to start on boot
systemctl enable docker
systemctl start docker
echo ">>> Docker installed: $(docker --version)"
# Install Nginx and Certbot for SSL
echo ">>> Installing Nginx and Certbot..."
apt-get install -y nginx certbot python3-certbot-nginx
# Create app directory
echo ">>> Setting up application directory..."
mkdir -p /opt/vip-coordinator
cd /opt/vip-coordinator
# Create docker-compose.yml
echo ">>> Creating docker-compose.yml..."
cat > docker-compose.yml << 'COMPOSE'
services:
# PostgreSQL Database
postgres:
image: postgres:15-alpine
container_name: vip-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
POSTGRES_DB: vip_coordinator
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- vip-network
# Redis (for caching/sessions)
redis:
image: redis:7-alpine
container_name: vip-redis
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
networks:
- vip-network
# Signal CLI REST API for messaging
signal-api:
image: bbernhard/signal-cli-rest-api:latest
container_name: vip-signal
environment:
- MODE=native
volumes:
- signal_data:/home/.local/share/signal-cli
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/v1/about"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
networks:
- vip-network
# Backend API
backend:
image: t72chevy/vip-coordinator-backend:latest
container_name: vip-backend
environment:
NODE_ENV: production
PORT: 3000
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-changeme}@postgres:5432/vip_coordinator
REDIS_URL: redis://redis:6379
SIGNAL_API_URL: http://signal-api:8080
AUTH0_DOMAIN: ${AUTH0_DOMAIN}
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE}
AUTH0_ISSUER: ${AUTH0_ISSUER}
FRONTEND_URL: https://${DOMAIN_NAME}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
ports:
- "127.0.0.1:3000:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped
networks:
- vip-network
# Frontend
frontend:
image: t72chevy/vip-coordinator-frontend:latest
container_name: vip-frontend
ports:
- "127.0.0.1:5173:80"
depends_on:
- backend
restart: unless-stopped
networks:
- vip-network
volumes:
postgres_data:
name: vip_postgres_data
redis_data:
name: vip_redis_data
signal_data:
name: vip_signal_data
networks:
vip-network:
driver: bridge
COMPOSE
# Create .env file template
echo ">>> Creating .env file..."
cat > .env << 'ENVFILE'
# Database
POSTGRES_PASSWORD=CHANGE_THIS_TO_SECURE_PASSWORD
# Domain
DOMAIN_NAME=vip.madeamess.online
# Auth0
AUTH0_DOMAIN=dev-s855cy3bvjjbkljt.us.auth0.com
AUTH0_AUDIENCE=https://vip-coordinator-api
AUTH0_ISSUER=https://dev-s855cy3bvjjbkljt.us.auth0.com/
# Anthropic API (for AI Copilot)
ANTHROPIC_API_KEY=PASTE_YOUR_API_KEY_HERE
ENVFILE
echo ">>> IMPORTANT: Edit /opt/vip-coordinator/.env with your actual values!"
echo ""
# Configure Nginx as reverse proxy
echo ">>> Configuring Nginx..."
cat > /etc/nginx/sites-available/vip-coordinator << 'NGINX'
server {
listen 80;
server_name vip.madeamess.online;
# Redirect HTTP to HTTPS (will be enabled after certbot)
# location / {
# return 301 https://$host$request_uri;
# }
# API proxy - forward /api requests to backend
location /api/ {
proxy_pass http://127.0.0.1:3000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Frontend
location / {
proxy_pass http://127.0.0.1:5173;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
NGINX
# Enable the site
ln -sf /etc/nginx/sites-available/vip-coordinator /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
# Test and reload nginx
nginx -t && systemctl reload nginx
# Configure firewall
echo ">>> Configuring UFW firewall..."
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw --force enable
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Next steps:"
echo "1. Edit /opt/vip-coordinator/.env with your actual values:"
echo " - Set POSTGRES_PASSWORD to a secure password"
echo " - Set ANTHROPIC_API_KEY to your API key"
echo ""
echo "2. Start the stack:"
echo " cd /opt/vip-coordinator"
echo " docker compose pull"
echo " docker compose up -d"
echo ""
echo "3. Wait for backend to start, then run database migration:"
echo " docker exec vip-backend npx prisma migrate deploy"
echo ""
echo "4. Get SSL certificate:"
echo " certbot --nginx -d vip.madeamess.online"
echo ""
echo "5. Update Auth0 callback URLs to:"
echo " https://vip.madeamess.online/callback"
echo ""
echo "Droplet IP: $(curl -s ifconfig.me)"
echo ""

295
deployment/TRACCAR-SETUP.md Normal file
View File

@@ -0,0 +1,295 @@
# Traccar GPS Tracking Setup Guide
This guide explains how to set up Traccar GPS tracking with Auth0 OpenID Connect authentication for the VIP Coordinator application.
## Overview
Traccar integrates with Auth0 for Single Sign-On (SSO), using the same authentication as VIP Coordinator. Users are granted access based on their Auth0 roles:
- **ADMINISTRATOR** - Full admin access to Traccar
- **COORDINATOR** - Standard user access to Traccar
- Users without these roles cannot access Traccar
## How Access Control Works
```
┌─────────────────────────────────────────────────────────────────┐
│ Auth0 Tenant │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Roles │ │ Action │ │ Users │ │
│ │ ADMINISTRATOR│ │ Adds roles │ │ john@company.com │ │
│ │ COORDINATOR │ │ to tokens │ │ └─ ADMINISTRATOR │ │
│ └──────────────┘ └──────────────┘ │ jane@company.com │ │
│ │ └─ COORDINATOR │ │
│ │ guest@example.com │ │
│ │ └─ (no role) │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Traccar │
│ Checks token for roles: │
│ - john@company.com → ADMINISTRATOR → Admin access ✓ │
│ - jane@company.com → COORDINATOR → Standard access ✓ │
│ - guest@example.com → No role → Access denied ✗ │
└─────────────────────────────────────────────────────────────────┘
```
## Prerequisites
1. Auth0 tenant with Management API access
2. Digital Ocean droplet or server with Docker
3. Domain with SSL certificate (e.g., `traccar.yourdomain.com`)
4. VIP Coordinator already deployed (sharing the same Auth0 tenant)
## Step 1: Configure Auth0
### Automatic Setup (Recommended)
Run the setup script with your configuration:
```bash
# Get a Management API token from Auth0 Dashboard:
# Applications → APIs → Auth0 Management API → API Explorer → Copy Token
cd vip-coordinator
node scripts/setup-auth0-traccar.js \
--token=<AUTH0_MANAGEMENT_TOKEN> \
--domain=<your-tenant.us.auth0.com> \
--traccar-url=<https://traccar.yourdomain.com> \
--admins=<admin@example.com,other-admin@example.com>
```
**Example for a new deployment:**
```bash
node scripts/setup-auth0-traccar.js \
--token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... \
--domain=acme-corp.us.auth0.com \
--traccar-url=https://traccar.acme.com \
--admins=john@acme.com,jane@acme.com
```
This script will:
1. Create ADMINISTRATOR and COORDINATOR roles in your Auth0 tenant
2. Create a Post Login Action that adds roles to tokens as a "groups" claim
3. Deploy the action to the Login flow
4. Assign ADMINISTRATOR role to the specified admin emails (if they exist in Auth0)
### Manual Setup
If you prefer manual setup:
1. **Create Roles** in Auth0 Dashboard → User Management → Roles:
- Name: `ADMINISTRATOR`, Description: "Full admin access"
- Name: `COORDINATOR`, Description: "Standard access"
2. **Create Action** in Auth0 Dashboard → Actions → Library → Build Custom:
- Name: `Add Roles to Traccar Groups`
- Trigger: `Login / Post Login`
- Code:
```javascript
exports.onExecutePostLogin = async (event, api) => {
const namespace = 'https://traccar.vip.madeamess.online';
if (event.authorization && event.authorization.roles) {
api.idToken.setCustomClaim(namespace + '/groups', event.authorization.roles);
api.accessToken.setCustomClaim(namespace + '/groups', event.authorization.roles);
}
};
```
3. **Deploy Action** to Login Flow in Auth0 Dashboard → Actions → Flows → Login
4. **Assign Roles** to admin users in Auth0 Dashboard → User Management → Users
## Step 2: Configure Auth0 Application URLs
In Auth0 Dashboard → Applications → BSA VIP Track (your app), add:
**Allowed Callback URLs:**
```
https://traccar.vip.madeamess.online/api/session/openid/callback
```
**Allowed Logout URLs:**
```
https://traccar.vip.madeamess.online
```
**Allowed Web Origins:**
```
https://traccar.vip.madeamess.online
```
## Step 3: Deploy Traccar
### Docker Compose Configuration
Add to your `docker-compose.yml`:
```yaml
traccar:
image: traccar/traccar:6.4
container_name: vip-traccar
ports:
- "127.0.0.1:8082:8082" # Web UI (proxied through nginx)
- "5055:5055" # GPS device protocol (OsmAnd)
volumes:
- ./traccar.xml:/opt/traccar/conf/traccar.xml:ro
- traccar_data:/opt/traccar/data
restart: unless-stopped
volumes:
traccar_data:
```
### Traccar Configuration
Create `traccar.xml` on the server:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<!-- Database -->
<entry key="database.driver">org.h2.Driver</entry>
<entry key="database.url">jdbc:h2:./data/database</entry>
<entry key="database.user">sa</entry>
<entry key="database.password"></entry>
<!-- Auth0 OpenID Connect -->
<entry key="openid.clientId">YOUR_AUTH0_CLIENT_ID</entry>
<entry key="openid.clientSecret">YOUR_AUTH0_CLIENT_SECRET</entry>
<entry key="openid.issuerUrl">https://YOUR_AUTH0_DOMAIN</entry>
<entry key="openid.force">true</entry>
<entry key="web.url">https://traccar.your-domain.com</entry>
<!-- Role-based Access Control -->
<entry key="openid.group">https://traccar.your-domain.com/groups</entry>
<entry key="openid.adminGroup">ADMINISTRATOR</entry>
<entry key="openid.allowGroup">ADMINISTRATOR,COORDINATOR</entry>
<!-- Logging -->
<entry key="logger.level">info</entry>
</properties>
```
### Nginx Configuration
Add to your nginx config:
```nginx
server {
listen 443 ssl http2;
server_name traccar.vip.madeamess.online;
ssl_certificate /etc/letsencrypt/live/vip.madeamess.online/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/vip.madeamess.online/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8082;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## Step 4: Bootstrap First User
Traccar 6.x requires at least one user before OpenID authentication works. Create a bootstrap user via API:
```bash
curl -X POST "https://traccar.your-domain.com/api/users" \
-H "Content-Type: application/json" \
-d '{"name":"Bootstrap Admin","email":"bootstrap@your-domain.com","password":"TEMP_PASSWORD"}'
```
This user will become admin. After OpenID is working, you can delete this user from Traccar settings.
## Step 5: Start Traccar
```bash
cd /opt/vip-coordinator
docker-compose up -d traccar
docker-compose logs -f traccar # Watch logs
```
## Step 6: Test Authentication
1. Open `https://traccar.your-domain.com` in an incognito browser
2. Should redirect to Auth0 login
3. Log in with an admin user email
4. Should land in Traccar dashboard as admin
## Managing Users After Deployment
Once Traccar is deployed, manage user access through Auth0:
### Adding a New Admin
1. Go to Auth0 Dashboard → User Management → Users
2. Find the user (or wait for them to log in once to create their account)
3. Click on the user → Roles tab
4. Click "Assign Roles" → Select "ADMINISTRATOR"
### Adding a Coordinator
1. Go to Auth0 Dashboard → User Management → Users
2. Find the user
3. Click on the user → Roles tab
4. Click "Assign Roles" → Select "COORDINATOR"
### Removing Access
1. Go to Auth0 Dashboard → User Management → Users
2. Find the user → Roles tab
3. Remove both ADMINISTRATOR and COORDINATOR roles
4. User will be denied access on next login
### Bulk User Management
You can also use the Auth0 Management API:
```bash
# Assign role to user
curl -X POST "https://YOUR_DOMAIN/api/v2/users/USER_ID/roles" \
-H "Authorization: Bearer MGMT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"roles": ["ROLE_ID"]}'
```
## Troubleshooting
### "Registration form appears instead of Auth0"
- Check that `newServer: false` in `/api/server` response
- If `newServer: true`, bootstrap a user first (Step 4)
### "User logged in but not admin"
- Verify user has ADMINISTRATOR role in Auth0
- Check that the Action is deployed to Login flow
- Test with a fresh incognito window
### "Access denied"
- User doesn't have ADMINISTRATOR or COORDINATOR Auth0 role
- Assign role in Auth0 Dashboard → User Management → Users
### "OpenID not working at all"
- Check Auth0 callback URL is correct
- Verify `openid.issuerUrl` has NO trailing slash
- Check Traccar logs: `docker-compose logs traccar`
## Security Notes
1. The `openid.clientSecret` should be kept secure
2. Only users with specific Auth0 roles can access Traccar
3. The bootstrap user can be deleted once OpenID is working
4. Consider using PostgreSQL instead of H2 for production
## Files Reference
- `scripts/setup-auth0-traccar.js` - Auth0 setup automation
- `deployment/traccar-production.xml` - Production Traccar config
- `deployment/TRACCAR-SETUP.md` - This guide

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<!-- Database - H2 embedded database -->
<entry key="database.driver">org.h2.Driver</entry>
<entry key="database.url">jdbc:h2:./data/database</entry>
<entry key="database.user">sa</entry>
<entry key="database.password"></entry>
<!-- Auth0 OpenID Connect Authentication -->
<entry key="openid.clientId">JXEVOIfS5eYCkeKbbCWIkBYIvjqdSP5d</entry>
<entry key="openid.clientSecret">uV25EDh7YwZsvuLYp_bkaSUbpXVJ4uz8dkYZxd9FvvhcCNhGfwjSeen1TMG9c55V</entry>
<entry key="openid.issuerUrl">https://dev-s855cy3bvjjbkljt.us.auth0.com</entry>
<entry key="openid.force">true</entry>
<entry key="web.url">https://traccar.vip.madeamess.online</entry>
<!-- Auth0 Role-based Access Control -->
<!-- Users must have ADMINISTRATOR or COORDINATOR Auth0 role to access Traccar -->
<!-- Only ADMINISTRATOR role users get admin rights in Traccar -->
<entry key="openid.group">https://traccar.vip.madeamess.online/groups</entry>
<entry key="openid.adminGroup">ADMINISTRATOR</entry>
<entry key="openid.allowGroup">ADMINISTRATOR,COORDINATOR</entry>
<!-- Logging - set to 'all' for debugging, 'info' for production -->
<entry key="logger.level">info</entry>
</properties>

26
deployment/traccar.xml Normal file
View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<!-- Database - H2 embedded database -->
<entry key="database.driver">org.h2.Driver</entry>
<entry key="database.url">jdbc:h2:./data/database</entry>
<entry key="database.user">sa</entry>
<entry key="database.password"></entry>
<!-- Auth0 OpenID Connect Authentication -->
<!-- These values should be set via environment variables or secrets in production -->
<entry key="openid.clientId">${TRACCAR_OPENID_CLIENT_ID}</entry>
<entry key="openid.clientSecret">${TRACCAR_OPENID_CLIENT_SECRET}</entry>
<entry key="openid.issuerUrl">${AUTH0_DOMAIN}</entry>
<entry key="openid.force">true</entry>
<entry key="web.url">${TRACCAR_PUBLIC_URL}</entry>
<!-- Auth0 Role-based Access Control -->
<!-- Namespace must match the Auth0 Action that adds groups to tokens -->
<entry key="openid.group">${TRACCAR_PUBLIC_URL}/groups</entry>
<entry key="openid.adminGroup">ADMINISTRATOR</entry>
<entry key="openid.allowGroup">ADMINISTRATOR,COORDINATOR</entry>
<!-- Logging - set to 'info' in production -->
<entry key="logger.level">info</entry>
</properties>

View File

@@ -0,0 +1,86 @@
# Digital Ocean App Platform Specification
# Deploy VIP Coordinator with managed containers
name: vip-coordinator
region: nyc
# Database (Managed PostgreSQL)
databases:
- name: vip-coordinator-db
engine: PG
version: "16"
production: false # Set to true for production (more expensive)
cluster_name: vip-coordinator-postgres
# Services
services:
# Backend API
- name: backend
github:
repo: kyle/vip-coordinator # Will be overridden to use Docker registry
branch: main
deploy_on_push: true
dockerfile_path: backend/Dockerfile
# Override with Docker registry
image:
registry_type: DOCR # Will need to use DigitalOcean registry or Docker Hub
registry: gitea.madeamess.online
repository: kyle/vip-coordinator/backend
tag: latest
instance_count: 1
instance_size_slug: basic-xxs # $5/month
http_port: 3000
health_check:
http_path: /api/v1/health
envs:
- key: NODE_ENV
value: production
- key: DATABASE_URL
scope: RUN_TIME
type: SECRET
- key: REDIS_URL
value: redis://redis:6379
- key: AUTH0_DOMAIN
value: dev-s855cy3bvjjbkljt.us.auth0.com
- key: AUTH0_AUDIENCE
value: https://vip-coordinator-api
- key: AUTH0_ISSUER
value: https://dev-s855cy3bvjjbkljt.us.auth0.com/
routes:
- path: /api
# Frontend
- name: frontend
github:
repo: kyle/vip-coordinator
branch: main
dockerfile_path: frontend/Dockerfile
image:
registry_type: DOCR
registry: gitea.madeamess.online
repository: kyle/vip-coordinator/frontend
tag: latest
instance_count: 1
instance_size_slug: basic-xxs # $5/month
http_port: 80
build_command: echo "Using pre-built image"
envs:
- key: VITE_API_URL
value: https://vip-coordinator-${APP_URL}/api/v1
- key: VITE_AUTH0_DOMAIN
value: dev-s855cy3bvjjbkljt.us.auth0.com
- key: VITE_AUTH0_CLIENT_ID
value: JXEVOIfS5eYCkeKbbCWIkBYIvjqdSP5d
- key: VITE_AUTH0_AUDIENCE
value: https://vip-coordinator-api
routes:
- path: /
# Workers (Redis)
workers:
- name: redis
github:
repo: kyle/vip-coordinator
branch: main
dockerfile_path: Dockerfile.redis
instance_count: 1
instance_size_slug: basic-xxs

View File

@@ -0,0 +1,137 @@
version: '3.8'
# ==========================================
# VIP Coordinator - Digital Ocean Deployment
# ==========================================
# This compose file pulls pre-built images from Gitea registry
# No local builds required - perfect for cloud deployment
services:
# PostgreSQL Database
postgres:
image: postgres:16-alpine
container_name: vip-coordinator-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB:-vip_coordinator}
POSTGRES_USER: ${POSTGRES_USER:-vip_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
volumes:
- vip-coordinator-postgres-data:/var/lib/postgresql/data
networks:
- vip-coordinator-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-vip_user}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# Redis Cache
redis:
image: redis:7-alpine
container_name: vip-coordinator-redis
volumes:
- vip-coordinator-redis-data:/data
networks:
- vip-coordinator-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
start_period: 5s
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
command: redis-server --appendonly yes
# NestJS Backend API (from Gitea registry)
backend:
image: ${GITEA_REGISTRY:-192.168.68.53:3000}/kyle/vip-coordinator/backend:${IMAGE_TAG:-latest}
container_name: vip-coordinator-backend
environment:
# Database Configuration
DATABASE_URL: postgresql://${POSTGRES_USER:-vip_user}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-vip_coordinator}
# Redis Configuration
REDIS_URL: redis://redis:6379
# Auth0 Configuration
AUTH0_DOMAIN: ${AUTH0_DOMAIN:?AUTH0_DOMAIN must be set}
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:?AUTH0_AUDIENCE must be set}
AUTH0_ISSUER: ${AUTH0_ISSUER:?AUTH0_ISSUER must be set}
# Application Configuration
NODE_ENV: production
PORT: 3000
# Optional: AviationStack API (for flight tracking)
AVIATIONSTACK_API_KEY: ${AVIATIONSTACK_API_KEY:-}
# Optional: Database seeding
RUN_SEED: ${RUN_SEED:-false}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- vip-coordinator-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# React Frontend (Nginx) (from Gitea registry)
frontend:
image: ${GITEA_REGISTRY:-192.168.68.53:3000}/kyle/vip-coordinator/frontend:${IMAGE_TAG:-latest}
container_name: vip-coordinator-frontend
ports:
- "${FRONTEND_PORT:-80}:80"
depends_on:
backend:
condition: service_healthy
networks:
- vip-coordinator-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# Named volumes for data persistence
volumes:
vip-coordinator-postgres-data:
name: vip-coordinator-postgres-data
vip-coordinator-redis-data:
name: vip-coordinator-redis-data
# Dedicated network for service communication
networks:
vip-coordinator-network:
name: vip-coordinator-network
driver: bridge

View File

@@ -1,5 +1,3 @@
version: '3.8'
services:
# PostgreSQL Database
postgres:
@@ -10,7 +8,7 @@ services:
POSTGRES_PASSWORD: changeme
POSTGRES_DB: vip_coordinator
ports:
- "5433:5432" # Using 5433 on host to avoid conflict
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -20,12 +18,12 @@ services:
retries: 5
restart: unless-stopped
# Redis (Optional - for caching/sessions)
# Redis (for caching/sessions)
redis:
image: redis:7-alpine
container_name: vip-redis
ports:
- "6380:6379" # Using 6380 on host to avoid conflicts
- "6380:6379"
volumes:
- redis_data:/data
healthcheck:
@@ -35,8 +33,94 @@ services:
retries: 5
restart: unless-stopped
# Signal CLI REST API for messaging
signal-api:
image: bbernhard/signal-cli-rest-api:latest
container_name: vip-signal
environment:
- MODE=native
ports:
- "8080:8080"
volumes:
- signal_data:/home/.local/share/signal-cli
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/v1/about"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
# Traccar GPS Tracking Server
traccar:
image: traccar/traccar:6.11
container_name: vip-traccar
ports:
- "8082:8082" # Web UI & API
- "5055:5055" # GPS device port (OsmAnd protocol)
volumes:
- traccar_data:/opt/traccar/data
- traccar_logs:/opt/traccar/logs
environment:
- JAVA_OPTS=-Xms256m -Xmx512m
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8082/api/server"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
restart: unless-stopped
# Backend API
backend:
image: t72chevy/vip-coordinator-backend:latest
container_name: vip-backend
environment:
NODE_ENV: production
PORT: 3000
DATABASE_URL: postgresql://postgres:changeme@postgres:5432/vip_coordinator
REDIS_URL: redis://redis:6379
SIGNAL_API_URL: http://signal-api:8080
SIGNAL_WEBHOOK_SECRET: ${SIGNAL_WEBHOOK_SECRET:-}
TRACCAR_API_URL: http://traccar:8082
TRACCAR_DEVICE_PORT: 5055
AUTH0_DOMAIN: ${AUTH0_DOMAIN}
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE}
AUTH0_ISSUER: ${AUTH0_ISSUER}
FRONTEND_URL: http://localhost:5173
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped
# Frontend
frontend:
image: t72chevy/vip-coordinator-frontend:latest
container_name: vip-frontend
ports:
- "5173:80"
depends_on:
- backend
restart: unless-stopped
volumes:
postgres_data:
name: vip_postgres_data
redis_data:
name: vip_redis_data
signal_data:
name: vip_signal_data
traccar_data:
name: vip_traccar_data
traccar_logs:
name: vip_traccar_logs

Some files were not shown because too many files have changed in this diff Show More