Files
vip-coordinator/backend/src/gps/gps.service.ts
kyle 4dbb899409 fix: improve GPS position sync reliability and add route trails (#21)
Backend:
- Increase sync overlap buffer from 5s to 30s to catch late-arriving positions
- Add position history endpoint GET /gps/locations/:driverId/history
- Add logging for position sync counts (returned vs inserted)

Frontend:
- Add useDriverLocationHistory hook for fetching position trails
- Draw Polyline route trails on GPS map for each tracked driver
- Historical positions shown as semi-transparent paths behind live markers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:42:41 +01:00

905 lines
26 KiB
TypeScript

import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
OnModuleInit,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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,
private configService: ConfigService,
) {}
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,
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;
qrCodeUrl: string;
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 (lowercase alphanumeric only for compatibility)
const deviceIdentifier = `vipdriver${driverId.replace(/-/g, '').slice(0, 8)}`.toLowerCase();
this.logger.log(`Enrolling driver ${driver.name} with device identifier: ${deviceIdentifier}`);
// Create device in Traccar
const traccarDevice = await this.traccarClient.createDevice(
driver.name,
deviceIdentifier,
driver.phone || undefined,
);
// Use the uniqueId returned by Traccar (in case it was modified)
const actualDeviceId = traccarDevice.uniqueId;
this.logger.log(`Traccar returned device with uniqueId: ${actualDeviceId}`);
// Create GPS device record (consent pre-approved by HR at hiring)
await this.prisma.gpsDevice.create({
data: {
driverId,
traccarDeviceId: traccarDevice.id,
deviceIdentifier: actualDeviceId, // Use what Traccar actually stored
consentGiven: true,
consentGivenAt: new Date(),
},
});
const serverUrl = this.traccarClient.getDeviceServerUrl();
const settings = await this.getSettings();
// Build QR code URL for Traccar Client app
// Format: https://server:5055?id=DEVICE_ID&interval=SECONDS
// The Traccar Client app parses this as: server URL (origin) + query params (id, interval, etc.)
const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055;
const traccarPublicUrl = this.traccarClient.getTraccarUrl();
const qrUrl = new URL(traccarPublicUrl);
qrUrl.port = String(devicePort);
qrUrl.searchParams.set('id', actualDeviceId);
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
const qrCodeUrl = qrUrl.toString();
this.logger.log(`QR code URL for driver: ${qrCodeUrl}`);
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: ${actualDeviceId}
- 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: actualDeviceId, // Return what Traccar actually stored
serverUrl,
qrCodeUrl,
instructions,
signalMessageSent,
};
}
/**
* Get QR code info for an already-enrolled device
*/
async getDeviceQrInfo(driverId: string): Promise<{
driverName: string;
deviceIdentifier: string;
serverUrl: string;
qrCodeUrl: string;
updateIntervalSeconds: number;
}> {
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');
}
const settings = await this.getSettings();
const serverUrl = this.traccarClient.getDeviceServerUrl();
const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055;
const traccarPublicUrl = this.traccarClient.getTraccarUrl();
const qrUrl = new URL(traccarPublicUrl);
qrUrl.port = String(devicePort);
qrUrl.searchParams.set('id', device.deviceIdentifier);
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
return {
driverName: device.driver.name,
deviceIdentifier: device.deviceIdentifier,
serverUrl,
qrCodeUrl: qrUrl.toString(),
updateIntervalSeconds: settings.updateIntervalSeconds,
};
}
/**
* 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,
},
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 location history (for route trail display)
*/
async getDriverLocationHistory(
driverId: string,
fromDate?: Date,
toDate?: Date,
): Promise<LocationDataDto[]> {
const device = await this.prisma.gpsDevice.findUnique({
where: { driverId },
});
if (!device) {
throw new NotFoundException('Driver is not enrolled for GPS tracking');
}
// Default to last 4 hours if no date range specified
const to = toDate || new Date();
const from = fromDate || new Date(to.getTime() - 4 * 60 * 60 * 1000);
const locations = await this.prisma.gpsLocationHistory.findMany({
where: {
deviceId: device.id,
timestamp: {
gte: from,
lte: to,
},
},
orderBy: { timestamp: 'asc' },
});
return locations.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,
}));
}
/**
* 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_30_SECONDS)
async syncPositions(): Promise<void> {
const devices = await this.prisma.gpsDevice.findMany({
where: {
isActive: true,
},
});
if (devices.length === 0) {
this.logger.debug('[GPS Sync] No active devices to sync');
return;
}
const now = new Date();
this.logger.log(`[GPS Sync] Starting sync for ${devices.length} active devices`);
for (const device of devices) {
try {
// Calculate "since" from device's last active time with 30s overlap buffer
// (increased from 5s to catch late-arriving positions)
// Falls back to 2 minutes ago if no lastActive
const since = device.lastActive
? new Date(device.lastActive.getTime() - 30000)
: new Date(now.getTime() - 120000);
this.logger.debug(`[GPS Sync] Fetching positions for device ${device.traccarDeviceId} since ${since.toISOString()}`);
const positions = await this.traccarClient.getPositionHistory(
device.traccarDeviceId,
since,
now,
);
this.logger.log(`[GPS Sync] Device ${device.traccarDeviceId}: Retrieved ${positions.length} positions from Traccar`);
if (positions.length === 0) continue;
// Batch insert with skipDuplicates (unique constraint on deviceId+timestamp)
const insertResult = await this.prisma.gpsLocationHistory.createMany({
data: positions.map((p) => ({
deviceId: device.id,
latitude: p.latitude,
longitude: p.longitude,
altitude: p.altitude || null,
speed: this.traccarClient.knotsToMph(p.speed || 0),
course: p.course || null,
accuracy: p.accuracy || null,
battery: p.attributes?.batteryLevel || null,
timestamp: new Date(p.deviceTime),
})),
skipDuplicates: true,
});
const inserted = insertResult.count;
const skipped = positions.length - inserted;
this.logger.log(
`[GPS Sync] Device ${device.traccarDeviceId}: ` +
`Inserted ${inserted} new positions, skipped ${skipped} duplicates`
);
// Update lastActive to the latest position timestamp
const latestPosition = positions.reduce((latest, p) =>
new Date(p.deviceTime) > new Date(latest.deviceTime) ? p : latest
);
await this.prisma.gpsDevice.update({
where: { id: device.id },
data: { lastActive: new Date(latestPosition.deviceTime) },
});
} catch (error) {
this.logger.error(`[GPS Sync] Failed to sync positions for device ${device.traccarDeviceId}: ${error}`);
}
}
this.logger.log('[GPS Sync] Sync completed');
}
/**
* 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,
},
});
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;
}
}