chore: add build artifacts, map assets, and routing data

This commit is contained in:
Hamza-Ayed
2026-09-19 12:34:24 +03:00
parent aa2b9f131f
commit 43ad2e0ad4
327 changed files with 52421 additions and 1810 deletions
@@ -0,0 +1,61 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min, IsArray, ValidateNested } from 'class-validator';
import { Type, Transform } from 'class-transformer';
export class DriverTelemetryDto {
@ApiProperty({ description: 'Driver unique ID', example: 'driver_jo_1042' })
@IsString()
@IsNotEmpty()
@Transform(({ obj, value }) => value ?? obj.driver_id ?? obj.driverId)
driver_id: string;
@ApiProperty({ description: 'Latitude coordinate (-90 to 90)', example: 31.9539 })
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ obj, value }) => Number(value ?? obj.latitude ?? obj.lat))
latitude: number;
@ApiProperty({ description: 'Longitude coordinate (-180 to 180)', example: 35.9106 })
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ obj, value }) => Number(value ?? obj.longitude ?? obj.lng))
longitude: number;
@ApiProperty({ description: 'Instantaneous vehicle speed in km/h', example: 45.5 })
@IsNumber()
@Min(0)
@Transform(({ obj, value }) => Number(value ?? obj.speed ?? 0))
speed: number;
@ApiProperty({ description: 'Compass heading / bearing in degrees (0 - 360)', example: 185.0 })
@IsNumber()
@Min(0)
@Max(360)
@Transform(({ obj, value }) => Number(value ?? obj.heading ?? 0))
heading: number;
@ApiPropertyOptional({ description: 'Distance traveled in meters', example: 1250.4, default: 0 })
@IsOptional()
@IsNumber()
@Transform(({ obj, value }) => (value != null ? Number(value) : (obj.distance != null ? Number(obj.distance) : 0)))
distance?: number;
@ApiPropertyOptional({ description: 'Elevation above mean sea level in meters (AMSL)', example: 890.5, default: 0 })
@IsOptional()
@IsNumber()
@Transform(({ obj, value }) => {
const val = value ?? obj.elevation ?? obj.altitude;
return val != null ? Number(val) : 0;
})
elevation?: number;
}
export class DriverTelemetryBatchDto {
@ApiProperty({ type: [DriverTelemetryDto], description: 'Array of telemetry points for batch processing' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => DriverTelemetryDto)
points: DriverTelemetryDto[];
}
@@ -0,0 +1,66 @@
import { Controller, Post, Get, Body, Query, Param, UseGuards, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery, ApiParam, ApiSecurity } from '@nestjs/swagger';
import { TelemetryService } from './telemetry.service';
import { DriverTelemetryDto, DriverTelemetryBatchDto } from './dto/driver-telemetry.dto';
import { ApiKeyGuard } from '../common/guards/api-key.guard';
@ApiTags('telemetry')
@ApiSecurity('x-api-key')
@Controller('telemetry')
@UseGuards(ApiKeyGuard)
export class TelemetryController {
constructor(private readonly telemetryService: TelemetryService) {}
@Post()
@ApiOperation({
summary: 'Ingest real-time driver telemetry with elevation & distance 📡⛰️',
description: 'Receives GPS position, speed, heading, distance, and AMSL elevation from driver app.',
})
async ingest(@Body() data: DriverTelemetryDto) {
if (!data.driver_id) {
throw new HttpException('Missing driver_id', HttpStatus.BAD_REQUEST);
}
return this.telemetryService.ingest(data);
}
@Post('batch')
@ApiOperation({
summary: 'Batch ingest driver telemetry points 📦',
description: 'Receives an array of telemetry points for offline-buffered sync or high-frequency traces.',
})
async ingestBatch(@Body() body: DriverTelemetryBatchDto) {
if (!body || !Array.isArray(body.points)) {
throw new HttpException('Invalid payload: expected { points: [...] }', HttpStatus.BAD_REQUEST);
}
return this.telemetryService.ingestBatch(body.points);
}
@Get('nearby')
@ApiOperation({ summary: 'Query active drivers within spatial radius with elevation & bearing 🚗' })
@ApiQuery({ name: 'lat', required: true, type: Number, description: 'Center latitude' })
@ApiQuery({ name: 'lng', required: true, type: Number, description: 'Center longitude' })
@ApiQuery({ name: 'radius', required: false, type: Number, description: 'Radius in meters (default: 5000m)' })
async getNearby(
@Query('lat') lat: number,
@Query('lng') lng: number,
@Query('radius') radius?: number,
) {
const latNum = Number(lat);
const lngNum = Number(lng);
if (isNaN(latNum) || isNaN(lngNum)) {
throw new HttpException('lat and lng must be valid numbers', HttpStatus.BAD_REQUEST);
}
return this.telemetryService.getRecentDrivers(latNum, lngNum, radius ? Number(radius) : 5000);
}
@Get('driver/:driverId/profile')
@ApiOperation({ summary: 'Get 3D elevation profile, climb, and terrain grade for a driver 📈' })
@ApiParam({ name: 'driverId', required: true, description: 'Driver unique ID' })
@ApiQuery({ name: 'hours', required: false, description: 'Window in hours (default: 24)' })
async getDriverElevationProfile(
@Param('driverId') driverId: string,
@Query('hours') hours?: number,
) {
return this.telemetryService.getElevationProfile(driverId, hours ? Number(hours) : 24);
}
}
@@ -0,0 +1,46 @@
import { Entity, Column, PrimaryGeneratedColumn, Index, CreateDateColumn } from 'typeorm';
@Entity('telemetry_logs')
@Index(['driverId', 'timestamp'], { unique: false })
export class TelemetryLog {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'driverId' })
@Index()
driverId: string;
@Column('decimal', { precision: 10, scale: 7 })
latitude: number;
@Column('decimal', { precision: 10, scale: 7 })
longitude: number;
@Column('float', { default: 0 })
speed: number;
@Column('float', { default: 0 })
heading: number;
// Cumulative or step distance traveled in meters (المسافة المقطوعة بالمتر)
@Column('float', { default: 0 })
distance: number;
// Elevation above mean sea level in meters (الارتفاع عن مستوى سطح البحر بالمتر AMSL)
@Column('float', { default: 0 })
elevation: number;
@CreateDateColumn({ type: 'timestamp with time zone' })
@Index()
timestamp: Date;
// PostGIS spatial point for ultra-fast spatial and proximity indexing
@Column({
type: 'geography',
spatialFeatureType: 'Point',
srid: 4326,
nullable: true,
})
@Index({ spatial: true })
location: any;
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TelemetryLog } from './telemetry.entity';
import { TelemetryService } from './telemetry.service';
import { TelemetryController } from './telemetry.controller';
import { RedisModule } from '../common/redis.module';
@Module({
imports: [
TypeOrmModule.forFeature([TelemetryLog]),
RedisModule,
],
controllers: [TelemetryController],
providers: [TelemetryService],
exports: [TelemetryService],
})
export class TelemetryModule {}
@@ -0,0 +1,122 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { TelemetryService } from './telemetry.service';
import { TelemetryLog } from './telemetry.entity';
import { RedisService } from '../common/redis.service';
describe('TelemetryService', () => {
let service: TelemetryService;
let mockRepo: any;
let mockDataSource: any;
let mockRedis: any;
beforeEach(async () => {
mockRepo = {
create: jest.fn().mockImplementation((dto) => ({ id: 42, ...dto })),
save: jest.fn().mockImplementation((entity) => Promise.resolve({ id: 42, ...entity })),
};
mockDataSource = {
query: jest.fn().mockResolvedValue([]),
};
mockRedis = {
set: jest.fn().mockResolvedValue(undefined),
get: jest.fn().mockResolvedValue(null),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
TelemetryService,
{
provide: getRepositoryToken(TelemetryLog),
useValue: mockRepo,
},
{
provide: DataSource,
useValue: mockDataSource,
},
{
provide: RedisService,
useValue: mockRedis,
},
],
}).compile();
service = module.get<TelemetryService>(TelemetryService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should ingest telemetry with elevation, distance, speed, and heading', async () => {
const payload = {
driver_id: 'test_driver_77',
latitude: 31.9539,
longitude: 35.9106,
speed: 60.5,
heading: 180.0,
distance: 1450.0,
elevation: 920.4,
};
const result = await service.ingest(payload);
expect(result.success).toBe(true);
expect(result.driver_id).toBe('test_driver_77');
expect(result.elevation).toBe(920.4);
expect(result.distance).toBe(1450.0);
expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
driverId: 'test_driver_77',
latitude: 31.9539,
longitude: 35.9106,
speed: 60.5,
heading: 180.0,
distance: 1450.0,
elevation: 920.4,
}),
);
expect(mockRepo.save).toHaveBeenCalled();
expect(mockRedis.set).toHaveBeenCalledWith(
'fleet:driver:test_driver_77:live',
expect.objectContaining({
driverId: 'test_driver_77',
elevation: 920.4,
distance: 1450.0,
}),
900,
);
});
it('should batch ingest multiple telemetry points with elevation', async () => {
const batch = [
{
driver_id: 'd1',
latitude: 31.95,
longitude: 35.91,
speed: 50,
heading: 90,
distance: 100,
elevation: 900,
},
{
driver_id: 'd2',
latitude: 31.96,
longitude: 35.92,
speed: 55,
heading: 95,
distance: 120,
elevation: 915,
},
];
const res = await service.ingestBatch(batch);
expect(res.success).toBe(true);
expect(res.count).toBe(2);
expect(mockRepo.save).toHaveBeenCalled();
});
});
+278
View File
@@ -0,0 +1,278 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { TelemetryLog } from './telemetry.entity';
import { DriverTelemetryDto } from './dto/driver-telemetry.dto';
import { RedisService } from '../common/redis.service';
@Injectable()
export class TelemetryService implements OnModuleInit {
private readonly logger = new Logger(TelemetryService.name);
constructor(
@InjectRepository(TelemetryLog)
private readonly telemetryRepo: Repository<TelemetryLog>,
private readonly dataSource: DataSource,
private readonly redisService: RedisService,
) {}
async onModuleInit() {
try {
// Ensure PostGIS extension and telemetry_logs table columns exist
await this.dataSource.query(`
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE IF NOT EXISTS telemetry_logs (
id SERIAL PRIMARY KEY,
"driverId" VARCHAR(255) NOT NULL,
latitude NUMERIC(10, 7) NOT NULL,
longitude NUMERIC(10, 7) NOT NULL,
speed FLOAT NOT NULL DEFAULT 0,
heading FLOAT NOT NULL DEFAULT 0,
distance FLOAT DEFAULT 0,
elevation FLOAT DEFAULT 0,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
location GEOGRAPHY(Point, 4326)
);
ALTER TABLE telemetry_logs ADD COLUMN IF NOT EXISTS distance FLOAT DEFAULT 0;
ALTER TABLE telemetry_logs ADD COLUMN IF NOT EXISTS elevation FLOAT DEFAULT 0;
CREATE INDEX IF NOT EXISTS telemetry_logs_driver_idx ON telemetry_logs ("driverId");
CREATE INDEX IF NOT EXISTS telemetry_logs_timestamp_idx ON telemetry_logs (timestamp DESC);
CREATE INDEX IF NOT EXISTS telemetry_logs_location_idx ON telemetry_logs USING GIST (location);
`);
this.logger.log('✅ Telemetry schema verified: elevation and distance columns ready.');
} catch (err: any) {
this.logger.warn(`Telemetry DB auto-migration check note: ${err.message}`);
}
}
/**
* Ingest a single driver telemetry record including elevation & distance
*/
async ingest(data: DriverTelemetryDto) {
const lat = Number(data.latitude);
const lng = Number(data.longitude);
const speed = Number(data.speed || 0);
const heading = Number(data.heading || 0);
const distance = Number(data.distance || 0);
const elevation = Number(data.elevation || 0);
const log = this.telemetryRepo.create({
driverId: data.driver_id,
latitude: lat,
longitude: lng,
speed,
heading,
distance,
elevation,
timestamp: new Date(),
location: {
type: 'Point',
coordinates: [lng, lat],
},
});
const saved = await this.telemetryRepo.save(log);
// Fast memory caching in Redis for real-time fleet queries (TTL 15 minutes)
try {
await this.redisService.set(
`fleet:driver:${data.driver_id}:live`,
{
driverId: data.driver_id,
latitude: lat,
longitude: lng,
speed,
heading,
distance,
elevation,
updatedAt: new Date().toISOString(),
},
900,
);
} catch (_) {
// Redis failover - non-blocking
}
return {
success: true,
id: saved.id,
driver_id: data.driver_id,
elevation,
distance,
timestamp: saved.timestamp,
};
}
/**
* Batch ingest multiple telemetry points
*/
async ingestBatch(points: DriverTelemetryDto[]) {
if (!points || points.length === 0) {
return { success: true, count: 0 };
}
const entities = points.map((p) => {
const lat = Number(p.latitude);
const lng = Number(p.longitude);
const speed = Number(p.speed || 0);
const heading = Number(p.heading || 0);
const distance = Number(p.distance || 0);
const elevation = Number(p.elevation || 0);
return this.telemetryRepo.create({
driverId: p.driver_id,
latitude: lat,
longitude: lng,
speed,
heading,
distance,
elevation,
timestamp: new Date(),
location: {
type: 'Point',
coordinates: [lng, lat],
},
});
});
await this.telemetryRepo.save(entities);
// Update Redis cache for the latest point of each driver
try {
const latestByDriver = new Map<string, DriverTelemetryDto>();
for (const p of points) {
latestByDriver.set(p.driver_id, p);
}
for (const [dId, p] of latestByDriver.entries()) {
await this.redisService.set(
`fleet:driver:${dId}:live`,
{
driverId: dId,
latitude: Number(p.latitude),
longitude: Number(p.longitude),
speed: Number(p.speed || 0),
heading: Number(p.heading || 0),
distance: Number(p.distance || 0),
elevation: Number(p.elevation || 0),
updatedAt: new Date().toISOString(),
},
900,
);
}
} catch (_) {}
return {
success: true,
count: entities.length,
timestamp: new Date(),
};
}
/**
* Find nearby active drivers using PostGIS spatial geography search
*/
async getRecentDrivers(lat: number, lng: number, radiusMeters: number = 5000) {
const rows = await this.dataSource.query(
`SELECT DISTINCT ON ("driverId")
id, "driverId", latitude, longitude, speed, heading, distance, elevation, timestamp,
ST_Distance(location, ST_MakePoint($1, $2)::geography) as distance_to_center_meters
FROM telemetry_logs
WHERE ST_DWithin(location, ST_MakePoint($1, $2)::geography, $3)
AND timestamp >= NOW() - INTERVAL '4 hours'
ORDER BY "driverId", timestamp DESC
LIMIT 100`,
[lng, lat, radiusMeters],
);
return rows.map((r: any) => ({
driver_id: r.driverId,
latitude: parseFloat(r.latitude),
longitude: parseFloat(r.longitude),
speed: parseFloat(r.speed),
heading: parseFloat(r.heading),
distance: parseFloat(r.distance || 0),
elevation: parseFloat(r.elevation || 0),
timestamp: r.timestamp,
distance_to_center_meters: parseFloat(r.distance_to_center_meters),
}));
}
/**
* Calculate 3D elevation profile and vertical gradient for a specific driver
*/
async getElevationProfile(driverId: string, hours: number = 24) {
const points = await this.dataSource.query(
`SELECT latitude, longitude, speed, heading, distance, elevation, timestamp
FROM telemetry_logs
WHERE "driverId" = $1
AND timestamp >= NOW() - ($2 || ' hours')::interval
ORDER BY timestamp ASC`,
[driverId, hours],
);
if (points.length === 0) {
return {
driver_id: driverId,
hours,
pointsCount: 0,
minElevation: 0,
maxElevation: 0,
avgElevation: 0,
totalClimbMeters: 0,
totalDescentMeters: 0,
maxGradePercent: 0,
points: [],
};
}
let minElev = points[0].elevation || 0;
let maxElev = points[0].elevation || 0;
let sumElev = 0;
let totalClimb = 0;
let totalDescent = 0;
let maxGrade = 0;
for (let i = 0; i < points.length; i++) {
const elev = parseFloat(points[i].elevation || '0');
sumElev += elev;
if (elev < minElev) minElev = elev;
if (elev > maxElev) maxElev = elev;
if (i > 0) {
const prevElev = parseFloat(points[i - 1].elevation || '0');
const diff = elev - prevElev;
if (diff > 0) totalClimb += diff;
if (diff < 0) totalDescent += Math.abs(diff);
// Approximate grade percent if distance step is available
const stepDist = parseFloat(points[i].distance || '0') - parseFloat(points[i - 1].distance || '0');
if (stepDist > 10) {
const grade = (Math.abs(diff) / stepDist) * 100;
if (grade > maxGrade && grade < 50) {
maxGrade = grade;
}
}
}
}
return {
driver_id: driverId,
hours,
pointsCount: points.length,
minElevation: Math.round(minElev * 10) / 10,
maxElevation: Math.round(maxElev * 10) / 10,
avgElevation: Math.round((sumElev / points.length) * 10) / 10,
totalClimbMeters: Math.round(totalClimb * 10) / 10,
totalDescentMeters: Math.round(totalDescent * 10) / 10,
maxGradePercent: Math.round(maxGrade * 10) / 10,
recentPoints: points.slice(-30).map((p: any) => ({
latitude: parseFloat(p.latitude),
longitude: parseFloat(p.longitude),
speed: parseFloat(p.speed),
heading: parseFloat(p.heading),
elevation: parseFloat(p.elevation),
timestamp: p.timestamp,
})),
};
}
}