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:
21
backend/src/gps/dto/enroll-driver.dto.ts
Normal file
21
backend/src/gps/dto/enroll-driver.dto.ts
Normal 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;
|
||||
}
|
||||
3
backend/src/gps/dto/index.ts
Normal file
3
backend/src/gps/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './enroll-driver.dto';
|
||||
export * from './update-gps-settings.dto';
|
||||
export * from './location-response.dto';
|
||||
51
backend/src/gps/dto/location-response.dto.ts
Normal file
51
backend/src/gps/dto/location-response.dto.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
47
backend/src/gps/dto/update-gps-settings.dto.ts
Normal file
47
backend/src/gps/dto/update-gps-settings.dto.ts
Normal 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;
|
||||
}
|
||||
335
backend/src/gps/gps.controller.ts
Normal file
335
backend/src/gps/gps.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
backend/src/gps/gps.module.ts
Normal file
19
backend/src/gps/gps.module.ts
Normal 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 {}
|
||||
Reference in New Issue
Block a user