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>
905 lines
26 KiB
TypeScript
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;
|
|
}
|
|
}
|