diff --git a/backend/src/gps/gps.service.ts b/backend/src/gps/gps.service.ts new file mode 100644 index 0000000..3737396 --- /dev/null +++ b/backend/src/gps/gps.service.ts @@ -0,0 +1,775 @@ +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, + OnModuleInit, +} from '@nestjs/common'; +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, + ) {} + + 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, + consentGiven: 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; + port: number; + 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 + const deviceIdentifier = `vip-driver-${driverId.slice(0, 8)}`; + + // Create device in Traccar + const traccarDevice = await this.traccarClient.createDevice( + driver.name, + deviceIdentifier, + driver.phone || undefined, + ); + + // Create GPS device record + await this.prisma.gpsDevice.create({ + data: { + driverId, + traccarDeviceId: traccarDevice.id, + deviceIdentifier, + consentGiven: false, + }, + }); + + const serverUrl = this.traccarClient.getDeviceServerUrl(); + const settings = await this.getSettings(); + + 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: ${deviceIdentifier} + - 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, + serverUrl, + port: 5055, + instructions, + signalMessageSent, + }; + } + + /** + * 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, + consentGiven: true, + driver: { + deletedAt: null, + }, + }, + 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'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_MINUTE) + async syncPositions(): Promise { + const devices = await this.prisma.gpsDevice.findMany({ + where: { + isActive: true, + consentGiven: true, + }, + }); + + if (devices.length === 0) return; + + try { + const positions = await this.traccarClient.getAllPositions(); + + for (const device of devices) { + const position = positions.find((p) => p.deviceId === device.traccarDeviceId); + if (!position) continue; + + // Update last active timestamp + await this.prisma.gpsDevice.update({ + where: { id: device.id }, + data: { lastActive: new Date(position.deviceTime) }, + }); + + // Store in history + await this.prisma.gpsLocationHistory.create({ + data: { + deviceId: device.id, + 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), + }, + }); + } + } catch (error) { + this.logger.error(`Failed to sync positions: ${error}`); + } + } + + /** + * 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, + deletedAt: null, + }, + }); + + 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; + } +} diff --git a/backend/src/gps/traccar-client.service.ts b/backend/src/gps/traccar-client.service.ts new file mode 100644 index 0000000..b864162 --- /dev/null +++ b/backend/src/gps/traccar-client.service.ts @@ -0,0 +1,521 @@ +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; +} + +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 = 'admin'; + private adminPassword: string = 'admin'; + + constructor(private configService: ConfigService) { + this.baseUrl = this.configService.get('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 { + 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( + method: 'get' | 'post' | 'put' | 'delete', + path: string, + data?: any, + ): Promise { + // 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 { + try { + await this.client.get('/api/server'); + return true; + } catch { + return false; + } + } + + /** + * Get server info + */ + async getServerInfo(): Promise { + return this.request('get', '/api/server'); + } + + /** + * Create a new device in Traccar + */ + async createDevice(name: string, uniqueId: string, phone?: string): Promise { + return this.request('post', '/api/devices', { + name, + uniqueId, + phone, + category: 'person', + }); + } + + /** + * Get device by unique ID + */ + async getDeviceByUniqueId(uniqueId: string): Promise { + try { + const devices = await this.request('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 { + try { + const devices = await this.request('get', `/api/devices?id=${deviceId}`); + return devices.length > 0 ? devices[0] : null; + } catch { + return null; + } + } + + /** + * Get all devices + */ + async getAllDevices(): Promise { + return this.request('get', '/api/devices'); + } + + /** + * Delete a device + */ + async deleteDevice(deviceId: number): Promise { + await this.request('delete', `/api/devices/${deviceId}`); + } + + /** + * Get latest position for a device + */ + async getDevicePosition(deviceId: number): Promise { + try { + const positions = await this.request('get', `/api/positions?deviceId=${deviceId}`); + return positions.length > 0 ? positions[0] : null; + } catch { + return null; + } + } + + /** + * Get all current positions + */ + async getAllPositions(): Promise { + return this.request('get', '/api/positions'); + } + + /** + * Get position history for a device + */ + async getPositionHistory( + deviceId: number, + from: Date, + to: Date, + ): Promise { + 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 { + 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 { + 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 port URL for mobile app configuration + */ + getDeviceServerUrl(): string { + // In production, this should be the public URL + const port = this.configService.get('TRACCAR_DEVICE_PORT') || '5055'; + const frontendUrl = this.configService.get('FRONTEND_URL') || 'http://localhost:5173'; + + // Extract hostname from frontend URL + try { + const url = new URL(frontendUrl); + return `${url.protocol}//${url.hostname}:${port}`; + } catch { + return `http://localhost:${port}`; + } + } + + // ============================================ + // 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 { + // 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 { + 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('put', `/api/users/${user.id}`, { + ...user, + token, + }); + + return updatedUser.token; + } + + /** + * Get user's token if they have one + */ + async getUserToken(email: string): Promise { + const user = await this.getUserByEmail(email); + return user?.token || null; + } + + /** + * Get user by email + */ + async getUserByEmail(email: string): Promise { + try { + const users = await this.request('get', '/api/users'); + return users.find(u => u.email.toLowerCase() === email.toLowerCase()) || null; + } catch { + return null; + } + } + + /** + * Get all users + */ + async getAllUsers(): Promise { + return this.request('get', '/api/users'); + } + + /** + * Delete a user + */ + async deleteUser(userId: number): Promise { + 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('TRACCAR_PUBLIC_URL'); + if (traccarPublicUrl) { + return traccarPublicUrl; + } + + // Default: derive from frontend URL using traccar subdomain + const frontendUrl = this.configService.get('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 { + 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 { + 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; +}