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>
This commit is contained in:
521
backend/src/gps/traccar-client.service.ts
Normal file
521
backend/src/gps/traccar-client.service.ts
Normal file
@@ -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<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 = 'admin';
|
||||
private adminPassword: string = 'admin';
|
||||
|
||||
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> {
|
||||
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> {
|
||||
return this.request('post', '/api/devices', {
|
||||
name,
|
||||
uniqueId,
|
||||
phone,
|
||||
category: 'person',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<any[]> {
|
||||
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 port URL for mobile app configuration
|
||||
*/
|
||||
getDeviceServerUrl(): string {
|
||||
// In production, this should be the public URL
|
||||
const port = this.configService.get<string>('TRACCAR_DEVICE_PORT') || '5055';
|
||||
const frontendUrl = this.configService.get<string>('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<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>;
|
||||
}
|
||||
Reference in New Issue
Block a user