feat: add GPS tracking with Traccar integration

- Add GPS module with Traccar client service for device management
- Add driver enrollment flow with QR code generation
- Add real-time location tracking on driver profiles
- Add GPS settings configuration in admin tools
- Add Auth0 OpenID Connect setup script for Traccar
- Add deployment configs for production server
- Update nginx configs for SSL on GPS port 5055
- Add timezone setting support
- Various UI improvements and bug fixes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 18:13:17 +01:00
parent 3814d175ff
commit 5ded039793
91 changed files with 4403 additions and 68 deletions

View File

@@ -0,0 +1,21 @@
import { IsBoolean, IsOptional } from 'class-validator';
export class EnrollDriverDto {
@IsOptional()
@IsBoolean()
sendSignalMessage?: boolean = true;
}
export class EnrollmentResponseDto {
success: boolean;
deviceIdentifier: string;
serverUrl: string;
port: number;
instructions: string;
signalMessageSent?: boolean;
}
export class ConfirmConsentDto {
@IsBoolean()
consentGiven: boolean;
}

View File

@@ -0,0 +1,3 @@
export * from './enroll-driver.dto';
export * from './update-gps-settings.dto';
export * from './location-response.dto';

View File

@@ -0,0 +1,51 @@
export class DriverLocationDto {
driverId: string;
driverName: string;
driverPhone: string | null;
deviceIdentifier: string;
isActive: boolean;
lastActive: Date | null;
location: LocationDataDto | null;
}
export class LocationDataDto {
latitude: number;
longitude: number;
altitude: number | null;
speed: number | null; // mph
course: number | null;
accuracy: number | null;
battery: number | null;
timestamp: Date;
}
export class DriverStatsDto {
driverId: string;
driverName: string;
period: {
from: Date;
to: Date;
};
stats: {
totalMiles: number;
topSpeedMph: number;
topSpeedTimestamp: Date | null;
averageSpeedMph: number;
totalTrips: number;
totalDrivingMinutes: number;
};
recentLocations: LocationDataDto[];
}
export class GpsStatusDto {
traccarAvailable: boolean;
traccarVersion: string | null;
enrolledDrivers: number;
activeDrivers: number;
settings: {
updateIntervalSeconds: number;
shiftStartTime: string;
shiftEndTime: string;
retentionDays: number;
};
}

View File

@@ -0,0 +1,47 @@
import { IsInt, IsOptional, IsString, Min, Max } from 'class-validator';
export class UpdateGpsSettingsDto {
@IsOptional()
@IsInt()
@Min(10)
@Max(600)
updateIntervalSeconds?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(23)
shiftStartHour?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(59)
shiftStartMinute?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(23)
shiftEndHour?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(59)
shiftEndMinute?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(365)
retentionDays?: number;
@IsOptional()
@IsString()
traccarAdminUser?: string;
@IsOptional()
@IsString()
traccarAdminPassword?: string;
}

View File

