feat: add location intelligence engine and E2E simulation script
This commit is contained in:
Generated
+115
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"name": "tripz-e2e-tester",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "tripz-e2e-tester",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"socket.io-client": "^4.7.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-client": {
|
||||||
|
"version": "6.6.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
|
||||||
|
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.21.0",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-client": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
|
||||||
|
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||||
|
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "tripz-e2e-tester",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"socket.io-client": "^4.7.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import readline from 'readline';
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
|
const API_URL = process.env.API_URL || 'http://localhost:4010';
|
||||||
|
const TENANT_ID = 'tripz'; // Change this if you have a different slug
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
});
|
||||||
|
|
||||||
|
const ask = (query) => new Promise((resolve) => rl.question(query, resolve));
|
||||||
|
|
||||||
|
// --- API Helpers ---
|
||||||
|
async function api(method, path, body = null, token = null) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-tenant-id': TENANT_ID,
|
||||||
|
'x-device-id': 'simulated-device-123'
|
||||||
|
};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const options = { method, headers };
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}${path}`, options);
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`\x1b[31m[API Error] ${method} ${path} -> ${res.status}\x1b[0m`, data);
|
||||||
|
throw new Error(`API Error: ${res.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Simulation ---
|
||||||
|
async function run() {
|
||||||
|
console.log('\n\x1b[36m==========================================');
|
||||||
|
console.log('🚀 Tripz Backend Live Simulation Engine');
|
||||||
|
console.log('==========================================\x1b[0m\n');
|
||||||
|
|
||||||
|
// 1. Authentication
|
||||||
|
const driverPhone = await ask('📞 Enter Driver Phone Number (e.g. +962791234567): ');
|
||||||
|
console.log(`[Driver] Requesting OTP for ${driverPhone}...`);
|
||||||
|
await api('POST', '/auth/send-otp', { phone: driverPhone });
|
||||||
|
|
||||||
|
const driverOtp = await ask(`🔑 Enter OTP received by Driver ${driverPhone}: `);
|
||||||
|
const driverAuth = await api('POST', '/auth/verify-otp', { phone: driverPhone, code: driverOtp });
|
||||||
|
const driverToken = driverAuth.access_token;
|
||||||
|
console.log('\x1b[32m[Driver] Authenticated successfully!\x1b[0m');
|
||||||
|
|
||||||
|
const riderPhone = await ask('\n📞 Enter Rider Phone Number (e.g. +962781234567): ');
|
||||||
|
console.log(`[Rider] Requesting OTP for ${riderPhone}...`);
|
||||||
|
await api('POST', '/auth/send-otp', { phone: riderPhone });
|
||||||
|
|
||||||
|
const riderOtp = await ask(`🔑 Enter OTP received by Rider ${riderPhone}: `);
|
||||||
|
const riderAuth = await api('POST', '/auth/verify-otp', { phone: riderPhone, code: riderOtp });
|
||||||
|
const riderToken = riderAuth.access_token;
|
||||||
|
console.log('\x1b[32m[Rider] Authenticated successfully!\x1b[0m\n');
|
||||||
|
|
||||||
|
// 2. Setup Driver Profile & Go Online
|
||||||
|
console.log(`[Driver] Creating Driver profile...`);
|
||||||
|
await api('POST', '/drivers/apply', { service_class: 'economy', vehicle_make: 'Toyota', vehicle_model: 'Prius', vehicle_plate: '12-3456' }, driverToken);
|
||||||
|
|
||||||
|
console.log(`[Driver] Going Online...`);
|
||||||
|
await api('PATCH', '/drivers/status', { online: true }, driverToken);
|
||||||
|
console.log('\x1b[32m[Driver] Profile created and is now ONLINE.\x1b[0m\n');
|
||||||
|
|
||||||
|
// 3. Connect WebSockets
|
||||||
|
console.log(`[Sockets] Connecting Rider and Driver via WebSockets...`);
|
||||||
|
const driverSocket = io(API_URL, { extraHeaders: { Authorization: `Bearer ${driverToken}` } });
|
||||||
|
const riderSocket = io(API_URL, { extraHeaders: { Authorization: `Bearer ${riderToken}` } });
|
||||||
|
|
||||||
|
let tripId = null;
|
||||||
|
|
||||||
|
driverSocket.on('connect', () => console.log('\x1b[36m[Driver WS] Connected to Gateway\x1b[0m'));
|
||||||
|
riderSocket.on('connect', () => console.log('\x1b[36m[Rider WS] Connected to Gateway\x1b[0m'));
|
||||||
|
|
||||||
|
driverSocket.on('trip:dispatch', async (data) => {
|
||||||
|
console.log(`\n\x1b[35m[Driver WS] 🔔 RECEIVED DISPATCH for Trip ${data.id}!\x1b[0m`);
|
||||||
|
tripId = data.id;
|
||||||
|
|
||||||
|
console.log(`[Driver] Accepting Trip ${tripId}...`);
|
||||||
|
await api('POST', `/trips/${tripId}/accept`, null, driverToken);
|
||||||
|
console.log('\x1b[32m[Driver] Trip Accepted!\x1b[0m');
|
||||||
|
|
||||||
|
// Join Trip Room
|
||||||
|
driverSocket.emit('trip:join', { tripId });
|
||||||
|
riderSocket.emit('trip:join', { tripId });
|
||||||
|
console.log(`[Sockets] Both joined trip room: ${tripId}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
riderSocket.on('trip:status_changed', (data) => {
|
||||||
|
console.log(`\x1b[33m[Rider WS] Trip Status Changed -> ${data.status}\x1b[0m`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Driver Location Pings
|
||||||
|
let driverLat = 32.0645; // Zarqa Coordinates
|
||||||
|
let driverLng = 36.0827;
|
||||||
|
|
||||||
|
const locationInterval = setInterval(() => {
|
||||||
|
// Driver moves slightly
|
||||||
|
driverLat += 0.0001;
|
||||||
|
driverSocket.emit('driver:location', { lat: driverLat, lng: driverLng, speed: 40, heading: 90 });
|
||||||
|
console.log(`[Driver WS] 📍 Location ping: ${driverLat.toFixed(5)}, ${driverLng.toFixed(5)}`);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
// Wait a few seconds before Rider requests
|
||||||
|
await new Promise(r => setTimeout(r, 5000));
|
||||||
|
|
||||||
|
// 5. Rider Requests Trip
|
||||||
|
console.log(`\n[Rider] Requesting Trip from Zarqa...`);
|
||||||
|
const tripRequest = await api('POST', '/trips', {
|
||||||
|
city: 'amman',
|
||||||
|
service_class: 'economy',
|
||||||
|
origin: { lat: 32.0650, lng: 36.0830 }, // Zarqa origin
|
||||||
|
destination: { lat: 32.0700, lng: 36.0900 }
|
||||||
|
}, riderToken);
|
||||||
|
|
||||||
|
console.log(`\x1b[32m[Rider] Trip created: ${tripRequest.id}\x1b[0m`);
|
||||||
|
|
||||||
|
// 6. Simulate Trip Flow (Wait 10 seconds per state)
|
||||||
|
await new Promise(r => setTimeout(r, 10000));
|
||||||
|
|
||||||
|
if (tripId) {
|
||||||
|
console.log(`\n[Driver] Arriving at pickup...`);
|
||||||
|
await api('PATCH', `/trips/${tripId}/status`, { status: 'driver_arrived' }, driverToken);
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 5000));
|
||||||
|
|
||||||
|
console.log(`\n[Driver] Starting Trip...`);
|
||||||
|
await api('PATCH', `/trips/${tripId}/status`, { status: 'in_progress' }, driverToken);
|
||||||
|
|
||||||
|
console.log(`\n[System] Simulating 1-minute ride to destination...`);
|
||||||
|
// Simulate 30 seconds wait
|
||||||
|
await new Promise(r => setTimeout(r, 30000));
|
||||||
|
console.log(`[System] Halfway there...`);
|
||||||
|
await new Promise(r => setTimeout(r, 30000));
|
||||||
|
|
||||||
|
console.log(`\n[Driver] Completing Trip...`);
|
||||||
|
await api('PATCH', `/trips/${tripId}/status`, { status: 'completed' }, driverToken);
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
|
||||||
|
console.log(`\n[Driver] Collecting Cash...`);
|
||||||
|
await api('PATCH', `/trips/${tripId}/status`, { status: 'paid' }, driverToken);
|
||||||
|
|
||||||
|
console.log('\n\x1b[32m🎉 Simulation Completed Successfully! 🎉\x1b[0m\n');
|
||||||
|
} else {
|
||||||
|
console.log('\n\x1b[31mFailed to receive dispatch!\x1b[0m');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
clearInterval(locationInterval);
|
||||||
|
driverSocket.disconnect();
|
||||||
|
riderSocket.disconnect();
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => {
|
||||||
|
console.error('\x1b[31mSimulation Failed!\x1b[0m', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -43,6 +43,7 @@ import { DocumentsModule } from './modules/documents/documents.module';
|
|||||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||||
import { GeminiModule } from './integrations/gemini/gemini.module';
|
import { GeminiModule } from './integrations/gemini/gemini.module';
|
||||||
import { BillingModule } from './modules/billing/billing.module';
|
import { BillingModule } from './modules/billing/billing.module';
|
||||||
|
import { GeofenceModule } from './modules/geofence/geofence.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -82,6 +83,7 @@ import { BillingModule } from './modules/billing/billing.module';
|
|||||||
NotificationsModule, // عالمي — FCM
|
NotificationsModule, // عالمي — FCM
|
||||||
StorageModule, // عالمي — تخزين ملفات الوثائق
|
StorageModule, // عالمي — تخزين ملفات الوثائق
|
||||||
GeminiModule, // عالمي — رؤية AI (وثائق + وجه)
|
GeminiModule, // عالمي — رؤية AI (وثائق + وجه)
|
||||||
|
GeofenceModule,
|
||||||
|
|
||||||
HealthModule,
|
HealthModule,
|
||||||
TenantsModule,
|
TenantsModule,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('campaign_logs')
|
||||||
|
export class CampaignLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
user_id: string; // rider or driver id
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
zone_id: string;
|
||||||
|
|
||||||
|
@Column({ length: 50 })
|
||||||
|
user_type: 'rider' | 'driver';
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
sent_at: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('geofence_zones')
|
||||||
|
export class GeofenceZone {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
tenant_id: string;
|
||||||
|
|
||||||
|
@Column({ length: 150 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column('decimal', { precision: 10, scale: 7 })
|
||||||
|
latitude: number;
|
||||||
|
|
||||||
|
@Column('decimal', { precision: 10, scale: 7 })
|
||||||
|
longitude: number;
|
||||||
|
|
||||||
|
@Column('int')
|
||||||
|
radius_meters: number;
|
||||||
|
|
||||||
|
@Column('int', { default: 0 })
|
||||||
|
priority: number;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
is_active: boolean;
|
||||||
|
|
||||||
|
// --- Pricing Modifiers ---
|
||||||
|
// e.g., 1.5 = 50% surge if pickup is here. 0.8 = 20% discount. 1.0 = normal.
|
||||||
|
@Column('decimal', { precision: 5, scale: 2, default: 1.0 })
|
||||||
|
pricing_multiplier: number;
|
||||||
|
|
||||||
|
// --- Marketing / Campaigns ---
|
||||||
|
@Column({ nullable: true })
|
||||||
|
campaign_title: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
campaign_body: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Controller, Get, Query, UseGuards, ParseFloatPipe } from '@nestjs/common';
|
||||||
|
import { GeofenceService } from './geofence.service';
|
||||||
|
import { CurrentUser } from '../../common/auth/current-user.decorator';
|
||||||
|
import { AuthUser } from '../../common/auth/auth-user.interface';
|
||||||
|
import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard';
|
||||||
|
|
||||||
|
@Controller('geofence')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class GeofenceController {
|
||||||
|
constructor(private readonly geofenceService: GeofenceService) {}
|
||||||
|
|
||||||
|
@Get('nearby')
|
||||||
|
async getNearbyZones(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query('lat', ParseFloatPipe) lat: number,
|
||||||
|
@Query('lng', ParseFloatPipe) lng: number,
|
||||||
|
) {
|
||||||
|
const zones = await this.geofenceService.getTopGeofences(user.tenantId, lat, lng);
|
||||||
|
|
||||||
|
// Geofence Hook: Process marketing campaigns when app fetches nearby zones (App Opening)
|
||||||
|
this.geofenceService.processLocation(user.tenantId, user.userId, user.role as any, lat, lng).catch(() => {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: zones,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { GeofenceZone } from './entities/geofence-zone.entity';
|
||||||
|
import { CampaignLog } from './entities/campaign-log.entity';
|
||||||
|
import { GeofenceService } from './geofence.service';
|
||||||
|
import { GeofenceController } from './geofence.controller';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([GeofenceZone, CampaignLog]),
|
||||||
|
NotificationsModule,
|
||||||
|
],
|
||||||
|
providers: [GeofenceService],
|
||||||
|
controllers: [GeofenceController],
|
||||||
|
exports: [GeofenceService],
|
||||||
|
})
|
||||||
|
export class GeofenceModule {}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { GeofenceZone } from './entities/geofence-zone.entity';
|
||||||
|
import { CampaignLog } from './entities/campaign-log.entity';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GeofenceService {
|
||||||
|
private readonly logger = new Logger(GeofenceService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(GeofenceZone) private readonly zonesRepo: Repository<GeofenceZone>,
|
||||||
|
@InjectRepository(CampaignLog) private readonly campaignLogRepo: Repository<CampaignLog>,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the highest-priority intersecting zone for a given point.
|
||||||
|
*/
|
||||||
|
async findIntersectingZone(tenantId: string, lat: number, lng: number): Promise<GeofenceZone | null> {
|
||||||
|
const zones = await this.zonesRepo.find({ where: { tenant_id: tenantId, is_active: true } });
|
||||||
|
|
||||||
|
let bestZone: GeofenceZone | null = null;
|
||||||
|
let maxPriority = -1;
|
||||||
|
|
||||||
|
for (const zone of zones) {
|
||||||
|
const distance = this.haversineDistance(lat, lng, Number(zone.latitude), Number(zone.longitude));
|
||||||
|
if (distance <= zone.radius_meters) {
|
||||||
|
if (zone.priority > maxPriority) {
|
||||||
|
bestZone = zone;
|
||||||
|
maxPriority = zone.priority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a user's location ping. If they enter a marketing zone, send a push campaign.
|
||||||
|
*/
|
||||||
|
async processLocation(tenantId: string, userId: string, userType: 'rider' | 'driver', lat: number, lng: number) {
|
||||||
|
const zone = await this.findIntersectingZone(tenantId, lat, lng);
|
||||||
|
if (!zone) return;
|
||||||
|
|
||||||
|
if (zone.campaign_title && zone.campaign_body) {
|
||||||
|
// Check for spam limit (last 24 hours)
|
||||||
|
const lastSent = await this.campaignLogRepo.createQueryBuilder('log')
|
||||||
|
.where('log.user_id = :userId', { userId })
|
||||||
|
.andWhere('log.zone_id = :zoneId', { zoneId: zone.id })
|
||||||
|
.andWhere('log.sent_at > NOW() - INTERVAL \'24 HOURS\'')
|
||||||
|
.getCount();
|
||||||
|
|
||||||
|
if (lastSent === 0) {
|
||||||
|
this.logger.log(`Geofence breach by ${userType} ${userId} in zone ${zone.name}. Sending campaign.`);
|
||||||
|
|
||||||
|
// Send Notification
|
||||||
|
await this.notifications.sendPushToUser(tenantId, userId, {
|
||||||
|
title: zone.campaign_title,
|
||||||
|
body: zone.campaign_body,
|
||||||
|
data: { type: 'geofence_promo', zoneId: zone.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log it
|
||||||
|
await this.campaignLogRepo.save({
|
||||||
|
user_id: userId,
|
||||||
|
zone_id: zone.id,
|
||||||
|
user_type: userType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the top 20 nearest active geofences to register in the native OS app.
|
||||||
|
*/
|
||||||
|
async getTopGeofences(tenantId: string, lat: number, lng: number, limit = 20): Promise<GeofenceZone[]> {
|
||||||
|
const zones = await this.zonesRepo.find({ where: { tenant_id: tenantId, is_active: true } });
|
||||||
|
|
||||||
|
// Calculate distance and sort
|
||||||
|
const mapped = zones.map(zone => ({
|
||||||
|
zone,
|
||||||
|
distance: this.haversineDistance(lat, lng, Number(zone.latitude), Number(zone.longitude)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mapped.sort((a, b) => a.distance - b.distance);
|
||||||
|
|
||||||
|
return mapped.slice(0, limit).map(item => item.zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast Haversine Distance Calculation (Meters).
|
||||||
|
*/
|
||||||
|
private haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const toRad = (x: number) => (x * Math.PI) / 180;
|
||||||
|
const R = 6371e3;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
|
||||||
|
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { REDIS } from '../../common/redis/redis.module';
|
|||||||
import { MatchingService, DriverAvailability } from '../matching/matching.service';
|
import { MatchingService, DriverAvailability } from '../matching/matching.service';
|
||||||
import { Driver } from '../drivers/entities/driver.entity';
|
import { Driver } from '../drivers/entities/driver.entity';
|
||||||
import { MapsService } from '../maps/maps.service';
|
import { MapsService } from '../maps/maps.service';
|
||||||
|
import { GeofenceService } from '../geofence/geofence.service';
|
||||||
|
|
||||||
/** الموقع الحيّ كما يعيش في Redis. */
|
/** الموقع الحيّ كما يعيش في Redis. */
|
||||||
export interface LivePosition {
|
export interface LivePosition {
|
||||||
@@ -48,6 +49,7 @@ export class DriverLocationService {
|
|||||||
@Inject(REDIS) private readonly redis: Redis,
|
@Inject(REDIS) private readonly redis: Redis,
|
||||||
@InjectRepository(Driver) private readonly drivers: Repository<Driver>,
|
@InjectRepository(Driver) private readonly drivers: Repository<Driver>,
|
||||||
private readonly matching: MatchingService,
|
private readonly matching: MatchingService,
|
||||||
|
private readonly geofence: GeofenceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private key(tenantId: string, driverId: string): string {
|
private key(tenantId: string, driverId: string): string {
|
||||||
@@ -110,6 +112,16 @@ export class DriverLocationService {
|
|||||||
updatedAt: String(position.updatedAt),
|
updatedAt: String(position.updatedAt),
|
||||||
});
|
});
|
||||||
pipe.expire(k, LOC_TTL_SEC);
|
pipe.expire(k, LOC_TTL_SEC);
|
||||||
|
|
||||||
|
// Geofence Hook: Process marketing campaigns (non-blocking)
|
||||||
|
this.geofence.processLocation(tenantId, driverId, 'driver', position.lat, position.lng).catch(() => {});
|
||||||
|
|
||||||
|
// Geofence Hook: Publish to transit channel so backend-transit can evaluate Geofence
|
||||||
|
this.redis.publish(
|
||||||
|
'channel:bus-locations',
|
||||||
|
JSON.stringify({ driverId, lat: position.lat, lng: position.lng })
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
// «متّسخ» = يحتاج كتابة لقطة على القاعدة لاحقاً (مجموعة → لا تكرار).
|
// «متّسخ» = يحتاج كتابة لقطة على القاعدة لاحقاً (مجموعة → لا تكرار).
|
||||||
pipe.sadd(DriverLocationService.DIRTY_KEY, `${tenantId}|${driverId}`);
|
pipe.sadd(DriverLocationService.DIRTY_KEY, `${tenantId}|${driverId}`);
|
||||||
// نقطة مسار — تُدرَج دفعةً واحدة من الـworker.
|
// نقطة مسار — تُدرَج دفعةً واحدة من الـworker.
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { DriverTrack } from './entities/driver-track.entity';
|
|||||||
import { DriverLocationService } from './driver-location.service';
|
import { DriverLocationService } from './driver-location.service';
|
||||||
import { LocationFlusherService } from './location-flusher.service';
|
import { LocationFlusherService } from './location-flusher.service';
|
||||||
import { MatchingModule } from '../matching/matching.module';
|
import { MatchingModule } from '../matching/matching.module';
|
||||||
|
import { GeofenceModule } from '../geofence/geofence.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* وحدة مستقلة عمداً: تستورد MatchingModule، وتستوردها DriversModule و
|
* وحدة مستقلة عمداً: تستورد MatchingModule، وتستوردها DriversModule و
|
||||||
* TripsModule — لو عاشت داخل drivers لصارت دورة استيراد مع matching.
|
* TripsModule — لو عاشت داخل drivers لصارت دورة استيراد مع matching.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Driver, DriverTrack]), MatchingModule],
|
imports: [TypeOrmModule.forFeature([Driver, DriverTrack]), MatchingModule, GeofenceModule],
|
||||||
providers: [DriverLocationService, LocationFlusherService],
|
providers: [DriverLocationService, LocationFlusherService],
|
||||||
exports: [DriverLocationService, LocationFlusherService],
|
exports: [DriverLocationService, LocationFlusherService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface QuoteInput {
|
|||||||
durationMin: number;
|
durationMin: number;
|
||||||
waitingMin?: number;
|
waitingMin?: number;
|
||||||
at?: Date; // لحظة الحساب (لاختيار النافذة الزمنية)
|
at?: Date; // لحظة الحساب (لاختيار النافذة الزمنية)
|
||||||
|
geofenceMultiplier?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuoteBreakdown {
|
export interface QuoteBreakdown {
|
||||||
@@ -58,9 +59,13 @@ export class TariffEngine {
|
|||||||
|
|
||||||
const surgeMultiplier =
|
const surgeMultiplier =
|
||||||
def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1;
|
def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1;
|
||||||
let total = subtotal * surgeMultiplier;
|
|
||||||
|
|
||||||
if (def.min_fare && total < def.min_fare) total = def.min_fare;
|
const geofenceMultiplier = input.geofenceMultiplier ?? 1.0;
|
||||||
|
const finalMultiplier = surgeMultiplier * geofenceMultiplier;
|
||||||
|
|
||||||
|
subtotal *= finalMultiplier;
|
||||||
|
|
||||||
|
let total = Math.max(def.min_fare ?? 0, subtotal);
|
||||||
total = TariffEngine.round(total, def.rounding);
|
total = TariffEngine.round(total, def.rounding);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { WalletModule } from '../wallet/wallet.module';
|
|||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { LocationsModule } from '../locations/locations.module';
|
import { LocationsModule } from '../locations/locations.module';
|
||||||
import { CreditModule } from '../credit/credit.module';
|
import { CreditModule } from '../credit/credit.module';
|
||||||
|
import { GeofenceModule } from '../geofence/geofence.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -30,6 +31,7 @@ import { CreditModule } from '../credit/credit.module';
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
LocationsModule,
|
LocationsModule,
|
||||||
CreditModule,
|
CreditModule,
|
||||||
|
GeofenceModule,
|
||||||
],
|
],
|
||||||
controllers: [TripsController],
|
controllers: [TripsController],
|
||||||
providers: [TripsService, TripStateService, ScheduledTripsSweeper],
|
providers: [TripsService, TripStateService, ScheduledTripsSweeper],
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export interface RequestTripDto {
|
|||||||
scheduled_at?: string; // ISO — حجز مسبق (docs/17 — B5)
|
scheduled_at?: string; // ISO — حجز مسبق (docs/17 — B5)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { GeofenceService } from '../geofence/geofence.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TripsService {
|
export class TripsService {
|
||||||
private readonly logger = new Logger('Trips');
|
private readonly logger = new Logger('Trips');
|
||||||
@@ -80,6 +82,7 @@ export class TripsService {
|
|||||||
private readonly users: UsersService,
|
private readonly users: UsersService,
|
||||||
private readonly locations: DriverLocationService,
|
private readonly locations: DriverLocationService,
|
||||||
private readonly credit: DriverCreditService,
|
private readonly credit: DriverCreditService,
|
||||||
|
private readonly geofence: GeofenceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get(tenantId: string, id: string): Promise<Trip | null> {
|
get(tenantId: string, id: string): Promise<Trip | null> {
|
||||||
@@ -169,10 +172,17 @@ export class TripsService {
|
|||||||
// العمولة المتوقعة — تُعرض للسائق ليعرف ما سيُخصم من رصيده (docs/18).
|
// العمولة المتوقعة — تُعرض للسائق ليعرف ما سيُخصم من رصيده (docs/18).
|
||||||
let commissionAmount: number | null = null;
|
let commissionAmount: number | null = null;
|
||||||
let commissionRate: number | null = null;
|
let commissionRate: number | null = null;
|
||||||
|
let geofenceMultiplier = 1.0;
|
||||||
|
try {
|
||||||
|
const zone = await this.geofence.findIntersectingZone(tenantId, dto.origin.lat, dto.origin.lng);
|
||||||
|
if (zone) geofenceMultiplier = zone.pricing_multiplier;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const q = await this.tariff.quote(tenantId, city, serviceClass, {
|
const q = await this.tariff.quote(tenantId, city, serviceClass, {
|
||||||
distanceKm: route.distanceKm,
|
distanceKm: route.distanceKm,
|
||||||
durationMin: route.durationMin,
|
durationMin: route.durationMin,
|
||||||
|
geofenceMultiplier,
|
||||||
});
|
});
|
||||||
quotedFare = q.quote.total;
|
quotedFare = q.quote.total;
|
||||||
currency = q.quote.currency;
|
currency = q.quote.currency;
|
||||||
|
|||||||
Reference in New Issue
Block a user