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:
775
backend/src/gps/gps.service.ts
Normal file
775
backend/src/gps/gps.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
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