@@ -0,0 +1,335 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { GpsService } from './gps.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Role } from '@prisma/client';
import { EnrollDriverDto, ConfirmConsentDto } from './dto/enroll-driver.dto';
import { UpdateGpsSettingsDto } from './dto/update-gps-settings.dto';
import { PrismaService } from '../prisma/prisma.service';
@Controller('gps')
@UseGuards(JwtAuthGuard, RolesGuard)
export class GpsController {
constructor(
private readonly gpsService: GpsService,
private readonly prisma: PrismaService,
) {}
// ============================================
// Admin-only endpoints
// ============================================
/**
* Get GPS system status
*/
@Get('status')
@Roles(Role.ADMINISTRATOR)
async getStatus() {
return this.gpsService.getStatus();
}
/**
* Get GPS settings
*/
@Get('settings')
@Roles(Role.ADMINISTRATOR)
async getSettings() {
const settings = await this.gpsService.getSettings();
// Don't return the password
return {
...settings,
traccarAdminPassword: settings.traccarAdminPassword ? '********' : null,
};
}
/**
* Update GPS settings
*/
@Patch('settings')
@Roles(Role.ADMINISTRATOR)
async updateSettings(@Body() dto: UpdateGpsSettingsDto) {
const settings = await this.gpsService.updateSettings(dto);
return {
...settings,
traccarAdminPassword: settings.traccarAdminPassword ? '********' : null,
};
}
/**
* Get all enrolled devices
*/
@Get('devices')
@Roles(Role.ADMINISTRATOR)
async getEnrolledDevices() {
return this.gpsService.getEnrolledDevices();
}
/**
* Enroll a driver for GPS tracking
*/
@Post('enroll/:driverId')
@Roles(Role.ADMINISTRATOR)
async enrollDriver(
@Param('driverId') driverId: string,
@Body() dto: EnrollDriverDto,
) {
return this.gpsService.enrollDriver(driverId, dto.sendSignalMessage ?? true);
}
/**
* Unenroll a driver from GPS tracking
*/
@Delete('devices/:driverId')
@Roles(Role.ADMINISTRATOR)
async unenrollDriver(@Param('driverId') driverId: string) {
return this.gpsService.unenrollDriver(driverId);
}
/**
* Get all active driver locations (Admin map view)
*/
@Get('locations')
@Roles(Role.ADMINISTRATOR)
async getActiveDriverLocations() {
return this.gpsService.getActiveDriverLocations();
}
/**
* Get a specific driver's location
*/
@Get('locations/:driverId')
@Roles(Role.ADMINISTRATOR)
async getDriverLocation(@Param('driverId') driverId: string) {
const location = await this.gpsService.getDriverLocation(driverId);
if (!location) {
throw new NotFoundException('Driver not found or not enrolled for GPS tracking');
}
return location;
}
/**
* Get a driver's stats (Admin viewing any driver)
*/
@Get('stats/:driverId')
@Roles(Role.ADMINISTRATOR)
async getDriverStats(
@Param('driverId') driverId: string,
@Query('from') fromStr?: string,
@Query('to') toStr?: string,
) {
const from = fromStr ? new Date(fromStr) : undefined;
const to = toStr ? new Date(toStr) : undefined;
return this.gpsService.getDriverStats(driverId, from, to);
}
// ============================================
// Traccar Admin Access
// ============================================
/**
* Check Traccar setup status
*/
@Get('traccar/status')
@Roles(Role.ADMINISTRATOR)
async getTraccarSetupStatus() {
return this.gpsService.checkTraccarSetup();
}
/**
* Perform initial Traccar setup
*/
@Post('traccar/setup')
@Roles(Role.ADMINISTRATOR)
async performTraccarSetup(@CurrentUser() user: any) {
const success = await this.gpsService.performTraccarSetup(user.email);
if (!success) {
throw new NotFoundException('Failed to setup Traccar. It may already be configured.');
}
return { success: true, message: 'Traccar setup complete' };
}
/**
* Sync all VIP admins to Traccar
*/
@Post('traccar/sync-admins')
@Roles(Role.ADMINISTRATOR)
async syncAdminsToTraccar() {
return this.gpsService.syncAllAdminsToTraccar();
}
/**
* Get Traccar admin URL (auto-login for current user)
*/
@Get('traccar/admin-url')
@Roles(Role.ADMINISTRATOR)
async getTraccarAdminUrl(@CurrentUser() user: any) {
// Get full user from database
const fullUser = await this.prisma.user.findUnique({
where: { id: user.id },
});
if (!fullUser) {
throw new NotFoundException('User not found');
}
return this.gpsService.getTraccarAutoLoginUrl(fullUser);
}
/**
* Get Traccar session for iframe/proxy access
*/
@Get('traccar/session')
@Roles(Role.ADMINISTRATOR)
async getTraccarSession(@CurrentUser() user: any) {
const fullUser = await this.prisma.user.findUnique({
where: { id: user.id },
});
if (!fullUser) {
throw new NotFoundException('User not found');
}
const session = await this.gpsService.getTraccarSessionForUser(fullUser);
if (!session) {
throw new NotFoundException('Could not create Traccar session');
}
return { session };
}
// ============================================
// Driver self-service endpoints
// ============================================
/**
* Get my GPS enrollment status
*/
@Get('me')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
async getMyGpsStatus(@CurrentUser() user: any) {
// Find driver linked to this user
const driver = await this.prisma.driver.findFirst({
where: {
userId: user.id,
deletedAt: null,
},
include: {
gpsDevice: true,
},
});
if (!driver) {
return { enrolled: false, message: 'No driver profile linked to your account' };
}
if (!driver.gpsDevice) {
return { enrolled: false, driverId: driver.id };
}
return {
enrolled: true,
driverId: driver.id,
deviceIdentifier: driver.gpsDevice.deviceIdentifier,
consentGiven: driver.gpsDevice.consentGiven,
consentGivenAt: driver.gpsDevice.consentGivenAt,
isActive: driver.gpsDevice.isActive,
lastActive: driver.gpsDevice.lastActive,
};
}
/**
* Confirm GPS tracking consent (Driver accepting tracking)
*/
@Post('me/consent')
@Roles(Role.DRIVER)
async confirmMyConsent(
@CurrentUser() user: any,
@Body() dto: ConfirmConsentDto,
) {
const driver = await this.prisma.driver.findFirst({
where: {
userId: user.id,
deletedAt: null,
},
});
if (!driver) {
throw new NotFoundException('No driver profile linked to your account');
}
await this.gpsService.confirmConsent(driver.id, dto.consentGiven);
return {
success: true,
message: dto.consentGiven
? 'GPS tracking consent confirmed. Your location will be tracked during shift hours.'
: 'GPS tracking consent revoked. Your location will not be tracked.',
};
}
/**
* Get my GPS stats (Driver viewing own stats)
*/
@Get('me/stats')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
async getMyStats(
@CurrentUser() user: any,
@Query('from') fromStr?: string,
@Query('to') toStr?: string,
) {
const driver = await this.prisma.driver.findFirst({
where: {
userId: user.id,
deletedAt: null,
},
});
if (!driver) {
throw new NotFoundException('No driver profile linked to your account');
}
const from = fromStr ? new Date(fromStr) : undefined;
const to = toStr ? new Date(toStr) : undefined;
return this.gpsService.getDriverStats(driver.id, from, to);
}
/**
* Get my current location
*/
@Get('me/location')
@Roles(Role.DRIVER, Role.ADMINISTRATOR, Role.COORDINATOR)
async getMyLocation(@CurrentUser() user: any) {
const driver = await this.prisma.driver.findFirst({
where: {
userId: user.id,
deletedAt: null,
},
});
if (!driver) {
throw new NotFoundException('No driver profile linked to your account');
}
const location = await this.gpsService.getDriverLocation(driver.id);
if (!location) {
throw new NotFoundException('You are not enrolled for GPS tracking');
}
return location;
}
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { GpsController } from './gps.controller';
import { GpsService } from './gps.service';
import { TraccarClientService } from './traccar-client.service';
import { PrismaModule } from '../prisma/prisma.module';
import { SignalModule } from '../signal/signal.module';
@Module({
imports: [
PrismaModule,
SignalModule,
ScheduleModule.forRoot(),
],
controllers: [GpsController],
providers: [GpsService, TraccarClientService],
exports: [GpsService, TraccarClientService],
})
export class GpsModule {}