feat: add PDF reports, timezone management, GPS QR codes, and fix GPS tracking gaps

Issue #1: QR button on GPS Devices tab for re-enrollment
Issue #2: App-wide timezone setting with TimezoneContext, useFormattedDate hook,
  and admin timezone selector. All date displays now respect the configured timezone.
Issue #3: PDF export for Accountability Roster using @react-pdf/renderer with
  professional styling matching VIPSchedulePDF. Added Signal send button.
Issue #4: Fixed GPS "teleporting" gaps - syncPositions now fetches position history
  per device instead of only latest position. Changed cron to every 30s, added
  unique constraint on deviceId+timestamp for deduplication, lowered min interval to 10s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 07:36:51 +01:00
parent 0f0f1cbf38
commit a4d360aae9
33 changed files with 2136 additions and 361 deletions

View File

@@ -0,0 +1,12 @@
-- Delete duplicate rows keeping the first entry (by id) for each deviceId+timestamp pair
DELETE FROM "gps_location_history" a
USING "gps_location_history" b
WHERE a."id" > b."id"
AND a."deviceId" = b."deviceId"
AND a."timestamp" = b."timestamp";
-- Drop the existing index that covered deviceId+timestamp (non-unique)
DROP INDEX IF EXISTS "gps_location_history_deviceId_timestamp_idx";
-- CreateIndex (unique constraint replaces the old non-unique index)
CREATE UNIQUE INDEX "gps_location_history_deviceId_timestamp_key" ON "gps_location_history"("deviceId", "timestamp");

View File

@@ -430,7 +430,7 @@ model GpsLocationHistory {
createdAt DateTime @default(now())
@@map("gps_location_history")
@@index([deviceId, timestamp])
@@unique([deviceId, timestamp]) // Prevent duplicate position records
@@index([timestamp]) // For cleanup job
}

View File

