chore: add build artifacts, map assets, and routing data
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 || '');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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 {}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user