feat: add location intelligence engine and E2E simulation script

This commit is contained in:
Hamza-Ayed
2026-07-18 19:08:52 +03:00
parent a15e65c42e
commit fef1235fc9
14 changed files with 539 additions and 3 deletions
@@ -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;
}
}