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:
@@ -31,3 +31,10 @@ AUTH0_ISSUER="https://dev-s855cy3bvjjbkljt.us.auth0.com/"
|
||||
# ============================================
|
||||
# Get API key from: https://aviationstack.com/
|
||||
AVIATIONSTACK_API_KEY="your-aviationstack-api-key"
|
||||
|
||||
# ============================================
|
||||
# AI Copilot Configuration (Optional)
|
||||
# ============================================
|
||||
# Get API key from: https://console.anthropic.com/
|
||||
# Cost: ~$3 per million tokens
|
||||
ANTHROPIC_API_KEY="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"
|
||||
|
||||
53
backend/package-lock.json
generated
53
backend/package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"@prisma/client": "^5.8.1",
|
||||
"@types/pdfkit": "^0.17.4",
|
||||
"axios": "^1.6.5",
|
||||
@@ -1958,6 +1959,20 @@
|
||||
"@nestjs/core": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schedule": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
|
||||
"integrity": "sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron": "3.2.1",
|
||||
"uuid": "11.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics": {
|
||||
"version": "10.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
|
||||
@@ -2470,6 +2485,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
|
||||
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
@@ -4256,6 +4277,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cron": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-3.2.1.tgz",
|
||||
"integrity": "sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/luxon": "~3.4.0",
|
||||
"luxon": "~3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -7436,6 +7467,15 @@
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
|
||||
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.8",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
|
||||
@@ -10132,6 +10172,19 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
|
||||
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@nestjs/mapped-types": "^2.1.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"@prisma/client": "^5.8.1",
|
||||
"@types/pdfkit": "^0.17.4",
|
||||
"axios": "^1.6.5",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "pdf_settings" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'America/New_York';
|
||||
@@ -0,0 +1,71 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "gps_devices" (
|
||||
"id" TEXT NOT NULL,
|
||||
"driverId" TEXT NOT NULL,
|
||||
"traccarDeviceId" INTEGER NOT NULL,
|
||||
"deviceIdentifier" TEXT NOT NULL,
|
||||
"enrolledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"consentGiven" BOOLEAN NOT NULL DEFAULT false,
|
||||
"consentGivenAt" TIMESTAMP(3),
|
||||
"lastActive" TIMESTAMP(3),
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "gps_devices_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "gps_location_history" (
|
||||
"id" TEXT NOT NULL,
|
||||
"deviceId" TEXT NOT NULL,
|
||||
"latitude" DOUBLE PRECISION NOT NULL,
|
||||
"longitude" DOUBLE PRECISION NOT NULL,
|
||||
"altitude" DOUBLE PRECISION,
|
||||
"speed" DOUBLE PRECISION,
|
||||
"course" DOUBLE PRECISION,
|
||||
"accuracy" DOUBLE PRECISION,
|
||||
"battery" DOUBLE PRECISION,
|
||||
"timestamp" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "gps_location_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "gps_settings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"updateIntervalSeconds" INTEGER NOT NULL DEFAULT 60,
|
||||
"shiftStartHour" INTEGER NOT NULL DEFAULT 4,
|
||||
"shiftStartMinute" INTEGER NOT NULL DEFAULT 0,
|
||||
"shiftEndHour" INTEGER NOT NULL DEFAULT 1,
|
||||
"shiftEndMinute" INTEGER NOT NULL DEFAULT 0,
|
||||
"retentionDays" INTEGER NOT NULL DEFAULT 30,
|
||||
"traccarAdminUser" TEXT NOT NULL DEFAULT 'admin',
|
||||
"traccarAdminPassword" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "gps_settings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "gps_devices_driverId_key" ON "gps_devices"("driverId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "gps_devices_traccarDeviceId_key" ON "gps_devices"("traccarDeviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "gps_devices_deviceIdentifier_key" ON "gps_devices"("deviceIdentifier");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "gps_location_history_deviceId_timestamp_idx" ON "gps_location_history"("deviceId", "timestamp");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "gps_location_history_timestamp_idx" ON "gps_location_history"("timestamp");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "gps_devices" ADD CONSTRAINT "gps_devices_driverId_fkey" FOREIGN KEY ("driverId") REFERENCES "drivers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "gps_location_history" ADD CONSTRAINT "gps_location_history_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "gps_devices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -115,6 +115,7 @@ model Driver {
|
||||
events ScheduleEvent[]
|
||||
assignedVehicle Vehicle? @relation("AssignedDriver")
|
||||
messages SignalMessage[] // Signal chat messages
|
||||
gpsDevice GpsDevice? // GPS tracking device
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -281,6 +282,9 @@ model PdfSettings {
|
||||
showAppUrl Boolean @default(false)
|
||||
pageSize PageSize @default(LETTER)
|
||||
|
||||
// Timezone for correspondence and display (IANA timezone format)
|
||||
timezone String @default("America/New_York")
|
||||
|
||||
// Content Toggles
|
||||
showFlightInfo Boolean @default(true)
|
||||
showDriverNames Boolean @default(true)
|
||||
@@ -303,3 +307,81 @@ enum PageSize {
|
||||
A4
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GPS Tracking
|
||||
// ============================================
|
||||
|
||||
model GpsDevice {
|
||||
id String @id @default(uuid())
|
||||
driverId String @unique
|
||||
driver Driver @relation(fields: [driverId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Traccar device information
|
||||
traccarDeviceId Int @unique // Traccar's internal device ID
|
||||
deviceIdentifier String @unique // Unique ID for Traccar Client app
|
||||
|
||||
// Privacy & Consent
|
||||
enrolledAt DateTime @default(now())
|
||||
consentGiven Boolean @default(false)
|
||||
consentGivenAt DateTime?
|
||||
lastActive DateTime? // Last location report timestamp
|
||||
|
||||
// Settings
|
||||
isActive Boolean @default(true)
|
||||
|
||||
// Location history
|
||||
locationHistory GpsLocationHistory[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("gps_devices")
|
||||
}
|
||||
|
||||
model GpsLocationHistory {
|
||||
id String @id @default(uuid())
|
||||
deviceId String
|
||||
device GpsDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||
|
||||
latitude Float
|
||||
longitude Float
|
||||
altitude Float?
|
||||
speed Float? // km/h
|
||||
course Float? // Bearing in degrees
|
||||
accuracy Float? // Meters
|
||||
battery Float? // Battery percentage (0-100)
|
||||
|
||||
timestamp DateTime
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("gps_location_history")
|
||||
@@index([deviceId, timestamp])
|
||||
@@index([timestamp]) // For cleanup job
|
||||
}
|
||||
|
||||
model GpsSettings {
|
||||
id String @id @default(uuid())
|
||||
|
||||
// Update frequency (seconds)
|
||||
updateIntervalSeconds Int @default(60)
|
||||
|
||||
// Shift-based tracking (4AM - 1AM next day)
|
||||
shiftStartHour Int @default(4) // 4 AM
|
||||
shiftStartMinute Int @default(0)
|
||||
shiftEndHour Int @default(1) // 1 AM next day
|
||||
shiftEndMinute Int @default(0)
|
||||
|
||||
// Data retention (days)
|
||||
retentionDays Int @default(30)
|
||||
|
||||
// Traccar credentials
|
||||
traccarAdminUser String @default("admin")
|
||||
traccarAdminPassword String? // Encrypted or hashed
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("gps_settings")
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { CopilotModule } from './copilot/copilot.module';
|
||||
import { SignalModule } from './signal/signal.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { SeedModule } from './seed/seed.module';
|
||||
import { GpsModule } from './gps/gps.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
|
||||
@Module({
|
||||
@@ -40,6 +41,7 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
SignalModule,
|
||||
SettingsModule,
|
||||
SeedModule,
|
||||
GpsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
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 {}
|
||||
@@ -71,6 +71,12 @@ export class UpdatePdfSettingsDto {
|
||||
@IsEnum(PageSize)
|
||||
pageSize?: PageSize;
|
||||
|
||||
// Timezone (IANA format, e.g., "America/New_York")
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
timezone?: string;
|
||||
|
||||
// Content Toggles
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -34,6 +34,7 @@ export class SettingsService {
|
||||
contactPhone: '555-0100',
|
||||
contactLabel: 'Questions or Changes?',
|
||||
pageSize: 'LETTER',
|
||||
timezone: 'America/New_York',
|
||||
showDraftWatermark: false,
|
||||
showConfidentialWatermark: false,
|
||||
showTimestamp: true,
|
||||
|
||||
@@ -106,7 +106,12 @@ export class SignalService {
|
||||
/**
|
||||
* Register a new phone number (requires verification)
|
||||
*/
|
||||
async registerNumber(phoneNumber: string, captcha?: string): Promise<{ success: boolean; message: string }> {
|
||||
async registerNumber(phoneNumber: string, captcha?: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
captchaRequired?: boolean;
|
||||
captchaUrl?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await this.client.post(`/v1/register/${phoneNumber}`, {
|
||||
captcha,
|
||||
@@ -118,10 +123,27 @@ export class SignalService {
|
||||
message: 'Verification code sent. Check your phone.',
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Failed to register number:', error.message);
|
||||
const errorMessage = error.response?.data?.error || error.message;
|
||||
this.logger.error('Failed to register number:', errorMessage);
|
||||
|
||||
// Check if CAPTCHA is required
|
||||
const isCaptchaRequired =
|
||||
errorMessage.toLowerCase().includes('captcha') ||
|
||||
error.response?.status === 402; // Signal uses 402 for captcha requirement
|
||||
|
||||
if (isCaptchaRequired) {
|
||||
return {
|
||||
success: false,
|
||||
captchaRequired: true,
|
||||
captchaUrl: 'https://signalcaptchas.org/registration/generate.html',
|
||||
message:
|
||||
'CAPTCHA verification required. Please solve the CAPTCHA and submit the token.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.error || error.message,
|
||||
message: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -151,7 +173,8 @@ export class SignalService {
|
||||
*/
|
||||
async unlinkAccount(phoneNumber: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
await this.client.delete(`/v1/accounts/${phoneNumber}`);
|
||||
// Use POST /v1/unregister/{number} - the correct Signal API endpoint
|
||||
await this.client.post(`/v1/unregister/${phoneNumber}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
Reference in New Issue
Block a user