From 00870a8cc3cb4d6a308d10013b169125d714cacb Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 16 Jul 2026 16:41:58 +0300 Subject: [PATCH] =?UTF-8?q?P1:=20drivers=20+=20trips(=E2=98=85state=20mach?= =?UTF-8?q?ine)=20+=20matching(Redis=20GEO)=20+=20tariff=20engine=20+=20ma?= =?UTF-8?q?ps=20+=20realtime=20+=20seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drivers: apply/approve/status/location (+Redis GEO presence) - trips: request→accept→status transitions + trip_events audit + offers via socket - matching: Redis GEO nearby drivers - tariff: JSON-rule engine (time_or_distance/window/rounding) + quote endpoint - maps: straight-line routing seam (انطلق later) - realtime: Socket.IO gateway (JWT auth, tenant rooms) - seed: demo tenant siro + default tariff on boot - migration InitP1 (drivers/tariffs/trips/trip_events) Co-Authored-By: Claude Opus 4.8 --- backend/src/app.module.ts | 17 ++ backend/src/common/seed/seed.module.ts | 10 + backend/src/common/seed/seed.service.ts | 64 +++++ .../migrations/1721300000000-InitP1.ts | 87 +++++++ .../src/modules/drivers/drivers.controller.ts | 43 ++++ backend/src/modules/drivers/drivers.module.ts | 15 ++ .../src/modules/drivers/drivers.service.ts | 93 +++++++ .../modules/drivers/entities/driver.entity.ts | 66 +++++ backend/src/modules/maps/maps.controller.ts | 27 ++ backend/src/modules/maps/maps.module.ts | 10 + backend/src/modules/maps/maps.service.ts | 49 ++++ .../src/modules/matching/matching.module.ts | 8 + .../src/modules/matching/matching.service.ts | 66 +++++ .../modules/tariff/entities/tariff.entity.ts | 61 +++++ .../src/modules/tariff/tariff.controller.ts | 34 +++ backend/src/modules/tariff/tariff.engine.ts | 113 +++++++++ backend/src/modules/tariff/tariff.module.ts | 14 ++ backend/src/modules/tariff/tariff.service.ts | 49 ++++ .../trips/entities/trip-event.entity.ts | 38 +++ .../src/modules/trips/entities/trip.entity.ts | 96 ++++++++ backend/src/modules/trips/trips.controller.ts | 53 ++++ backend/src/modules/trips/trips.module.ts | 26 ++ backend/src/modules/trips/trips.service.ts | 233 ++++++++++++++++++ backend/src/modules/users/users.service.ts | 11 +- backend/src/realtime/realtime.gateway.ts | 57 +++++ backend/src/realtime/realtime.module.ts | 19 ++ 26 files changed, 1358 insertions(+), 1 deletion(-) create mode 100644 backend/src/common/seed/seed.module.ts create mode 100644 backend/src/common/seed/seed.service.ts create mode 100644 backend/src/database/migrations/1721300000000-InitP1.ts create mode 100644 backend/src/modules/drivers/drivers.controller.ts create mode 100644 backend/src/modules/drivers/drivers.module.ts create mode 100644 backend/src/modules/drivers/drivers.service.ts create mode 100644 backend/src/modules/drivers/entities/driver.entity.ts create mode 100644 backend/src/modules/maps/maps.controller.ts create mode 100644 backend/src/modules/maps/maps.module.ts create mode 100644 backend/src/modules/maps/maps.service.ts create mode 100644 backend/src/modules/matching/matching.module.ts create mode 100644 backend/src/modules/matching/matching.service.ts create mode 100644 backend/src/modules/tariff/entities/tariff.entity.ts create mode 100644 backend/src/modules/tariff/tariff.controller.ts create mode 100644 backend/src/modules/tariff/tariff.engine.ts create mode 100644 backend/src/modules/tariff/tariff.module.ts create mode 100644 backend/src/modules/tariff/tariff.service.ts create mode 100644 backend/src/modules/trips/entities/trip-event.entity.ts create mode 100644 backend/src/modules/trips/entities/trip.entity.ts create mode 100644 backend/src/modules/trips/trips.controller.ts create mode 100644 backend/src/modules/trips/trips.module.ts create mode 100644 backend/src/modules/trips/trips.service.ts create mode 100644 backend/src/realtime/realtime.gateway.ts create mode 100644 backend/src/realtime/realtime.module.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index eb42be6..bf87010 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -4,10 +4,18 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ThrottlerModule } from '@nestjs/throttler'; import configuration from './config/configuration'; import { TenantMiddleware } from './common/tenant/tenant.middleware'; +import { RedisModule } from './common/redis/redis.module'; +import { SeedModule } from './common/seed/seed.module'; import { HealthModule } from './modules/health/health.module'; import { TenantsModule } from './modules/tenants/tenants.module'; import { UsersModule } from './modules/users/users.module'; import { AuthModule } from './modules/auth/auth.module'; +import { DriversModule } from './modules/drivers/drivers.module'; +import { MatchingModule } from './modules/matching/matching.module'; +import { TariffModule } from './modules/tariff/tariff.module'; +import { MapsModule } from './modules/maps/maps.module'; +import { TripsModule } from './modules/trips/trips.module'; +import { RealtimeModule } from './realtime/realtime.module'; @Module({ imports: [ @@ -31,10 +39,19 @@ import { AuthModule } from './modules/auth/auth.module'; ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }]), + RedisModule, // عالمي — يوفّر عميل Redis للمطابقة و OTP لاحقاً + HealthModule, TenantsModule, UsersModule, AuthModule, + MapsModule, + MatchingModule, + TariffModule, + DriversModule, + RealtimeModule, + TripsModule, + SeedModule, ], }) export class AppModule implements NestModule { diff --git a/backend/src/common/seed/seed.module.ts b/backend/src/common/seed/seed.module.ts new file mode 100644 index 0000000..03ae904 --- /dev/null +++ b/backend/src/common/seed/seed.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SeedService } from './seed.service'; +import { TenantsModule } from '../../modules/tenants/tenants.module'; +import { TariffModule } from '../../modules/tariff/tariff.module'; + +@Module({ + imports: [TenantsModule, TariffModule], + providers: [SeedService], +}) +export class SeedModule {} diff --git a/backend/src/common/seed/seed.service.ts b/backend/src/common/seed/seed.service.ts new file mode 100644 index 0000000..8a7a76d --- /dev/null +++ b/backend/src/common/seed/seed.service.ts @@ -0,0 +1,64 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { TenantsService } from '../../modules/tenants/tenants.service'; +import { TariffService } from '../../modules/tariff/tariff.service'; + +/** + * seed تلقائي عند الإقلاع: مستأجر تجريبي (Tenant Zero) + تعرفة افتراضية، + * حتى لا نعيد إنشاءهما يدوياً بعد كل `docker compose down -v`. + */ +@Injectable() +export class SeedService implements OnModuleInit { + private readonly logger = new Logger('Seed'); + + constructor( + private readonly tenants: TenantsService, + private readonly tariff: TariffService, + ) {} + + async onModuleInit() { + try { + await this.run(); + } catch (e: any) { + // على قاعدة جديدة قد تسبق onModuleInit تشغيلَ الهجرات — لا نُسقط الإقلاع. + this.logger.warn(`seed skipped (run migrations then restart api): ${e?.message ?? e}`); + } + } + + private async run() { + let tenant = await this.tenants.findBySlug('siro'); + if (!tenant) { + tenant = await this.tenants.create({ + name: 'Siro (Demo — Tenant Zero)', + slug: 'siro', + countryPack: 'jo', + plan: 'brand', + }); + this.logger.log(`seeded demo tenant "siro" (${tenant.id})`); + } + + const existing = await this.tariff.getActive(tenant.id, 'default', 'economy'); + if (!existing) { + await this.tariff.create({ + tenant_id: tenant.id, + city: 'default', + service_class: 'economy', + version: 1, + active: true, + definition: { + currency: 'JOD', + rounding: { increment: 0.05, mode: 'nearest' }, + mode: 'time_or_distance', + speed_threshold_kmh: 18, + windows: [ + { name: 'day', from: '06:00', to: '22:00', flag: 0.39, per_km: 0.28, per_min: 0.06, per_min_waiting: 0.48 }, + { name: 'night', from: '22:00', to: '06:00', flag: 0.40, per_km: 0.33, per_min: 0.07, per_min_waiting: 0.55 }, + ], + booking_fee: 0.25, + min_fare: 1.0, + surge: { enabled: false }, + }, + }); + this.logger.log('seeded default tariff for "siro"'); + } + } +} diff --git a/backend/src/database/migrations/1721300000000-InitP1.ts b/backend/src/database/migrations/1721300000000-InitP1.ts new file mode 100644 index 0000000..834e967 --- /dev/null +++ b/backend/src/database/migrations/1721300000000-InitP1.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * جداول P1: drivers, tariffs, trips, trip_events (كلها ببادئة tripz_). + */ +export class InitP11721300000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + // drivers + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_drivers ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL, + user_id uuid NOT NULL, + service_class varchar NOT NULL DEFAULT 'economy', + vehicle_make varchar, vehicle_model varchar, + vehicle_plate varchar, vehicle_color varchar, + docs jsonb NOT NULL DEFAULT '{}', + verification_status varchar NOT NULL DEFAULT 'pending', + is_online boolean NOT NULL DEFAULT false, + rating numeric(3,2) NOT NULL DEFAULT 5, + last_lat double precision, last_lng double precision, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + )`); + await q.query(`CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_drivers_tenant_user" ON tripz_drivers (tenant_id, user_id)`); + + // tariffs + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_tariffs ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL, + city varchar NOT NULL, + service_class varchar NOT NULL, + definition jsonb NOT NULL, + version int NOT NULL DEFAULT 1, + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() + )`); + await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_tariffs_lookup" ON tripz_tariffs (tenant_id, city, service_class, active)`); + + // trips + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_trips ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL, + rider_id uuid NOT NULL, + driver_id uuid, + service_class varchar NOT NULL DEFAULT 'economy', + city varchar, + origin_lat double precision NOT NULL, origin_lng double precision NOT NULL, + dest_lat double precision NOT NULL, dest_lng double precision NOT NULL, + status varchar NOT NULL DEFAULT 'searching', + distance_km numeric(8,3), duration_min numeric(8,1), + tariff_id uuid, tariff_version int, + quoted_fare numeric(12,3), final_fare numeric(12,3), + currency varchar, + payment_method varchar NOT NULL DEFAULT 'cash', + requested_at timestamptz NOT NULL DEFAULT now(), + assigned_at timestamptz, completed_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT now() + )`); + await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_status" ON tripz_trips (tenant_id, status)`); + await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_rider" ON tripz_trips (tenant_id, rider_id)`); + await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_driver" ON tripz_trips (tenant_id, driver_id)`); + + // trip_events + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_trip_events ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL, + trip_id uuid NOT NULL, + from_status varchar, + to_status varchar NOT NULL, + source varchar NOT NULL DEFAULT 'system', + payload jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now() + )`); + await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trip_events_trip" ON tripz_trip_events (tenant_id, trip_id)`); + } + + public async down(q: QueryRunner): Promise { + await q.query(`DROP TABLE IF EXISTS tripz_trip_events`); + await q.query(`DROP TABLE IF EXISTS tripz_trips`); + await q.query(`DROP TABLE IF EXISTS tripz_tariffs`); + await q.query(`DROP TABLE IF EXISTS tripz_drivers`); + } +} diff --git a/backend/src/modules/drivers/drivers.controller.ts b/backend/src/modules/drivers/drivers.controller.ts new file mode 100644 index 0000000..c859250 --- /dev/null +++ b/backend/src/modules/drivers/drivers.controller.ts @@ -0,0 +1,43 @@ +import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { DriversService } from './drivers.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; + +@ApiTags('drivers') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('drivers') +export class DriversController { + constructor(private readonly drivers: DriversService) {} + + @Post('apply') + apply(@CurrentUser() user: AuthUser, @Body() body: any) { + return this.drivers.apply(user.tenantId, user.userId, body); + } + + @Get('me') + me(@CurrentUser() user: AuthUser) { + return this.drivers.findByUser(user.tenantId, user.userId); + } + + @Patch('status') + setStatus(@CurrentUser() user: AuthUser, @Body('online') online: boolean) { + return this.drivers.setOnline(user.tenantId, user.userId, !!online); + } + + @Post('location') + location( + @CurrentUser() user: AuthUser, + @Body('lat') lat: number, + @Body('lng') lng: number, + ) { + return this.drivers.updateLocation(user.tenantId, user.userId, Number(lat), Number(lng)); + } + + // للأدمن/المشغّل — تبسيط P1: أي مستخدم مصادَق؛ يُقيَّد بدور لاحقاً. + @Patch(':id/approve') + approve(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.drivers.approve(user.tenantId, id); + } +} diff --git a/backend/src/modules/drivers/drivers.module.ts b/backend/src/modules/drivers/drivers.module.ts new file mode 100644 index 0000000..b511e9f --- /dev/null +++ b/backend/src/modules/drivers/drivers.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Driver } from './entities/driver.entity'; +import { DriversService } from './drivers.service'; +import { DriversController } from './drivers.controller'; +import { UsersModule } from '../users/users.module'; +import { MatchingModule } from '../matching/matching.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Driver]), UsersModule, MatchingModule], + controllers: [DriversController], + providers: [DriversService], + exports: [DriversService], +}) +export class DriversModule {} diff --git a/backend/src/modules/drivers/drivers.service.ts b/backend/src/modules/drivers/drivers.service.ts new file mode 100644 index 0000000..64daf69 --- /dev/null +++ b/backend/src/modules/drivers/drivers.service.ts @@ -0,0 +1,93 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Driver } from './entities/driver.entity'; +import { UsersService } from '../users/users.service'; +import { MatchingService } from '../matching/matching.service'; + +@Injectable() +export class DriversService { + constructor( + @InjectRepository(Driver) + private readonly repo: Repository, + private readonly users: UsersService, + private readonly matching: MatchingService, + ) {} + + findByUser(tenantId: string, userId: string): Promise { + return this.repo.findOne({ where: { tenant_id: tenantId, user_id: userId } }); + } + + findById(tenantId: string, id: string): Promise { + return this.repo.findOne({ where: { tenant_id: tenantId, id } }); + } + + /** تسجيل السائق بالوثائق — ينشئ سجل driver ويرفع دور المستخدم إلى driver. */ + async apply( + tenantId: string, + userId: string, + data: Partial, + ): Promise { + let driver = await this.findByUser(tenantId, userId); + if (!driver) { + driver = this.repo.create({ + tenant_id: tenantId, + user_id: userId, + service_class: data.service_class ?? 'economy', + vehicle_make: data.vehicle_make, + vehicle_model: data.vehicle_model, + vehicle_plate: data.vehicle_plate, + vehicle_color: data.vehicle_color, + docs: data.docs ?? {}, + verification_status: 'pending', + }); + driver = await this.repo.save(driver); + } + // يصير دور المستخدم "driver" (يظهر في التوكن عند إعادة الدخول). + await this.users.setRole(tenantId, userId, 'driver'); + return driver; + } + + async approve(tenantId: string, driverId: string): Promise { + const driver = await this.findById(tenantId, driverId); + if (!driver) throw new NotFoundException('Driver not found'); + driver.verification_status = 'approved'; + return this.repo.save(driver); + } + + /** يبدّل حالة الاتصال؛ عند الاتصال يُضاف لفهرس GEO، وعند الفصل يُزال. */ + async setOnline( + tenantId: string, + userId: string, + online: boolean, + ): Promise { + const driver = await this.findByUser(tenantId, userId); + if (!driver) throw new NotFoundException('Driver profile not found'); + driver.is_online = online; + await this.repo.save(driver); + if (!online) { + await this.matching.removeDriver(tenantId, driver.id); + } else if (driver.last_lat != null && driver.last_lng != null) { + await this.matching.addDriver(tenantId, driver.id, driver.last_lat, driver.last_lng); + } + return driver; + } + + /** تحديث الموقع الجاري: Redis GEO + لقطة في Postgres. */ + async updateLocation( + tenantId: string, + userId: string, + lat: number, + lng: number, + ): Promise<{ ok: boolean }> { + const driver = await this.findByUser(tenantId, userId); + if (!driver) throw new NotFoundException('Driver profile not found'); + driver.last_lat = lat; + driver.last_lng = lng; + await this.repo.save(driver); + if (driver.is_online) { + await this.matching.addDriver(tenantId, driver.id, lat, lng); + } + return { ok: true }; + } +} diff --git a/backend/src/modules/drivers/entities/driver.entity.ts b/backend/src/modules/drivers/entities/driver.entity.ts new file mode 100644 index 0000000..19c57b5 --- /dev/null +++ b/backend/src/modules/drivers/entities/driver.entity.ts @@ -0,0 +1,66 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +export type VerificationStatus = 'pending' | 'approved' | 'rejected'; + +/** + * السائق = مستخدم بدور driver + بيانات مركبة ووثائق. الجدول: tripz_drivers. + * الموقع الجاري يعيش في Redis (المطابقة)؛ هنا نحفظ آخر لقطة فقط (docs/09). + */ +@Entity('drivers') +@Index(['tenant_id', 'user_id'], { unique: true }) +export class Driver { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + user_id: string; + + @Column({ default: 'economy' }) + service_class: string; + + @Column({ nullable: true }) + vehicle_make: string; + + @Column({ nullable: true }) + vehicle_model: string; + + @Column({ nullable: true }) + vehicle_plate: string; + + @Column({ nullable: true }) + vehicle_color: string; + + @Column({ type: 'jsonb', default: {} }) + docs: Record; + + @Column({ type: 'varchar', default: 'pending' }) + verification_status: VerificationStatus; + + @Column({ default: false }) + is_online: boolean; + + @Column({ type: 'numeric', precision: 3, scale: 2, default: 5 }) + rating: number; + + @Column({ type: 'double precision', nullable: true }) + last_lat: number; + + @Column({ type: 'double precision', nullable: true }) + last_lng: number; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/maps/maps.controller.ts b/backend/src/modules/maps/maps.controller.ts new file mode 100644 index 0000000..e547f42 --- /dev/null +++ b/backend/src/modules/maps/maps.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Query, BadRequestException } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { MapsService } from './maps.service'; + +@ApiTags('maps') +@Controller('maps') +export class MapsController { + constructor(private readonly maps: MapsService) {} + + // GET /maps/route?fromLat=&fromLng=&toLat=&toLng= + @Get('route') + route( + @Query('fromLat') fromLat: string, + @Query('fromLng') fromLng: string, + @Query('toLat') toLat: string, + @Query('toLng') toLng: string, + ) { + const nums = [fromLat, fromLng, toLat, toLng].map(Number); + if (nums.some((n) => Number.isNaN(n))) { + throw new BadRequestException('fromLat, fromLng, toLat, toLng are required numbers'); + } + return this.maps.route( + { lat: nums[0], lng: nums[1] }, + { lat: nums[2], lng: nums[3] }, + ); + } +} diff --git a/backend/src/modules/maps/maps.module.ts b/backend/src/modules/maps/maps.module.ts new file mode 100644 index 0000000..e21b0cf --- /dev/null +++ b/backend/src/modules/maps/maps.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MapsService } from './maps.service'; +import { MapsController } from './maps.controller'; + +@Module({ + controllers: [MapsController], + providers: [MapsService], + exports: [MapsService], +}) +export class MapsModule {} diff --git a/backend/src/modules/maps/maps.service.ts b/backend/src/modules/maps/maps.service.ts new file mode 100644 index 0000000..997bff7 --- /dev/null +++ b/backend/src/modules/maps/maps.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; + +export interface LatLng { + lat: number; + lng: number; +} + +export interface RouteResult { + distanceKm: number; + durationMin: number; + provider: string; +} + +/** + * وكيل الخرائط. الهدف النهائي: توجيه/ترميز من خرائط انطلق الذاتية (docs/07). + * حالياً (P1) توجيه تقديري بخط الطول الجغرافي (haversine) + سرعة متوسطة — + * يفكّ ارتباط بقية النظام (التعرفة/الرحلات) عن تفاصيل انطلق حتى نربطها. + * الاستبدال لاحقاً = تنفيذ route() عبر واجهة انطلق دون تغيير من يستدعيها. + */ +@Injectable() +export class MapsService { + private readonly avgSpeedKmh = 30; // تقدير حضري + + route(from: LatLng, to: LatLng): RouteResult { + const distanceKm = MapsService.haversineKm(from, to); + const durationMin = (distanceKm / this.avgSpeedKmh) * 60; + return { + distanceKm: Number(distanceKm.toFixed(3)), + durationMin: Number(durationMin.toFixed(1)), + provider: 'straight-line', // TODO: 'antlaq' + }; + } + + static haversineKm(a: LatLng, b: LatLng): number { + const R = 6371; + const dLat = MapsService.rad(b.lat - a.lat); + const dLng = MapsService.rad(b.lng - a.lng); + const lat1 = MapsService.rad(a.lat); + const lat2 = MapsService.rad(b.lat); + const h = + Math.sin(dLat / 2) ** 2 + + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2); + return 2 * R * Math.asin(Math.sqrt(h)); + } + + private static rad(deg: number): number { + return (deg * Math.PI) / 180; + } +} diff --git a/backend/src/modules/matching/matching.module.ts b/backend/src/modules/matching/matching.module.ts new file mode 100644 index 0000000..00cab56 --- /dev/null +++ b/backend/src/modules/matching/matching.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { MatchingService } from './matching.service'; + +@Module({ + providers: [MatchingService], + exports: [MatchingService], +}) +export class MatchingModule {} diff --git a/backend/src/modules/matching/matching.service.ts b/backend/src/modules/matching/matching.service.ts new file mode 100644 index 0000000..b35c421 --- /dev/null +++ b/backend/src/modules/matching/matching.service.ts @@ -0,0 +1,66 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { REDIS } from '../../common/redis/redis.module'; + +export interface NearbyDriver { + driverId: string; + distanceKm: number; +} + +/** + * المطابقة الجغرافية عبر Redis GEO (docs/09). مفتاح لكل مستأجر: + * geo:drivers:{tenantId} (ببادئة tripz: تلقائياً من ioredis). + * نستخدم redis.call لتفادي تعقيد أنماط ioredis المتغيّرة. + */ +@Injectable() +export class MatchingService { + constructor(@Inject(REDIS) private readonly redis: Redis) {} + + private key(tenantId: string): string { + return `geo:drivers:${tenantId}`; + } + + async addDriver(tenantId: string, driverId: string, lat: number, lng: number) { + await this.redis.call( + 'GEOADD', + this.key(tenantId), + String(lng), + String(lat), + driverId, + ); + } + + async removeDriver(tenantId: string, driverId: string) { + await this.redis.call('ZREM', this.key(tenantId), driverId); + } + + /** أقرب السائقين ضمن نصف قطر (كم)، مرتبين بالأقرب. */ + async findNearby( + tenantId: string, + lat: number, + lng: number, + radiusKm = 5, + count = 10, + ): Promise { + const res = (await this.redis.call( + 'GEOSEARCH', + this.key(tenantId), + 'FROMLONLAT', + String(lng), + String(lat), + 'BYRADIUS', + String(radiusKm), + 'km', + 'ASC', + 'COUNT', + String(count), + 'WITHDIST', + )) as [string, string][]; + + if (!Array.isArray(res)) return []; + return res.map(([driverId, dist]) => ({ + driverId, + distanceKm: Number(Number(dist).toFixed(3)), + })); + } +} diff --git a/backend/src/modules/tariff/entities/tariff.entity.ts b/backend/src/modules/tariff/entities/tariff.entity.ts new file mode 100644 index 0000000..485664e --- /dev/null +++ b/backend/src/modules/tariff/entities/tariff.entity.ts @@ -0,0 +1,61 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** + * تعريف تعرفة لكل (مستأجر × مدينة × فئة خدمة) كوثيقة JSON — راجع docs/04. + * الجدول: tripz_tariffs. + */ +@Entity('tariffs') +@Index(['tenant_id', 'city', 'service_class', 'active']) +export class Tariff { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column() + city: string; + + @Column() + service_class: string; + + // وثيقة التعرفة الكاملة (mode, windows, fees, rounding...) — راجع docs/04. + @Column({ type: 'jsonb' }) + definition: TariffDefinition; + + @Column({ type: 'int', default: 1 }) + version: number; + + @Column({ default: true }) + active: boolean; + + @CreateDateColumn() + created_at: Date; +} + +export interface TariffWindow { + name: string; + from: string; // "HH:MM" + to: string; + flag: number; + per_km: number; + per_min: number; + per_min_waiting?: number; +} + +export interface TariffDefinition { + currency: string; + rounding?: { increment: number; mode?: 'nearest' | 'up' | 'down' }; + mode: 'time_and_distance' | 'time_or_distance' | 'fixed_quote'; + speed_threshold_kmh?: number; + windows: TariffWindow[]; + booking_fee?: number; + min_fare?: number; + surge?: { enabled: boolean; multiplier?: number; max?: number }; +} diff --git a/backend/src/modules/tariff/tariff.controller.ts b/backend/src/modules/tariff/tariff.controller.ts new file mode 100644 index 0000000..989d7a4 --- /dev/null +++ b/backend/src/modules/tariff/tariff.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Post, Headers, UnauthorizedException } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { TariffService } from './tariff.service'; +import { TenantsService } from '../tenants/tenants.service'; + +@ApiTags('tariff') +@Controller('tariff') +export class TariffController { + constructor( + private readonly tariff: TariffService, + private readonly tenants: TenantsService, + ) {} + + // POST /tariff/quote { city, serviceClass, distanceKm, durationMin } + @Post('quote') + async quote( + @Headers('x-tenant-id') tenantSlug: string, + @Body() body: any, + ) { + if (!tenantSlug) throw new UnauthorizedException('x-tenant-id is required'); + const tenant = await this.tenants.resolve(tenantSlug); + if (!tenant) throw new UnauthorizedException('Unknown tenant'); + return this.tariff.quote( + tenant.id, + body.city, + body.serviceClass, + { + distanceKm: Number(body.distanceKm), + durationMin: Number(body.durationMin), + waitingMin: body.waitingMin ? Number(body.waitingMin) : 0, + }, + ); + } +} diff --git a/backend/src/modules/tariff/tariff.engine.ts b/backend/src/modules/tariff/tariff.engine.ts new file mode 100644 index 0000000..4a6668b --- /dev/null +++ b/backend/src/modules/tariff/tariff.engine.ts @@ -0,0 +1,113 @@ +import { TariffDefinition, TariffWindow } from './entities/tariff.entity'; + +export interface QuoteInput { + distanceKm: number; + durationMin: number; + waitingMin?: number; + at?: Date; // لحظة الحساب (لاختيار النافذة الزمنية) +} + +export interface QuoteBreakdown { + window: string; + flag: number; + distance: number; + time: number; + waiting: number; + bookingFee: number; + subtotal: number; + surgeMultiplier: number; + total: number; + currency: string; +} + +/** + * محرك التعرفة النقي (قابل للاختبار بوحدات) — نفس المدخل = نفس المخرج. + * يدعم أوضاع docs/04: time_and_distance و time_or_distance (عتبة سرعة). + */ +export class TariffEngine { + static quote(def: TariffDefinition, input: QuoteInput): QuoteBreakdown { + const at = input.at ?? new Date(); + const win = TariffEngine.pickWindow(def.windows, at); + const distanceKm = Math.max(0, input.distanceKm); + const durationMin = Math.max(0, input.durationMin); + const waitingMin = Math.max(0, input.waitingMin ?? 0); + + let distanceCharge = 0; + let timeCharge = 0; + + if (def.mode === 'time_or_distance') { + // العداد المنظَّم: تحت العتبة يُحسب بالدقيقة (زحمة)، فوقها بالكيلومتر. + const avgSpeed = durationMin > 0 ? (distanceKm / durationMin) * 60 : 999; + const threshold = def.speed_threshold_kmh ?? 18; + if (avgSpeed < threshold) { + timeCharge = durationMin * win.per_min; + } else { + distanceCharge = distanceKm * win.per_km; + } + } else { + // time_and_distance (و fixed_quote يُحسب مرة ويثبّت) + distanceCharge = distanceKm * win.per_km; + timeCharge = durationMin * win.per_min; + } + + const waitingCharge = waitingMin * (win.per_min_waiting ?? 0); + const bookingFee = def.booking_fee ?? 0; + + let subtotal = + win.flag + distanceCharge + timeCharge + waitingCharge + bookingFee; + + const surgeMultiplier = + 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; + total = TariffEngine.round(total, def.rounding); + + return { + window: win.name, + flag: win.flag, + distance: TariffEngine.n(distanceCharge), + time: TariffEngine.n(timeCharge), + waiting: TariffEngine.n(waitingCharge), + bookingFee, + subtotal: TariffEngine.n(subtotal), + surgeMultiplier, + total: TariffEngine.n(total), + currency: def.currency, + }; + } + + private static pickWindow(windows: TariffWindow[], at: Date): TariffWindow { + const mins = at.getUTCHours() * 60 + at.getUTCMinutes(); + for (const w of windows) { + const [fromH, fromM] = w.from.split(':').map(Number); + const [toH, toM] = w.to.split(':').map(Number); + const from = fromH * 60 + fromM; + const to = toH * 60 + toM; + // نافذة تعبر منتصف الليل (مثل 22:00 → 06:00) + if (from <= to ? mins >= from && mins < to : mins >= from || mins < to) { + return w; + } + } + return windows[0]; + } + + private static round( + value: number, + rounding?: { increment: number; mode?: 'nearest' | 'up' | 'down' }, + ): number { + if (!rounding || !rounding.increment) return TariffEngine.n(value); + const q = value / rounding.increment; + const r = + rounding.mode === 'up' + ? Math.ceil(q) + : rounding.mode === 'down' + ? Math.floor(q) + : Math.round(q); + return TariffEngine.n(r * rounding.increment); + } + + private static n(v: number): number { + return Number(v.toFixed(3)); + } +} diff --git a/backend/src/modules/tariff/tariff.module.ts b/backend/src/modules/tariff/tariff.module.ts new file mode 100644 index 0000000..d9cdc75 --- /dev/null +++ b/backend/src/modules/tariff/tariff.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Tariff } from './entities/tariff.entity'; +import { TariffService } from './tariff.service'; +import { TariffController } from './tariff.controller'; +import { TenantsModule } from '../tenants/tenants.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Tariff]), TenantsModule], + controllers: [TariffController], + providers: [TariffService], + exports: [TariffService], +}) +export class TariffModule {} diff --git a/backend/src/modules/tariff/tariff.service.ts b/backend/src/modules/tariff/tariff.service.ts new file mode 100644 index 0000000..99d4d4d --- /dev/null +++ b/backend/src/modules/tariff/tariff.service.ts @@ -0,0 +1,49 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Tariff } from './entities/tariff.entity'; +import { TariffEngine, QuoteInput, QuoteBreakdown } from './tariff.engine'; + +@Injectable() +export class TariffService { + constructor( + @InjectRepository(Tariff) + private readonly repo: Repository, + ) {} + + getActive( + tenantId: string, + city: string, + serviceClass: string, + ): Promise { + return this.repo.findOne({ + where: { + tenant_id: tenantId, + city, + service_class: serviceClass, + active: true, + }, + order: { version: 'DESC' }, + }); + } + + create(data: Partial): Promise { + return this.repo.save(this.repo.create(data)); + } + + async quote( + tenantId: string, + city: string, + serviceClass: string, + input: QuoteInput, + ): Promise<{ quote: QuoteBreakdown; tariffId: string; version: number }> { + const tariff = await this.getActive(tenantId, city, serviceClass); + if (!tariff) { + throw new NotFoundException( + `No active tariff for ${city}/${serviceClass}`, + ); + } + const quote = TariffEngine.quote(tariff.definition, input); + return { quote, tariffId: tariff.id, version: tariff.version }; + } +} diff --git a/backend/src/modules/trips/entities/trip-event.entity.ts b/backend/src/modules/trips/entities/trip-event.entity.ts new file mode 100644 index 0000000..bf1b6e2 --- /dev/null +++ b/backend/src/modules/trips/entities/trip-event.entity.ts @@ -0,0 +1,38 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** + * سجل انتقالات الرحلة — تشخيص + مطلب المنظّم (docs/08). الجدول: tripz_trip_events. + */ +@Entity('trip_events') +@Index(['tenant_id', 'trip_id']) +export class TripEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + trip_id: string; + + @Column({ nullable: true }) + from_status: string; + + @Column() + to_status: string; + + @Column({ default: 'system' }) + source: string; // rider | driver | system + + @Column({ type: 'jsonb', default: {} }) + payload: Record; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/modules/trips/entities/trip.entity.ts b/backend/src/modules/trips/entities/trip.entity.ts new file mode 100644 index 0000000..ad8f061 --- /dev/null +++ b/backend/src/modules/trips/entities/trip.entity.ts @@ -0,0 +1,96 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +export type TripStatus = + | 'searching' + | 'assigned' + | 'driver_arriving' + | 'driver_arrived' + | 'in_progress' + | 'completed' + | 'paid' + | 'cancelled' + | 'no_drivers' + | 'expired'; + +/** الرحلة. الجدول: tripz_trips. آلة الحالة مصدرها الباك إند (docs/02). */ +@Entity('trips') +@Index(['tenant_id', 'status']) +@Index(['tenant_id', 'rider_id']) +@Index(['tenant_id', 'driver_id']) +export class Trip { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + rider_id: string; + + @Column({ type: 'uuid', nullable: true }) + driver_id: string | null; + + @Column({ default: 'economy' }) + service_class: string; + + @Column({ nullable: true }) + city: string; + + @Column({ type: 'double precision' }) + origin_lat: number; + + @Column({ type: 'double precision' }) + origin_lng: number; + + @Column({ type: 'double precision' }) + dest_lat: number; + + @Column({ type: 'double precision' }) + dest_lng: number; + + @Column({ type: 'varchar', default: 'searching' }) + status: TripStatus; + + @Column({ type: 'numeric', precision: 8, scale: 3, nullable: true }) + distance_km: number | null; + + @Column({ type: 'numeric', precision: 8, scale: 1, nullable: true }) + duration_min: number | null; + + @Column({ type: 'uuid', nullable: true }) + tariff_id: string | null; + + @Column({ type: 'int', nullable: true }) + tariff_version: number | null; + + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) + quoted_fare: number | null; + + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) + final_fare: number | null; + + @Column({ nullable: true }) + currency: string; + + @Column({ default: 'cash' }) + payment_method: string; + + @CreateDateColumn() + requested_at: Date; + + @Column({ type: 'timestamptz', nullable: true }) + assigned_at: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + completed_at: Date | null; + + @UpdateDateColumn() + updated_at: Date; +} diff --git a/backend/src/modules/trips/trips.controller.ts b/backend/src/modules/trips/trips.controller.ts new file mode 100644 index 0000000..3fe3bd2 --- /dev/null +++ b/backend/src/modules/trips/trips.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { TripsService, RequestTripDto } from './trips.service'; +import { TripStatus } from './entities/trip.entity'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; + +@ApiTags('trips') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('trips') +export class TripsController { + constructor(private readonly trips: TripsService) {} + + // الراكب يطلب رحلة + @Post() + request(@CurrentUser() user: AuthUser, @Body() body: RequestTripDto) { + return this.trips.request(user.tenantId, user.userId, body); + } + + @Get('mine') + mine(@CurrentUser() user: AuthUser) { + return this.trips.listForRider(user.tenantId, user.userId); + } + + @Get(':id') + get(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.trips.get(user.tenantId, id); + } + + // السائق يقبل الرحلة + @Post(':id/accept') + accept(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.trips.accept(user.tenantId, id, user.userId); + } + + // تحديث الحالة (driver_arriving / driver_arrived / in_progress / completed / paid) + @Patch(':id/status') + status( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body('status') status: TripStatus, + ) { + const actor = user.role === 'driver' ? 'driver' : 'rider'; + return this.trips.updateStatus(user.tenantId, id, status, actor); + } + + @Post(':id/cancel') + cancel(@CurrentUser() user: AuthUser, @Param('id') id: string) { + const actor = user.role === 'driver' ? 'driver' : 'rider'; + return this.trips.cancel(user.tenantId, id, actor); + } +} diff --git a/backend/src/modules/trips/trips.module.ts b/backend/src/modules/trips/trips.module.ts new file mode 100644 index 0000000..8038689 --- /dev/null +++ b/backend/src/modules/trips/trips.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Trip } from './entities/trip.entity'; +import { TripEvent } from './entities/trip-event.entity'; +import { TripsService } from './trips.service'; +import { TripsController } from './trips.controller'; +import { MapsModule } from '../maps/maps.module'; +import { TariffModule } from '../tariff/tariff.module'; +import { MatchingModule } from '../matching/matching.module'; +import { DriversModule } from '../drivers/drivers.module'; +import { RealtimeModule } from '../../realtime/realtime.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Trip, TripEvent]), + MapsModule, + TariffModule, + MatchingModule, + DriversModule, + RealtimeModule, + ], + controllers: [TripsController], + providers: [TripsService], + exports: [TripsService], +}) +export class TripsModule {} diff --git a/backend/src/modules/trips/trips.service.ts b/backend/src/modules/trips/trips.service.ts new file mode 100644 index 0000000..dd3ddc7 --- /dev/null +++ b/backend/src/modules/trips/trips.service.ts @@ -0,0 +1,233 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Trip, TripStatus } from './entities/trip.entity'; +import { TripEvent } from './entities/trip-event.entity'; +import { MapsService } from '../maps/maps.service'; +import { TariffService } from '../tariff/tariff.service'; +import { MatchingService } from '../matching/matching.service'; +import { DriversService } from '../drivers/drivers.service'; +import { RealtimeGateway } from '../../realtime/realtime.gateway'; + +/** الانتقالات المسموحة في آلة حالة الرحلة (docs/02). */ +const TRANSITIONS: Record = { + searching: ['assigned', 'cancelled', 'no_drivers', 'expired'], + assigned: ['driver_arriving', 'cancelled'], + driver_arriving: ['driver_arrived', 'cancelled'], + driver_arrived: ['in_progress', 'cancelled'], + in_progress: ['completed'], + completed: ['paid'], + paid: [], + cancelled: [], + no_drivers: [], + expired: [], +}; + +export interface RequestTripDto { + origin: { lat: number; lng: number }; + destination: { lat: number; lng: number }; + service_class?: string; + city?: string; +} + +@Injectable() +export class TripsService { + private readonly logger = new Logger('Trips'); + + constructor( + @InjectRepository(Trip) private readonly trips: Repository, + @InjectRepository(TripEvent) private readonly events: Repository, + private readonly maps: MapsService, + private readonly tariff: TariffService, + private readonly matching: MatchingService, + private readonly drivers: DriversService, + private readonly gateway: RealtimeGateway, + ) {} + + get(tenantId: string, id: string): Promise { + return this.trips.findOne({ where: { tenant_id: tenantId, id } }); + } + + listForRider(tenantId: string, riderId: string): Promise { + return this.trips.find({ + where: { tenant_id: tenantId, rider_id: riderId }, + order: { requested_at: 'DESC' }, + }); + } + + listForDriver(tenantId: string, driverId: string): Promise { + return this.trips.find({ + where: { tenant_id: tenantId, driver_id: driverId }, + order: { requested_at: 'DESC' }, + }); + } + + /** طلب رحلة: تسعير → إنشاء → مطابقة → بث عروض. */ + async request(tenantId: string, riderId: string, dto: RequestTripDto) { + if (!dto?.origin || !dto?.destination) { + throw new BadRequestException('origin and destination are required'); + } + const serviceClass = dto.service_class ?? 'economy'; + const city = dto.city ?? 'default'; + const route = this.maps.route(dto.origin, dto.destination); + + // تسعير (اختياري — لو ما في تعرفة مفعّلة نكمل بلا سعر مقفول) + let quotedFare: number | null = null; + let currency: string | null = null; + let tariffId: string | null = null; + let tariffVersion: number | null = null; + try { + const q = await this.tariff.quote(tenantId, city, serviceClass, { + distanceKm: route.distanceKm, + durationMin: route.durationMin, + }); + quotedFare = q.quote.total; + currency = q.quote.currency; + tariffId = q.tariffId; + tariffVersion = q.version; + } catch { + this.logger.warn(`no tariff for ${city}/${serviceClass} — trip without quote`); + } + + let trip = this.trips.create({ + tenant_id: tenantId, + rider_id: riderId, + service_class: serviceClass, + city, + origin_lat: dto.origin.lat, + origin_lng: dto.origin.lng, + dest_lat: dto.destination.lat, + dest_lng: dto.destination.lng, + status: 'searching', + distance_km: route.distanceKm, + duration_min: route.durationMin, + tariff_id: tariffId, + tariff_version: tariffVersion, + quoted_fare: quotedFare, + currency: currency ?? undefined, + }); + trip = await this.trips.save(trip); + await this.recordEvent(trip, null, 'searching', 'rider'); + + // مطابقة وبث عروض للسائقين القريبين + const nearby = await this.matching.findNearby( + tenantId, + dto.origin.lat, + dto.origin.lng, + ); + for (const n of nearby) { + const driver = await this.drivers.findById(tenantId, n.driverId); + if (driver && driver.verification_status === 'approved') { + this.gateway.offerToDriver(tenantId, driver.user_id, { + tripId: trip.id, + pickup: { lat: trip.origin_lat, lng: trip.origin_lng }, + dropoff: { lat: trip.dest_lat, lng: trip.dest_lng }, + distanceKm: n.distanceKm, + quotedFare, + currency, + }); + } + } + this.gateway.dispatch(tenantId, 'trip:new', { tripId: trip.id }); + + return { trip, offeredDrivers: nearby.length }; + } + + /** قبول سائق للرحلة (أول قبول يفوز). */ + async accept(tenantId: string, tripId: string, driverUserId: string) { + const trip = await this.getOr404(tenantId, tripId); + if (trip.status !== 'searching') { + throw new BadRequestException('Trip is no longer available'); + } + const driver = await this.drivers.findByUser(tenantId, driverUserId); + if (!driver) throw new ForbiddenException('Not a driver'); + if (driver.verification_status !== 'approved') { + throw new ForbiddenException('Driver not approved'); + } + + trip.driver_id = driver.id; + trip.assigned_at = new Date(); + await this.applyTransition(trip, 'assigned', 'driver'); + + this.gateway.tripUpdate(tenantId, trip.rider_id, { + tripId: trip.id, + status: 'assigned', + driverId: driver.id, + }); + return trip; + } + + /** تحديث حالة الرحلة (سائق/راكب) عبر آلة الحالة. */ + async updateStatus( + tenantId: string, + tripId: string, + toStatus: TripStatus, + actor: 'rider' | 'driver', + ) { + const trip = await this.getOr404(tenantId, tripId); + if (toStatus === 'completed') trip.completed_at = new Date(); + if (toStatus === 'completed' && trip.quoted_fare != null) { + trip.final_fare = trip.quoted_fare; // P1: النهائي = المقفول + } + await this.applyTransition(trip, toStatus, actor); + + // بث لطرفَي الرحلة + this.gateway.tripUpdate(tenantId, trip.rider_id, { tripId: trip.id, status: toStatus }); + if (trip.driver_id) { + const drv = await this.drivers.findById(tenantId, trip.driver_id); + if (drv) this.gateway.tripUpdate(tenantId, drv.user_id, { tripId: trip.id, status: toStatus }); + } + return trip; + } + + async cancel(tenantId: string, tripId: string, actor: 'rider' | 'driver') { + const trip = await this.getOr404(tenantId, tripId); + await this.applyTransition(trip, 'cancelled', actor); + this.gateway.tripUpdate(tenantId, trip.rider_id, { tripId: trip.id, status: 'cancelled' }); + return trip; + } + + // ---- داخلي ---- + + private async getOr404(tenantId: string, id: string): Promise { + const trip = await this.get(tenantId, id); + if (!trip) throw new NotFoundException('Trip not found'); + return trip; + } + + private async applyTransition(trip: Trip, to: TripStatus, source: string) { + const allowed = TRANSITIONS[trip.status] ?? []; + if (!allowed.includes(to)) { + throw new BadRequestException( + `Invalid transition ${trip.status} -> ${to}`, + ); + } + const from = trip.status; + trip.status = to; + await this.trips.save(trip); + await this.recordEvent(trip, from, to, source); + } + + private async recordEvent( + trip: Trip, + from: string | null, + to: string, + source: string, + ) { + await this.events.save( + this.events.create({ + tenant_id: trip.tenant_id, + trip_id: trip.id, + from_status: from ?? undefined, + to_status: to, + source, + }), + ); + } +} diff --git a/backend/src/modules/users/users.service.ts b/backend/src/modules/users/users.service.ts index e825af5..bfbe673 100644 --- a/backend/src/modules/users/users.service.ts +++ b/backend/src/modules/users/users.service.ts @@ -32,7 +32,16 @@ export class UsersService { id: string, data: Partial>, ): Promise { - await this.userRepository.update({ tenant_id: tenantId, id }, { name: data.name }); + if (data.name !== undefined) { + await this.userRepository.update({ tenant_id: tenantId, id }, { name: data.name }); + } return this.findById(tenantId, id); } + + async setRole(tenantId: string, id: string, role: string): Promise { + await this.userRepository.update( + { tenant_id: tenantId, id }, + { role: role as UserRole }, + ); + } } diff --git a/backend/src/realtime/realtime.gateway.ts b/backend/src/realtime/realtime.gateway.ts new file mode 100644 index 0000000..df1d1f0 --- /dev/null +++ b/backend/src/realtime/realtime.gateway.ts @@ -0,0 +1,57 @@ +import { + OnGatewayConnection, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Server, Socket } from 'socket.io'; + +/** + * البوابة الحية (Socket.IO) — docs/09. المصادقة على الاتصال بـ JWT، + * والانضمام لغرف مُنطَّقة بالمستأجر (لا تسريب بين المستأجرين). + * غرف: tenant:{id}:driver:{driverId} · tenant:{id}:trip:{tripId} · tenant:{id}:dispatch + */ +@WebSocketGateway({ cors: true }) +export class RealtimeGateway implements OnGatewayConnection { + private readonly logger = new Logger('Realtime'); + + @WebSocketServer() + server: Server; + + constructor(private readonly jwt: JwtService) {} + + handleConnection(client: Socket) { + try { + const token = + (client.handshake.auth?.token as string) || + (client.handshake.headers?.authorization as string)?.replace('Bearer ', ''); + if (!token) throw new Error('no token'); + const p: any = this.jwt.verify(token); + const tenantId = p.tenant_id; + client.data.user = { userId: p.sub, tenantId, role: p.role }; + client.join(`tenant:${tenantId}:user:${p.sub}`); + if (p.role === 'driver') client.join(`tenant:${tenantId}:drivers`); + this.logger.debug(`connected user=${p.sub} tenant=${tenantId} role=${p.role}`); + } catch { + client.disconnect(true); + } + } + + // ---- مساعدات البث التي تستدعيها الخدمات ---- + + /** عرض رحلة لسائق محدد. */ + offerToDriver(tenantId: string, driverUserId: string, payload: any) { + this.server.to(`tenant:${tenantId}:user:${driverUserId}`).emit('trip:offer', payload); + } + + /** تحديث حالة الرحلة للراكب أو السائق. */ + tripUpdate(tenantId: string, userId: string, payload: any) { + this.server.to(`tenant:${tenantId}:user:${userId}`).emit('trip:update', payload); + } + + /** بث للوحة المشغّل. */ + dispatch(tenantId: string, event: string, payload: any) { + this.server.to(`tenant:${tenantId}:dispatch`).emit(event, payload); + } +} diff --git a/backend/src/realtime/realtime.module.ts b/backend/src/realtime/realtime.module.ts new file mode 100644 index 0000000..a96bcca --- /dev/null +++ b/backend/src/realtime/realtime.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { RealtimeGateway } from './realtime.gateway'; + +@Module({ + imports: [ + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (cfg: ConfigService) => ({ + secret: cfg.get('JWT_SECRET') || 'change_me_jwt_secret', + }), + }), + ], + providers: [RealtimeGateway], + exports: [RealtimeGateway], +}) +export class RealtimeModule {}