@@ -78,6 +78,15 @@ export class GpsController {
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
*/

View File

@@ -256,6 +256,44 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
};
}
/**
* Get QR code info for an already-enrolled device
*/
async getDeviceQrInfo(driverId: string): Promise<{
driverName: string;
deviceIdentifier: string;
serverUrl: string;
qrCodeUrl: string;
updateIntervalSeconds: number;
}> {
const device = await this.prisma.gpsDevice.findUnique({
where: { driverId },
include: { driver: { select: { id: true, name: true } } },
});
if (!device) {
throw new NotFoundException('Driver is not enrolled for GPS tracking');
}
const settings = await this.getSettings();
const serverUrl = this.traccarClient.getDeviceServerUrl();
const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055;
const traccarPublicUrl = this.traccarClient.getTraccarUrl();
const qrUrl = new URL(traccarPublicUrl);
qrUrl.port = String(devicePort);
qrUrl.searchParams.set('id', device.deviceIdentifier);
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
return {
driverName: device.driver.name,
deviceIdentifier: device.deviceIdentifier,
serverUrl,
qrCodeUrl: qrUrl.toString(),
updateIntervalSeconds: settings.updateIntervalSeconds,
};
}
/**
* Unenroll a driver from GPS tracking
*/
@@ -562,7 +600,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
* Sync positions from Traccar to our database (for history/stats)
* Called periodically via cron job
*/
@Cron(CronExpression.EVERY_MINUTE)
@Cron(CronExpression.EVERY_30_SECONDS)
async syncPositions(): Promise<void> {
const devices = await this.prisma.gpsDevice.findMany({
where: {
@@ -572,36 +610,51 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
if (devices.length === 0) return;
try {
const positions = await this.traccarClient.getAllPositions();
const now = new Date();
for (const device of devices) {
const position = positions.find((p) => p.deviceId === device.traccarDeviceId);
if (!position) continue;
for (const device of devices) {
try {
// Calculate "since" from device's last active time with 5s overlap buffer
// Falls back to 2 minutes ago if no lastActive
const since = device.lastActive
? new Date(device.lastActive.getTime() - 5000)
: new Date(now.getTime() - 120000);
// Update last active timestamp
const positions = await this.traccarClient.getPositionHistory(
device.traccarDeviceId,
since,
now,
);
if (positions.length === 0) continue;
// Batch insert with skipDuplicates (unique constraint on deviceId+timestamp)
await this.prisma.gpsLocationHistory.createMany({
data: positions.map((p) => ({
deviceId: device.id,
latitude: p.latitude,
longitude: p.longitude,
altitude: p.altitude || null,
speed: this.traccarClient.knotsToMph(p.speed || 0),
course: p.course || null,
accuracy: p.accuracy || null,
battery: p.attributes?.batteryLevel || null,
timestamp: new Date(p.deviceTime),
})),
skipDuplicates: true,
});
// Update lastActive to the latest position timestamp
const latestPosition = positions.reduce((latest, p) =>
new Date(p.deviceTime) > new Date(latest.deviceTime) ? p : latest
);
await this.prisma.gpsDevice.update({
where: { id: device.id },
data: { lastActive: new Date(position.deviceTime) },
});
// Store in history
await this.prisma.gpsLocationHistory.create({
data: {
deviceId: device.id,
latitude: position.latitude,
longitude: position.longitude,
altitude: position.altitude || null,
speed: this.traccarClient.knotsToMph(position.speed || 0),
course: position.course || null,
accuracy: position.accuracy || null,
battery: position.attributes?.batteryLevel || null,
timestamp: new Date(position.deviceTime),
},
data: { lastActive: new Date(latestPosition.deviceTime) },
});
} catch (error) {
this.logger.error(`Failed to sync positions for device ${device.traccarDeviceId}: ${error}`);
}
} catch (error) {
this.logger.error(`Failed to sync positions: ${error}`);
}
}

View File

@@ -215,60 +215,181 @@ export class SeedService {
private getFlightData(vips: any[]) {
const flights: any[] = [];
const airlines = ['AA', 'UA', 'DL', 'SW', 'AS', 'JB'];
const origins = ['JFK', 'LAX', 'ORD', 'DFW', 'ATL', 'SFO', 'SEA', 'BOS', 'DEN', 'MIA'];
const destination = 'SLC';
vips.forEach((vip, index) => {
const airline = airlines[index % airlines.length];
const flightNum = `${airline}${1000 + index * 123}`;
const origin = origins[index % origins.length];
// Build a name->vip lookup for named scenarios
const vipByName = new Map<string, any>();
vips.forEach(v => vipByName.set(v.name, v));
// Arrival flight - times relative to now
const arrivalOffset = (index % 8) * 30 - 60;
const scheduledArrival = this.relativeTime(arrivalOffset);
// Helper: create a flight record
const makeFlight = (vipId: string, opts: any) => ({
vipId,
flightDate: new Date(),
...opts,
});
// ============================================================
// NAMED MULTI-SEGMENT SCENARIOS
// ============================================================
// Roger Krone: 3-segment journey, all landed cleanly
// BWI -> ORD -> DEN -> SLC
const krone = vipByName.get('Roger A. Krone');
if (krone) {
flights.push(makeFlight(krone.id, {
segment: 1, flightNumber: 'UA410', departureAirport: 'BWI', arrivalAirport: 'ORD',
scheduledDeparture: this.relativeTime(-360), scheduledArrival: this.relativeTime(-240),
actualDeparture: this.relativeTime(-358), actualArrival: this.relativeTime(-235),
status: 'landed',
}));
flights.push(makeFlight(krone.id, {
segment: 2, flightNumber: 'UA672', departureAirport: 'ORD', arrivalAirport: 'DEN',
scheduledDeparture: this.relativeTime(-150), scheduledArrival: this.relativeTime(-60),
actualDeparture: this.relativeTime(-148), actualArrival: this.relativeTime(-55),
status: 'landed',
}));
flights.push(makeFlight(krone.id, {
segment: 3, flightNumber: 'UA1190', departureAirport: 'DEN', arrivalAirport: destination,
scheduledDeparture: this.relativeTime(-20), scheduledArrival: this.relativeTime(40),
actualDeparture: this.relativeTime(-18), actualArrival: null,
status: 'active',
arrivalTerminal: '2', arrivalGate: 'B7',
}));
}
// Sarah Chen: 2-segment, leg 1 landed, leg 2 active/arriving
// JFK -> ORD -> SLC
const chen = vipByName.get('Sarah Chen');
if (chen) {
flights.push(makeFlight(chen.id, {
segment: 1, flightNumber: 'AA234', departureAirport: 'JFK', arrivalAirport: 'ORD',
scheduledDeparture: this.relativeTime(-300), scheduledArrival: this.relativeTime(-180),
actualDeparture: this.relativeTime(-298), actualArrival: this.relativeTime(-175),
status: 'landed',
}));
flights.push(makeFlight(chen.id, {
segment: 2, flightNumber: 'AA1456', departureAirport: 'ORD', arrivalAirport: destination,
scheduledDeparture: this.relativeTime(-90), scheduledArrival: this.relativeTime(30),
actualDeparture: this.relativeTime(-88), actualArrival: null,
status: 'active',
arrivalTerminal: '1', arrivalGate: 'A12',
}));
}
// Roberto Gonzalez: 2-segment, leg 1 DELAYED 45min - threatens connection
// LAX -> DFW -> SLC (90min layover scheduled, now ~45min WARNING)
const gonzalez = vipByName.get('Roberto Gonzalez');
if (gonzalez) {
flights.push(makeFlight(gonzalez.id, {
segment: 1, flightNumber: 'DL890', departureAirport: 'LAX', arrivalAirport: 'DFW',
scheduledDeparture: this.relativeTime(-240), scheduledArrival: this.relativeTime(-90),
estimatedArrival: this.relativeTime(-45), // 45min late
actualDeparture: this.relativeTime(-195), // departed 45min late
departureDelay: 45, arrivalDelay: 45,
status: 'active', // still in the air
}));
flights.push(makeFlight(gonzalez.id, {
segment: 2, flightNumber: 'DL1522', departureAirport: 'DFW', arrivalAirport: destination,
scheduledDeparture: this.relativeTime(0), scheduledArrival: this.relativeTime(150),
status: 'scheduled',
departureTerminal: 'E', departureGate: 'E14',
}));
}
// Thomas Anderson: 2-segment, MISSED CONNECTION
// BOS -> ORD -> SLC (leg 1 arrived 25min late, leg 2 already departed)
const anderson = vipByName.get('Thomas Anderson');
if (anderson) {
flights.push(makeFlight(anderson.id, {
segment: 1, flightNumber: 'JB320', departureAirport: 'BOS', arrivalAirport: 'ORD',
scheduledDeparture: this.relativeTime(-240), scheduledArrival: this.relativeTime(-90),
actualDeparture: this.relativeTime(-215), actualArrival: this.relativeTime(-65),
departureDelay: 25, arrivalDelay: 25,
status: 'landed',
}));
flights.push(makeFlight(anderson.id, {
segment: 2, flightNumber: 'JB988', departureAirport: 'ORD', arrivalAirport: destination,
scheduledDeparture: this.relativeTime(-75), scheduledArrival: this.relativeTime(15),
actualDeparture: this.relativeTime(-75), // departed on time - before leg 1 landed
status: 'active', // plane left without him
}));
}
// Marcus Johnson: 2-segment, both landed cleanly
// ATL -> DEN -> SLC
const johnson = vipByName.get('Marcus Johnson');
if (johnson) {
flights.push(makeFlight(johnson.id, {
segment: 1, flightNumber: 'DL512', departureAirport: 'ATL', arrivalAirport: 'DEN',
scheduledDeparture: this.relativeTime(-360), scheduledArrival: this.relativeTime(-210),
actualDeparture: this.relativeTime(-358), actualArrival: this.relativeTime(-205),
status: 'landed',
}));
flights.push(makeFlight(johnson.id, {
segment: 2, flightNumber: 'DL1780', departureAirport: 'DEN', arrivalAirport: destination,
scheduledDeparture: this.relativeTime(-120), scheduledArrival: this.relativeTime(-30),
actualDeparture: this.relativeTime(-118), actualArrival: this.relativeTime(-25),
status: 'landed',
arrivalTerminal: '2', arrivalGate: 'C4', arrivalBaggage: '3',
}));
}
// James O'Brien: 2-segment, both scheduled (future)
// DFW -> DEN -> SLC
const obrien = vipByName.get("James O'Brien");
if (obrien) {
flights.push(makeFlight(obrien.id, {
segment: 1, flightNumber: 'UA780', departureAirport: 'DFW', arrivalAirport: 'DEN',
scheduledDeparture: this.relativeTime(60), scheduledArrival: this.relativeTime(180),
status: 'scheduled',
}));
flights.push(makeFlight(obrien.id, {
segment: 2, flightNumber: 'UA1340', departureAirport: 'DEN', arrivalAirport: destination,
scheduledDeparture: this.relativeTime(240), scheduledArrival: this.relativeTime(330),
status: 'scheduled',
}));
}
// ============================================================
// DIRECT FLIGHTS (single segment)
// ============================================================
const directFlights: Array<{ name: string; airline: string; num: string; origin: string; offset: number; statusOverride?: string }> = [
{ name: 'Jennifer Wu', airline: 'AA', num: 'AA1023', origin: 'ORD', offset: 60 },
{ name: 'Priya Sharma', airline: 'UA', num: 'UA567', origin: 'SFO', offset: -15, statusOverride: 'active' },
{ name: 'David Okonkwo', airline: 'DL', num: 'DL1345', origin: 'SEA', offset: 120 },
{ name: 'Yuki Tanaka', airline: 'AA', num: 'AA890', origin: 'LAX', offset: 90 },
{ name: 'Isabella Costa', airline: 'SW', num: 'SW2210', origin: 'MIA', offset: -45, statusOverride: 'active' },
{ name: 'Fatima Al-Rahman', airline: 'AS', num: 'AS440', origin: 'SEA', offset: 180 },
{ name: 'William Zhang', airline: 'DL', num: 'DL1678', origin: 'ATL', offset: -90, statusOverride: 'landed' },
{ name: 'Alexander Volkov', airline: 'UA', num: 'UA2100', origin: 'DEN', offset: 45 },
];
for (const df of directFlights) {
const vip = vipByName.get(df.name);
if (!vip) continue;
const scheduledArrival = this.relativeTime(df.offset);
const scheduledDeparture = new Date(scheduledArrival.getTime() - 3 * 60 * 60 * 1000);
let status = 'scheduled';
let status = df.statusOverride || 'scheduled';
let actualArrival = null;
if (arrivalOffset < -30) {
status = 'landed';
actualArrival = new Date(scheduledArrival.getTime() + (Math.random() * 20 - 10) * 60000);
} else if (arrivalOffset < 0) {
status = 'landing';
} else if (index % 5 === 0) {
status = 'delayed';
if (status === 'landed') {
actualArrival = new Date(scheduledArrival.getTime() + 5 * 60000);
}
flights.push({
vipId: vip.id,
flightNumber: flightNum,
flightDate: new Date(),
flights.push(makeFlight(vip.id, {
segment: 1,
departureAirport: origin,
flightNumber: df.num,
departureAirport: df.origin,
arrivalAirport: destination,
scheduledDeparture,
scheduledArrival,
actualDeparture: status !== 'scheduled' ? scheduledDeparture : null,
actualArrival,
status,
});
// Some VIPs have connecting flights (segment 2)
if (index % 4 === 0) {
const connectOrigin = origins[(index + 3) % origins.length];
flights.push({
vipId: vip.id,
flightNumber: `${airline}${500 + index}`,
flightDate: new Date(),
segment: 2,
departureAirport: connectOrigin,
arrivalAirport: origin,
scheduledDeparture: new Date(scheduledDeparture.getTime() - 4 * 60 * 60 * 1000),
scheduledArrival: new Date(scheduledDeparture.getTime() - 1 * 60 * 60 * 1000),
status: 'landed',
});
}
});
}));
}
return flights;
}

View File

@@ -42,6 +42,23 @@ export class SettingsController {
};
}
/**
* Get app timezone - any authenticated user can read this
*/
@Get('timezone')
getTimezone() {
return this.settingsService.getTimezone();
}
/**
* Update app timezone - admin only
*/
@Patch('timezone')
@CanUpdate('Settings')
updateTimezone(@Body() dto: { timezone: string }) {
return this.settingsService.updateTimezone(dto.timezone);
}
@Get('pdf')
@CanUpdate('Settings') // Admin-only (Settings subject is admin-only)
getPdfSettings() {

View File

@@ -75,6 +75,37 @@ export class SettingsService {
}
}
/**
* Get the app-wide timezone setting
*/
async getTimezone(): Promise<{ timezone: string }> {
const settings = await this.getPdfSettings();
return { timezone: settings.timezone };
}
/**
* Update the app-wide timezone setting
*/
async updateTimezone(timezone: string): Promise<{ timezone: string }> {
this.logger.log(`Updating timezone to: ${timezone}`);
// Validate the timezone string
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone });
} catch {
throw new BadRequestException(`Invalid timezone: ${timezone}`);
}
const existing = await this.getPdfSettings();
await this.prisma.pdfSettings.update({
where: { id: existing.id },
data: { timezone },
});
return { timezone };
}
/**
* Upload logo as base64 data URL
*/