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