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

@@ -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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"

View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "pdf_settings" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'America/New_York';

View File

@@ -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;

View File

@@ -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")
}

View File

@@ -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: [

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 {}

View File

@@ -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()

View File

@@ -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,

View File

@@ -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,