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
+2
View File
@@ -12,6 +12,7 @@ import { BillingModule } from './billing/billing.module';
import { MailModule } from './common/mail.module';
import { WeatherModule } from './weather/weather.module';
import { TacticalModule } from './tactical/tactical.module';
import { TelemetryModule } from './telemetry/telemetry.module';
import { UsageInterceptor } from './usage/usage.interceptor';
@Module({
@@ -42,6 +43,7 @@ import { UsageInterceptor } from './usage/usage.interceptor';
MailModule,
WeatherModule,
TacticalModule,
TelemetryModule,
],
controllers: [],
providers: [
@@ -62,6 +62,6 @@ export abstract class BasePlace {
@Index()
neighborhood_id: number;
@Column({ type: 'int', nullable: true })
@Column({ type: 'int', default: 0, nullable: true })
elevation_meters: number;
}
@@ -30,7 +30,8 @@ export class GeocodingInitService implements OnModuleInit {
for (const table of tables) {
await this.repo.query(`
ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS elevation_meters INT;
ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS elevation_meters INT DEFAULT 0;
UPDATE ${table} SET elevation_meters = 0 WHERE elevation_meters IS NULL;
`);
for (const mapping of columnMapping) {
await this.repo.query(`
@@ -103,7 +104,44 @@ export class GeocodingInitService implements OnModuleInit {
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_egypt_names_trgm ON places_egypt USING gist (name_ar gist_trgm_ops, name_en gist_trgm_ops);');
await this.repo.query('CREATE INDEX IF NOT EXISTS idx_places_iraq_names_trgm ON places_iraq USING gist (name_ar gist_trgm_ops, name_en gist_trgm_ops);');
this.logger.log('Geocoding database triggers and optimized indexes initialized for Syria, Jordan, Egypt, and Iraq.');
// 6. Tactical Terrain Obstacles Table & Auto-Population
await this.repo.query(`
CREATE TABLE IF NOT EXISTS tactical_terrain_obstacles (
id SERIAL PRIMARY KEY,
osm_id BIGINT,
obstacle_type VARCHAR(64),
severity VARCHAR(32),
name VARCHAR(255),
geometry GEOMETRY(Geometry, 4326),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_tactical_obs_geom ON tactical_terrain_obstacles USING gist (geometry);
CREATE INDEX IF NOT EXISTS idx_tactical_obs_type ON tactical_terrain_obstacles (obstacle_type);
DO $$
BEGIN
IF to_regclass('public.planet_osm_line') IS NOT NULL THEN
INSERT INTO tactical_terrain_obstacles (osm_id, obstacle_type, severity, name, geometry)
SELECT
osm_id,
COALESCE(natural, barrier, man_made, waterway) AS obstacle_type,
CASE
WHEN natural = 'cliff' THEN 'SEVERE_NO_GO'
WHEN barrier = 'retaining_wall' THEN 'RESTRICTED'
WHEN barrier IN ('ditch', 'berm') THEN 'TACTICAL_BARRIER'
WHEN waterway = 'wadi' THEN 'DRAINAGE_DEFILE'
ELSE 'OBSTACLE'
END,
name,
geometry
FROM planet_osm_line
WHERE (natural IN ('cliff', 'ridge', 'arete') OR barrier IN ('retaining_wall', 'berm', 'ditch') OR waterway IN ('wadi', 'waterfall'))
ON CONFLICT DO NOTHING;
END IF;
END $$;
`);
this.logger.log('Geocoding database triggers, indexes, and tactical obstacles initialized.');
} catch (err) {
this.logger.error('Failed to initialize database geocoding triggers:', err);
}
+13 -12
View File
@@ -126,8 +126,8 @@ export class GeocodingService {
WHERE normalized_name % $1
${locationCondition}
${regionCondition}
ORDER BY (normalized_name <-> $1) ASC
LIMIT 50
ORDER BY ${hasLocation ? 'distance ASC, (normalized_name <-> $1) ASC' : '(normalized_name <-> $1) ASC'}
LIMIT 60
`;
const allResults = await Promise.race([
@@ -219,18 +219,19 @@ export class GeocodingService {
return results
.map(r => {
// Weighted scoring:
// 50% Text Match (relevance)
// 30% Popularity
// 20% Geographic Proximity
const textScore = Number(r.relevance);
const textScore = Number(r.relevance) || 0;
const popularityScore = (r.popularity_score || 10) / maxPopularity;
// Proximity bonus is 1.0 at 0m, decaying linearly to 0.0 at 10km.
const proximityBonus = hasLocation ? Math.max(0, 1 - (Number(r.distance) / 10000)) : 0;
// Proximity score: steep inverse decay so closer points get massive boost
// e.g. at 200m -> 0.91, 1km -> 0.67, 5km -> 0.28, 20km -> 0.09
const distKm = hasLocation ? (Number(r.distance) / 1000) : 0;
const proximityScore = hasLocation ? (1.0 / (1.0 + distKm * 0.5)) : 0;
const totalScore = (textScore * 0.5) + (popularityScore * 0.3) + (proximityBonus * 0.2);
// When location is available, proximity is heavily prioritized (60%)
const totalScore = hasLocation
? (proximityScore * 0.60) + (textScore * 0.30) + (popularityScore * 0.10)
: (textScore * 0.65) + (popularityScore * 0.35);
return { ...r, totalScore };
})
.sort((a, b) => b.totalScore - a.totalScore)
@@ -242,7 +243,7 @@ export class GeocodingService {
}
return true;
})
.slice(0, 4)
.slice(0, 20)
.map(r => {
const addressParts = [r.neighbourhood, r.district, r.governorate].filter(Boolean);
const full_address = addressParts.length > 0 ? addressParts.join('، ') : (r.address || '');
+47 -9
View File
@@ -38,8 +38,12 @@ export class MapsController {
@ApiOperation({ summary: 'Get MapLibre style JSON 🎨' })
async getStyleJson(@Query('theme') theme: string, @Res() res: Response) {
// Determine filenames based on theme
const isDark = theme === 'obsidian';
const filename = isDark ? 'style-dark.json' : 'style.json';
let filename = 'style.json';
if (theme === 'obsidian') {
filename = 'style-dark.json';
} else if (theme === 'satellite') {
filename = 'style-satellite.json';
}
const fallbackFilename = 'style.json';
// Paths to check
@@ -47,7 +51,7 @@ export class MapsController {
path.join('/data', filename),
path.join(process.cwd(), '../../', filename),
path.join(process.cwd(), filename),
// Fallbacks to light style if dark is missing
// Fallbacks to light style if specific theme is missing
path.join('/data', fallbackFilename),
path.join(process.cwd(), '../../', fallbackFilename),
path.join(process.cwd(), fallbackFilename),
@@ -69,19 +73,53 @@ export class MapsController {
const styleRaw = fs.readFileSync(stylePath, 'utf8');
const styleObj = JSON.parse(styleRaw);
// Dynamic Theme support (Safety overrides or fine-tuning)
// Dynamic Theme support
if (theme === 'light') {
styleObj.layers.forEach((layer: any) => {
if (layer.id === 'background') {
if (layer.id === 'background' && layer.paint) {
layer.paint['background-color'] = '#FFFFFF';
}
});
} else if (theme === 'obsidian') {
// If we found style-dark.json, we don't strictly need this,
// but keeping it as a helper or if it fell back to style.json
styleObj.layers.forEach((layer: any) => {
if (layer.id === 'background') {
layer.paint['background-color'] = '#101014'; // Dark tone
if (layer.id === 'background' && layer.paint) {
layer.paint['background-color'] = '#101014';
}
});
} else if (theme === 'satellite') {
// Ensure ESRI Satellite layer is injected if not already present
if (!styleObj.sources['esri-satellite']) {
styleObj.sources['esri-satellite'] = {
type: 'raster',
tiles: [
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
],
tileSize: 256,
maxzoom: 19,
attribution: '© Esri, Maxar, Earthstar Geographics',
};
}
if (!styleObj.layers.some((l: any) => l.id === 'esri-satellite-imagery')) {
const satLayer = {
id: 'esri-satellite-imagery',
type: 'raster',
source: 'esri-satellite',
minzoom: 0,
maxzoom: 19,
paint: { 'raster-opacity': 1.0 },
};
const bgIdx = styleObj.layers.findIndex((l: any) => l.id === 'background');
if (bgIdx >= 0) {
styleObj.layers.splice(bgIdx + 1, 0, satLayer);
} else {
styleObj.layers.unshift(satLayer);
}
}
styleObj.layers.forEach((l: any) => {
if (l.id === 'background' && l.paint) {
l.paint['background-color'] = '#000000';
} else if ((l.id.includes('landuse') || l.id.includes('poly')) && l.type === 'fill' && l.paint) {
l.paint['fill-opacity'] = 0.05;
}
});
}
+3 -1
View File
@@ -65,9 +65,11 @@ export class MapsService {
console.warn('Geocoding internal error during routing:', e);
}
const ghProfile = ['car', 'foot', 'bike'].includes(profile) ? profile : 'car';
const payload: any = {
points: ghPoints,
profile: profile,
profile: ghProfile,
locale: locale === 'en' ? 'ar' : locale, // Default to Arabic if not specified or fallback
calc_points: true,
points_encoded: false, // JSON arrays for reliable 3D elevation (SRTM)
@@ -0,0 +1,82 @@
import * as fs from 'fs';
import * as path from 'path';
import { Logger } from '@nestjs/common';
import { HttpException, HttpStatus } from '@nestjs/common';
export interface RoutingPackageManifest {
packageId: string;
version: string;
fileName: string;
sizeBytes: number;
sha256: string;
engine: string;
elevation: string;
bbox?: Record<string, number>;
builtAt: string;
}
/**
* Serves the on-device Valhalla routing package (Jordan) built by
* infrastructure/scripts/build-valhalla-tiles.sh. The tactical app downloads
* this package once and routes fully offline against the real road network
* with SRTM elevation — same data the server-side GraphHopper engine uses.
*/
export class RoutingPackageService {
private static readonly logger = new Logger(RoutingPackageService.name);
private static readonly PACKAGE_DIR =
process.env.ROUTING_PACKAGE_DIR ||
(fs.existsSync('/data/infrastructure/osm-data/routing-packages')
? '/data/infrastructure/osm-data/routing-packages'
: path.join(process.cwd(), 'infrastructure/osm-data/routing-packages'));
getDirectory(): string {
return RoutingPackageService.PACKAGE_DIR;
}
/**
* Read jordan-routing-manifest.json written by the tile builder.
* Returns null when no package has been built yet.
*/
getManifest(): RoutingPackageManifest | null {
const manifestPath = path.join(RoutingPackageService.PACKAGE_DIR, 'jordan-routing-manifest.json');
try {
if (!fs.existsSync(manifestPath)) return null;
const raw = fs.readFileSync(manifestPath, 'utf8');
const manifest = JSON.parse(raw) as RoutingPackageManifest;
// Verify the archive actually exists next to the manifest.
const filePath = this.getPackageFilePath(manifest);
if (!fs.existsSync(filePath)) return null;
return manifest;
} catch (e) {
RoutingPackageService.logger.warn(`Failed to read routing manifest: ${e}`);
return null;
}
}
/**
* Require the manifest + file or throw 404 — used before streaming.
*/
requireManifest(): RoutingPackageManifest {
const manifest = this.getManifest();
if (!manifest) {
throw new HttpException(
'Routing package not available. Run infrastructure/scripts/build-valhalla-tiles.sh on the server.',
HttpStatus.NOT_FOUND,
);
}
return manifest;
}
getPackageFilePath(manifest: RoutingPackageManifest): string {
// Never trust fileName blindly: only allow plain names inside the package dir.
const safeName = path.basename(manifest.fileName || '');
return path.join(RoutingPackageService.PACKAGE_DIR, safeName);
}
createPackageStream(manifest: RoutingPackageManifest): fs.ReadStream {
const filePath = this.getPackageFilePath(manifest);
return fs.createReadStream(filePath);
}
}
+70 -1
View File
@@ -18,6 +18,7 @@ import { LineOfSightBodyDto, LineOfSightQueryDto } from './dto/line-of-sight.dto
import { ArtilleryMissionRequestDto, SaveScenarioDto } from './dto/tactical.dto';
import { TacticalService } from './tactical.service';
import { DemTileService } from './dem-tile.service';
import { RoutingPackageService } from './routing-package.service';
@ApiTags('tactical')
@ApiHeader({
@@ -28,7 +29,10 @@ import { DemTileService } from './dem-tile.service';
@Controller('tactical')
@UseGuards(ApiKeyGuard, TenantThrottlerGuard)
export class TacticalController {
constructor(private readonly tacticalService: TacticalService) { }
constructor(
private readonly tacticalService: TacticalService,
private readonly routingPackageService: RoutingPackageService,
) { }
@Get('verify-license')
@ApiOperation({ summary: 'Verify tactical clearance and military license' })
@@ -225,6 +229,42 @@ export class TacticalController {
return this.tacticalService.getOfflinePackageInfo();
}
@Get('routing-package/jordan/manifest')
@ApiOperation({
summary: 'Manifest of the on-device Valhalla routing package (version, sha256, size)',
})
getRoutingPackageManifest() {
const manifest = this.routingPackageService.getManifest();
if (!manifest) {
return {
available: false,
message:
'Routing package not built yet. Run infrastructure/scripts/build-valhalla-tiles.sh on the server.',
};
}
return { available: true, ...manifest };
}
@Get('routing-package/jordan')
@ApiOperation({
summary:
'Download the Jordan Valhalla routing tar (real road graph + SRTM elevation) for 100% offline on-device routing',
})
async downloadRoutingPackage(@Res() res: any) {
const manifest = this.routingPackageService.requireManifest();
const stream = this.routingPackageService.createPackageStream(manifest);
res.setHeader('Content-Type', 'application/x-tar');
res.setHeader('Content-Length', manifest.sizeBytes);
res.setHeader(
'Content-Disposition',
`attachment; filename="${manifest.fileName}"`,
);
res.setHeader('X-Package-Version', manifest.version);
res.setHeader('X-Package-Sha256', manifest.sha256);
stream.pipe(res);
}
@Get('landmarks')
@ApiOperation({
summary: 'Get tactical strategic landmarks / استرجاع معالم الأردن البصرية والاستراتيجية للتقاطع الميداني',
@@ -236,6 +276,24 @@ export class TacticalController {
return this.tacticalService.getLandmarks(region, type);
}
@Get('ipb/obstacles')
@ApiOperation({
summary: 'Query Tactical IPB Obstacles by Bounding Box / استعلام الموانع التكتيكية ضمن نطاق جغرافي',
})
async getIPBObstacles(
@Query('minLat') minLatStr: string,
@Query('minLng') minLngStr: string,
@Query('maxLat') maxLatStr: string,
@Query('maxLng') maxLngStr: string,
) {
const minLat = parseFloat(minLatStr) || 31.0;
const minLng = parseFloat(minLngStr) || 35.0;
const maxLat = parseFloat(maxLatStr) || 33.0;
const maxLng = parseFloat(maxLngStr) || 37.0;
return this.tacticalService.getIPBObstacles({ minLat, minLng, maxLat, maxLng });
}
@Get('dem/:zoom/:x/:y')
@ApiOperation({
summary: 'Stream Sovereign Real Satellite DEM Elevation Tile / تقديم بلاطات الارتفاعات السيادية من السيرفر المحلي',
@@ -257,4 +315,15 @@ export class TacticalController {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.send(buffer);
}
@Post('ai-assessment')
@ApiOperation({
summary: 'Generate Advanced AI Tactical Assessment using Gemini 1.5 Pro based on comprehensive IPB and Terrain data',
})
async generateAIAssessment(@Body() body: { ipbData: any; terrainData: any }) {
if (!body.ipbData || !body.terrainData) {
throw new HttpException('Missing required tactical data (ipbData, terrainData)', HttpStatus.BAD_REQUEST);
}
return this.tacticalService.generateTacticalAIAssessment(body.ipbData, body.terrainData);
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TacticalController } from './tactical.controller';
import { TacticalService } from './tactical.service';
import { RoutingPackageService } from './routing-package.service';
import { RedisModule } from '../common/redis.module';
import { PlaceJordan } from '../geocoding/entities/place-jordan.entity';
@@ -11,7 +12,7 @@ import { PlaceJordan } from '../geocoding/entities/place-jordan.entity';
TypeOrmModule.forFeature([PlaceJordan]),
],
controllers: [TacticalController],
providers: [TacticalService],
providers: [TacticalService, RoutingPackageService],
exports: [TacticalService],
})
export class TacticalModule {}
+170
View File
@@ -5,6 +5,7 @@ import { RedisService } from '../common/redis.service';
import { ArtilleryMissionRequestDto, TacticalSymbolDto } from './dto/tactical.dto';
import { getElevationMeters } from '../common/gis.utils';
import { DemTileService } from './dem-tile.service';
import { RoutingPackageService } from './routing-package.service';
export interface LosPoint {
index: number;
@@ -82,6 +83,7 @@ export class TacticalService {
@InjectRepository(PlaceJordan)
private readonly placeJordanRepo: Repository<PlaceJordan>,
private readonly dataSource: DataSource,
private readonly routingPackageService: RoutingPackageService,
) {
this.graphHopperUrl = this.configService.get('GRAPH_HOPPER_URL', 'http://routing:8080');
}
@@ -1022,6 +1024,9 @@ export class TacticalService {
}
} catch (_) {}
// Real on-device routing package status (Valhalla graph built from OSM + SRTM)
const routingManifest = this.routingPackageService?.getManifest?.() ?? null;
return {
packageId: 'jordan-tactical-offline-v2',
name: 'حزمة الأردن التكتيكية الميدانية الكاملة (Off-Grid Sovereign Package)',
@@ -1033,6 +1038,21 @@ export class TacticalService {
sizeFormatted: '825 KB (خفيفة جداً وسريعة التحميل)',
offlineRoutingReady: true,
offlineResectionReady: true,
routingPackage: routingManifest
? {
available: true,
packageId: routingManifest.packageId,
version: routingManifest.version,
fileName: routingManifest.fileName,
sizeBytes: routingManifest.sizeBytes,
sha256: routingManifest.sha256,
engine: routingManifest.engine,
elevation: routingManifest.elevation,
downloadUrl: '/api/tactical/routing-package/jordan',
manifestUrl: '/api/tactical/routing-package/jordan/manifest',
builtAt: routingManifest.builtAt,
}
: { available: false },
lastUpdated: new Date().toISOString()
};
}
@@ -1150,4 +1170,154 @@ export class TacticalService {
timestamp: new Date().toISOString()
};
}
/**
* Get Tactical IPB Obstacles within Bounding Box
*/
async getIPBObstacles(bbox: {
minLat: number;
minLng: number;
maxLat: number;
maxLng: number;
}) {
const { minLat, minLng, maxLat, maxLng } = bbox;
try {
// 1. Query pre-computed / merged tactical_terrain_obstacles table if it exists
const tableCheck = await this.dataSource.query(`
SELECT to_regclass('public.tactical_terrain_obstacles') as exists;
`);
let features: any[] = [];
if (tableCheck?.[0]?.exists) {
const rows = await this.dataSource.query(
`
SELECT
id,
obstacle_type,
severity,
name,
ST_AsGeoJSON(geometry)::json as geojson
FROM tactical_terrain_obstacles
WHERE geometry && ST_MakeEnvelope($1, $2, $3, $4, 4326)
LIMIT 500;
`,
[minLng, minLat, maxLng, maxLat],
);
features = rows.map((r: any) => ({
type: 'Feature',
properties: {
id: r.id,
obstacleType: r.obstacle_type,
severity: r.severity,
name: r.name,
},
geometry: r.geojson,
}));
}
// 2. If tactical_terrain_obstacles was empty, query planet_osm_line directly as fallback
if (features.length === 0) {
const osmCheck = await this.dataSource.query(`
SELECT to_regclass('public.planet_osm_line') as exists;
`);
if (osmCheck?.[0]?.exists) {
const rows = await this.dataSource.query(
`
SELECT
osm_id as id,
COALESCE(natural, barrier, man_made, waterway) AS obstacle_type,
CASE
WHEN natural = 'cliff' THEN 'SEVERE_NO_GO'
WHEN barrier = 'retaining_wall' THEN 'RESTRICTED'
WHEN barrier IN ('ditch', 'berm') THEN 'TACTICAL_BARRIER'
WHEN waterway = 'wadi' THEN 'DRAINAGE_DEFILE'
ELSE 'OBSTACLE'
END as severity,
name,
ST_AsGeoJSON(geometry)::json as geojson
FROM planet_osm_line
WHERE geometry && ST_MakeEnvelope($1, $2, $3, $4, 4326)
AND (natural IN ('cliff', 'ridge', 'arete') OR barrier IN ('retaining_wall', 'berm', 'ditch') OR waterway IN ('wadi', 'waterfall'))
LIMIT 500;
`,
[minLng, minLat, maxLng, maxLat],
);
features = rows.map((r: any) => ({
type: 'Feature',
properties: {
id: r.id,
obstacleType: r.obstacle_type,
severity: r.severity,
name: r.name,
},
geometry: r.geojson,
}));
}
}
return {
type: 'FeatureCollection',
count: features.length,
features,
};
} catch (err: any) {
this.logger.error(`Error querying IPB obstacles: ${err?.message}`);
return {
type: 'FeatureCollection',
count: 0,
features: [],
};
}
}
async generateTacticalAIAssessment(ipbData: any, terrainData: any): Promise<any> {
const geminiKey = this.configService.get<string>('GEMINI_API_KEY');
if (!geminiKey) {
throw new Error('GEMINI_API_KEY is not configured on the server.');
}
const prompt = `أنت ضابط ركن استخبارات عسكرية (G2) ومحلل تكتيكي استراتيجي خبير.
الرجاء دراسة التقرير التكتيكي المرفق والذي يحتوي على تقدير موقف الاستخبارات عن الأرض (IPB)، الموانع الطبيعية، المقاطع الصخرية، مناطق السكن، والارتفاعات.
المعطيات:
بيانات دراسة الأرض والتضاريس:
${JSON.stringify(terrainData, null, 2)}
بيانات الشفافات التكتيكية (IPB):
${JSON.stringify(ipbData, null, 2)}
المطلوب:
بناءً على الأرقام الدقيقة والموقع الجغرافي المعطى، قدم تحليلاً استراتيجياً مفصلاً يشمل:
1. التهديدات والفرص التعبوية بناءً على التضاريس.
2. أفضل محاور التقدم ومناطق التقتيل (Engagement Areas/Kill Zones).
3. تقييم الموانع وتأثيرها على حركة الدروع والمشاة الآلية.
4. توصيات لتموضع القوات الصديقة (احتياط، مدفعية، رصد).
الرجاء كتابة التقرير بلغة عسكرية احترافية وواضحة (باللغة العربية). لا تقم باختراع أرقام، اعتمد كلياً على البيانات المرفقة.`;
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.7-flash:generateContent?key=${geminiKey}`;
try {
this.logger.log('Sending comprehensive tactical data to Gemini 3.7 Flash for analysis...');
const response = await axios.post(
url,
{
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: 0.2,
}
},
{ timeout: 35000 }
);
const content = response.data?.candidates?.[0]?.content?.parts?.[0]?.text;
return { success: true, assessment: content };
} catch (err: any) {
this.logger.error(`Failed to generate AI assessment: ${err.message}`);
throw new Error('Failed to generate tactical AI assessment.');
}
}
}
@@ -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,
})),
};
}
}