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:
2026-02-02 22:44:13 +01:00
parent 3b0b1205df
commit 8ff331f8fa
2 changed files with 1296 additions and 0 deletions

View File

@@ -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<GpsSettings> {
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<GpsSettings> {
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<GpsStatusDto> {
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<void> {
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<any[]> {
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<DriverLocationDto[]> {
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<DriverLocationDto | null> {
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<DriverStatsDto> {
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<void> {
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<void> {
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<boolean> {
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<string | null> {
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<boolean> {
// 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;
}
}

View 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>;
}