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 { const device = await this.request('post', '/api/devices', { name, uniqueId, phone, category: 'person', }); // 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 { 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 { 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 { 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 * Returns full HTTPS URL - nginx terminates SSL on port 5055 */ getDeviceServerUrl(): string { const port = this.configService.get('TRACCAR_DEVICE_PORT') || '5055'; // Use the Traccar public URL to derive the device server hostname const traccarUrl = this.getTraccarUrl(); try { const url = new URL(traccarUrl); // Return HTTPS URL - nginx terminates SSL and proxies to Traccar return `https://${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; }