diff --git a/backend/.env b/backend/.env deleted file mode 100644 index 573e53a..0000000 --- a/backend/.env +++ /dev/null @@ -1,40 +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" - -# ============================================ -# AI Copilot Configuration (Optional) -# ============================================ -# Get API key from: https://console.anthropic.com/ -# Cost: ~$3 per million tokens -ANTHROPIC_API_KEY="your-anthropic-api-key" diff --git a/backend/.env.example b/backend/.env.example index a85bf29..5e4eb06 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/prisma/migrations/20260207080656_add_vip_roster_and_emergency_fields/migration.sql b/backend/prisma/migrations/20260207080656_add_vip_roster_and_emergency_fields/migration.sql new file mode 100644 index 0000000..402b85f --- /dev/null +++ b/backend/prisma/migrations/20260207080656_add_vip_roster_and_emergency_fields/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 1e0221b..cc30e34 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -52,6 +52,16 @@ model VIP { 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 diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 03ef762..f2c3600 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -18,52 +18,56 @@ 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({ - where: { auth0Id }, + // Check if user exists (exclude soft-deleted users) + let user = await this.prisma.user.findFirst({ + where: { auth0Id, deletedAt: null }, 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, deletedAt: null }, + }); + 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 (excludes soft-deleted users) */ async getCurrentUser(auth0Id: string) { - return this.prisma.user.findUnique({ - where: { auth0Id }, + return this.prisma.user.findFirst({ + where: { auth0Id, deletedAt: null }, include: { driver: true }, }); } diff --git a/backend/src/gps/traccar-client.service.ts b/backend/src/gps/traccar-client.service.ts index 119e352..86d0d34 100644 --- a/backend/src/gps/traccar-client.service.ts +++ b/backend/src/gps/traccar-client.service.ts @@ -53,8 +53,8 @@ export class TraccarClientService implements OnModuleInit { private client: AxiosInstance; private readonly baseUrl: string; private sessionCookie: string | null = null; - private adminUser: string = 'admin'; - private adminPassword: string = 'admin'; + private adminUser: string = ''; + private adminPassword: string = ''; constructor(private configService: ConfigService) { this.baseUrl = this.configService.get('TRACCAR_API_URL') || 'http://localhost:8082'; @@ -86,6 +86,11 @@ export class TraccarClientService implements OnModuleInit { * Authenticate with Traccar and get session cookie */ async authenticate(): Promise { + 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', diff --git a/backend/src/settings/settings.controller.ts b/backend/src/settings/settings.controller.ts index ad138d3..9927e24 100644 --- a/backend/src/settings/settings.controller.ts +++ b/backend/src/settings/settings.controller.ts @@ -12,6 +12,7 @@ import { 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'; @@ -22,7 +23,24 @@ import { CanUpdate } from '../auth/decorators/check-ability.decorator'; @Controller('settings') @UseGuards(JwtAuthGuard, AbilitiesGuard) export class SettingsController { - constructor(private readonly settingsService: SettingsService) {} + 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('pdf') @CanUpdate('Settings') // Admin-only (Settings subject is admin-only) diff --git a/backend/src/vips/dto/create-vip.dto.ts b/backend/src/vips/dto/create-vip.dto.ts index f9bba80..ca3ef4d 100644 --- a/backend/src/vips/dto/create-vip.dto.ts +++ b/backend/src/vips/dto/create-vip.dto.ts @@ -5,6 +5,7 @@ import { IsBoolean, IsDateString, IsInt, + IsEmail, Min, } from 'class-validator'; import { Department, ArrivalMode } from '@prisma/client'; @@ -43,4 +44,27 @@ export class CreateVipDto { @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; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4f6dfb5..0a5153c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import { AdminTools } from '@/pages/AdminTools'; import { DriverProfile } from '@/pages/DriverProfile'; import { MySchedule } from '@/pages/MySchedule'; import { GpsTracking } from '@/pages/GpsTracking'; +import { Reports } from '@/pages/Reports'; import { useAuth } from '@/contexts/AuthContext'; // Smart redirect based on user role @@ -122,6 +123,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 91ad3ac..bd83e83 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -21,6 +21,7 @@ import { LogOut, Phone, AlertCircle, + FileText, } from 'lucide-react'; import { UserMenu } from '@/components/UserMenu'; import { AppearanceMenu } from '@/components/AppearanceMenu'; @@ -59,6 +60,22 @@ export function Layout({ children }: LayoutProps) { return () => document.removeEventListener('mousedown', handleClickOutside); }, []); + // Fetch feature flags from backend (which optional services are configured) + const { data: features } = useQuery<{ + copilot: boolean; + flightTracking: boolean; + signalMessaging: boolean; + gpsTracking: boolean; + }>({ + queryKey: ['features'], + queryFn: async () => { + const { data } = await api.get('/settings/features'); + return data; + }, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + enabled: !!backendUser, // Only fetch when authenticated + }); + // Check if user is a driver (limited access) const isDriverRole = backendUser?.role === 'DRIVER'; @@ -78,6 +95,7 @@ export function Layout({ children }: LayoutProps) { // Admin dropdown items (nested under Admin) const adminItems = [ { name: 'Users', href: '/users', icon: UserCog }, + { name: 'Reports', href: '/reports', icon: FileText }, { name: 'GPS Tracking', href: '/gps-tracking', icon: Radio }, { name: 'Admin Tools', href: '/admin-tools', icon: Settings }, ]; @@ -405,8 +423,8 @@ export function Layout({ children }: LayoutProps) { {children} - {/* AI Copilot - floating chat (only for Admins and Coordinators) */} - {backendUser && (backendUser.role === 'ADMINISTRATOR' || backendUser.role === 'COORDINATOR') && ( + {/* AI Copilot - floating chat (only if backend has API key and user is Admin/Coordinator) */} + {features?.copilot && backendUser && (backendUser.role === 'ADMINISTRATOR' || backendUser.role === 'COORDINATOR') && ( )} diff --git a/frontend/src/components/VIPForm.tsx b/frontend/src/components/VIPForm.tsx index 8435783..4ded53d 100644 --- a/frontend/src/components/VIPForm.tsx +++ b/frontend/src/components/VIPForm.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { X } from 'lucide-react'; +import { X, ChevronDown, ChevronUp, ClipboardList } from 'lucide-react'; interface VIPFormProps { vip?: VIP | null; @@ -19,6 +19,11 @@ interface VIP { venueTransport: boolean; partySize: number; notes: string | null; + isRosterOnly: boolean; + phone: string | null; + email: string | null; + emergencyContactName: string | null; + emergencyContactPhone: string | null; } export interface VIPFormData { @@ -31,6 +36,11 @@ export interface VIPFormData { venueTransport?: boolean; partySize?: number; notes?: string; + isRosterOnly?: boolean; + phone?: string; + email?: string; + emergencyContactName?: string; + emergencyContactPhone?: string; } export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) { @@ -56,8 +66,17 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) venueTransport: vip?.venueTransport ?? false, partySize: vip?.partySize ?? 1, notes: vip?.notes || '', + isRosterOnly: vip?.isRosterOnly ?? false, + phone: vip?.phone || '', + email: vip?.email || '', + emergencyContactName: vip?.emergencyContactName || '', + emergencyContactPhone: vip?.emergencyContactPhone || '', }); + const [showRosterFields, setShowRosterFields] = useState( + vip?.isRosterOnly || vip?.phone || vip?.email || vip?.emergencyContactName ? true : false + ); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -69,6 +88,10 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) ? new Date(formData.expectedArrival).toISOString() : undefined, notes: formData.notes || undefined, + phone: formData.phone || undefined, + email: formData.email || undefined, + emergencyContactName: formData.emergencyContactName || undefined, + emergencyContactPhone: formData.emergencyContactPhone || undefined, }; onSubmit(cleanedData); @@ -238,6 +261,118 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps) + {/* Roster & Contact Info Section */} +
+ + + {showRosterFields && ( +
+ {/* Roster Only Toggle */} + +

+ Check this for VIPs who are attending but don't need transportation services +

+ + {/* VIP Contact Info */} +
+
+ + +
+
+ + +
+
+ + {/* Emergency Contact */} +
+

Emergency Contact

+
+
+ + +
+
+ + +
+
+
+
+ )} +
+ {/* Notes */}
)} - {/* Results count */} -
-
- Showing {filteredVIPs.length} of {vips?.length || 0} VIPs - {debouncedSearchTerm !== searchTerm && (searching...)} + {/* Results count and Roster-Only Toggle */} +
+
+
+ Showing {filteredVIPs.length} of {vips?.length || 0} VIPs + {debouncedSearchTerm !== searchTerm && (searching...)} +
+ {rosterOnlyCount > 0 && ( + + )}
{(searchTerm || selectedDepartments.length > 0 || selectedArrivalModes.length > 0) && (