Routes now follow actual roads instead of cutting through buildings: - New OsrmService calls free OSRM Match API to snap GPS points to roads - Position history endpoint accepts ?matched=true for road-snapped geometry - Stats use OSRM road distance instead of Haversine crow-flies distance - Frontend shows solid blue polylines for matched routes, dashed for raw - Handles chunking (100 coord limit), rate limiting, graceful fallback - Distance badge shows accurate road miles on route trails Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
369 lines
9.5 KiB
TypeScript
369 lines
9.5 KiB
TypeScript
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();
|
|
}
|
|
|
|
/**
|
|
* Get QR code info for an enrolled device
|
|
*/
|
|
@Get('devices/:driverId/qr')
|
|
@Roles(Role.ADMINISTRATOR)
|
|
async getDeviceQr(@Param('driverId') driverId: string) {
|
|
return this.gpsService.getDeviceQrInfo(driverId);
|
|
}
|
|
|
|
/**
|
|
* 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 location history (for route trail display)
|
|
* Query param 'matched=true' returns OSRM road-snapped route
|
|
*/
|
|
@Get('locations/:driverId/history')
|
|
@Roles(Role.ADMINISTRATOR)
|
|
async getDriverLocationHistory(
|
|
@Param('driverId') driverId: string,
|
|
@Query('from') fromStr?: string,
|
|
@Query('to') toStr?: string,
|
|
@Query('matched') matched?: string,
|
|
) {
|
|
const from = fromStr ? new Date(fromStr) : undefined;
|
|
const to = toStr ? new Date(toStr) : undefined;
|
|
|
|
// If matched=true, return OSRM road-matched route
|
|
if (matched === 'true') {
|
|
return this.gpsService.getMatchedRoute(driverId, from, to);
|
|
}
|
|
|
|
// Otherwise return raw GPS points
|
|
return this.gpsService.getDriverLocationHistory(driverId, from, to);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|