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 { 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 { 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 { 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 // Format: https://server:5055?id=DEVICE_ID&interval=SECONDS // The Traccar Client app parses this as: server URL (origin) + query params (id, interval, etc.) const devicePort = this.configService.get('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)); 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 configure: - Device identifier: ${actualDeviceId} - Server URL: ${serverUrl} - Frequency: ${settings.updateIntervalSeconds} seconds - Location accuracy: High 3. Tap "Service Status" to start tracking. Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}:00 - ${settings.shiftEndHour}:00). `.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, // Return what Traccar actually stored 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('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)); 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 { 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 { return this.prisma.gpsDevice.findMany({ include: { driver: { select: { id: true, name: true, phone: true, }, }, }, orderBy: { enrolledAt: 'desc' }, }); } /** * Get all active driver locations (Admin only) */ async getActiveDriverLocations(): Promise { 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 */ async getDriverLocation(driverId: string): Promise { 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, }; } /** * Get driver location history (for route trail display) */ async getDriverLocationHistory( driverId: string, fromDate?: Date, toDate?: Date, ): Promise { const device = await this.prisma.gpsDevice.findUnique({ where: { driverId }, }); if (!device) { throw new NotFoundException('Driver is not enrolled for GPS tracking'); } // Default to last 4 hours if no date range specified const to = toDate || new Date(); const from = fromDate || new Date(to.getTime() - 4 * 60 * 60 * 1000); const locations = await this.prisma.gpsLocationHistory.findMany({ where: { deviceId: device.id, timestamp: { gte: from, lte: to, }, }, orderBy: { timestamp: 'asc' }, }); return locations.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, })); } /** * Get driver's own stats (for driver self-view) */ async getDriverStats( driverId: string, fromDate?: Date, toDate?: Date, ): Promise { 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); // Get summary from Traccar let totalMiles = 0; let topSpeedMph = 0; let topSpeedTimestamp: Date | null = null; let totalTrips = 0; let totalDrivingMinutes = 0; try { const summary = await this.traccarClient.getSummaryReport( device.traccarDeviceId, from, to, ); if (summary.length > 0) { const report = summary[0]; // Distance is in meters, convert to miles totalMiles = (report.distance || 0) / 1609.344; topSpeedMph = this.traccarClient.knotsToMph(report.maxSpeed || 0); // Engine hours in milliseconds, convert to minutes totalDrivingMinutes = Math.round((report.engineHours || 0) / 60000); } // Get trips for additional stats const trips = await this.traccarClient.getTripReport( device.traccarDeviceId, from, to, ); totalTrips = trips.length; // Find top speed timestamp from positions const positions = await this.traccarClient.getPositionHistory( device.traccarDeviceId, from, to, ); let maxSpeed = 0; for (const pos of positions) { const speedMph = this.traccarClient.knotsToMph(pos.speed || 0); if (speedMph > maxSpeed) { maxSpeed = speedMph; topSpeedTimestamp = new Date(pos.deviceTime); } } topSpeedMph = maxSpeed; } catch (error) { this.logger.warn(`Failed to fetch stats from Traccar: ${error}`); } // Get recent locations from our database const recentLocations = await this.prisma.gpsLocationHistory.findMany({ where: { deviceId: device.id, timestamp: { gte: from, lte: to, }, }, orderBy: { timestamp: 'desc' }, take: 100, }); return { driverId, driverName: device.driver.name, period: { from, to, }, stats: { totalMiles: Math.round(totalMiles * 10) / 10, topSpeedMph: Math.round(topSpeedMph), topSpeedTimestamp, averageSpeedMph: totalDrivingMinutes > 0 ? Math.round((totalMiles / (totalDrivingMinutes / 60)) * 10) / 10 : 0, totalTrips, 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 { 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 { // Calculate "since" from device's last active time with 30s overlap buffer // (increased from 5s to catch late-arriving positions) // Falls back to 2 minutes ago if no lastActive const since = device.lastActive ? new Date(device.lastActive.getTime() - 30000) : new Date(now.getTime() - 120000); this.logger.debug(`[GPS Sync] Fetching positions for device ${device.traccarDeviceId} since ${since.toISOString()}`); 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; // Batch insert with skipDuplicates (unique constraint on deviceId+timestamp) 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` ); // Update lastActive to the latest position timestamp 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 { 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) // ============================================ /** * Generate a secure password for Traccar user */ private generateTraccarPassword(userId: string): string { // Generate deterministic but secure password based on user ID + secret const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-sync'; return crypto .createHmac('sha256', secret) .update(userId) .digest('hex') .substring(0, 24); } /** * Generate a secure token for Traccar auto-login */ private generateTraccarToken(userId: string): string { // Generate deterministic token for auto-login const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-token'; return crypto .createHmac('sha256', secret + '-token') .update(userId) .digest('hex') .substring(0, 32); } /** * Sync a VIP user to Traccar */ async syncUserToTraccar(user: User): Promise { 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, // Include token for auto-login ); 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; } } /** * Sync all VIP admins to Traccar */ 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 }; } /** * Get auto-login URL for Traccar (for admin users) */ async getTraccarAutoLoginUrl(user: User): Promise<{ url: string; directAccess: boolean; }> { if (user.role !== 'ADMINISTRATOR') { throw new BadRequestException('Only administrators can access Traccar admin'); } // Ensure user is synced to Traccar (this also sets up their token) await this.syncUserToTraccar(user); // Get the token for auto-login const token = this.generateTraccarToken(user.id); const baseUrl = this.traccarClient.getTraccarUrl(); // Return URL with token parameter for auto-login // Traccar supports ?token=xxx for direct authentication return { url: `${baseUrl}?token=${token}`, directAccess: true, }; } /** * Get Traccar session cookie for a user (for proxy/iframe auth) */ async getTraccarSessionForUser(user: User): Promise { if (user.role !== 'ADMINISTRATOR') { return null; } // Ensure user is synced await this.syncUserToTraccar(user); const password = this.generateTraccarPassword(user.id); const session = await this.traccarClient.createUserSession(user.email, password); return session?.cookie || null; } /** * Check if Traccar needs initial setup */ 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 }; } /** * Perform initial Traccar setup */ async performTraccarSetup(adminEmail: string): Promise { // Generate a secure password for the service account const servicePassword = crypto.randomBytes(16).toString('hex'); const success = await this.traccarClient.performInitialSetup( adminEmail, servicePassword, ); if (success) { // Save the service account credentials to settings await this.updateSettings({ traccarAdminUser: adminEmail, traccarAdminPassword: servicePassword, }); this.logger.log('Traccar initial setup complete'); return true; } return false; } }