From 65a2e5e275b5e41357c2aac833287c108689f529 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 16 Jul 2026 17:12:38 +0300 Subject: [PATCH] =?UTF-8?q?P1=20complete:=20real=20=D8=A7=D9=86=D8=B7?= =?UTF-8?q?=D9=84=D9=82=20maps=20+=20Nabeh=20WhatsApp=20OTP=20+=20chat=20+?= =?UTF-8?q?=20ratings=20+=20cancel(both)=20+=20fraud=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - maps: real map-saas route/geocode/reverse/places (x-api-key per country) + straight-line fallback - auth: Redis-backed OTP + Nabeh WhatsApp send (dev mode keeps fixed 1234 bypass) - chat: per-trip messages (participant-guarded) + socket broadcast - ratings: post-trip rating + driver avg recompute + no double-rate - cancel: from rider or driver + stage-based cancel fee + notify both - fraud: cancel-abuse (soft/hard block via Redis), arrived-far-from-pickup, completed-too-fast → fraud_flags - migration InitP1b (chat_messages, ratings, fraud_flags, trips.cancelled_by/cancel_fee) Co-Authored-By: Claude Opus 4.8 --- backend/.env.example | 15 ++- backend/src/app.module.ts | 10 +- backend/src/config/configuration.ts | 19 +++ .../migrations/1721400000000-InitP1b.ts | 59 +++++++++ .../src/integrations/nabeh/nabeh.module.ts | 9 ++ .../src/integrations/nabeh/nabeh.service.ts | 74 +++++++++++ backend/src/modules/auth/auth.service.ts | 89 +++++++++---- backend/src/modules/chat/chat.controller.ts | 27 ++++ backend/src/modules/chat/chat.module.ts | 20 +++ backend/src/modules/chat/chat.service.ts | 76 +++++++++++ .../chat/entities/chat-message.entity.ts | 33 +++++ .../src/modules/drivers/drivers.service.ts | 7 ++ .../fraud/entities/fraud-flag.entity.ts | 36 ++++++ backend/src/modules/fraud/fraud.module.ts | 12 ++ backend/src/modules/fraud/fraud.service.ts | 119 ++++++++++++++++++ backend/src/modules/maps/maps.controller.ts | 38 +++++- backend/src/modules/maps/maps.service.ts | 112 ++++++++++++++--- .../modules/ratings/entities/rating.entity.ts | 40 ++++++ .../src/modules/ratings/ratings.controller.ts | 23 ++++ backend/src/modules/ratings/ratings.module.ts | 14 +++ .../src/modules/ratings/ratings.service.ts | 81 ++++++++++++ .../src/modules/trips/entities/trip.entity.ts | 6 + backend/src/modules/trips/trips.controller.ts | 2 +- backend/src/modules/trips/trips.module.ts | 2 + backend/src/modules/trips/trips.service.ts | 78 +++++++++++- 25 files changed, 948 insertions(+), 53 deletions(-) create mode 100644 backend/src/database/migrations/1721400000000-InitP1b.ts create mode 100644 backend/src/integrations/nabeh/nabeh.module.ts create mode 100644 backend/src/integrations/nabeh/nabeh.service.ts create mode 100644 backend/src/modules/chat/chat.controller.ts create mode 100644 backend/src/modules/chat/chat.module.ts create mode 100644 backend/src/modules/chat/chat.service.ts create mode 100644 backend/src/modules/chat/entities/chat-message.entity.ts create mode 100644 backend/src/modules/fraud/entities/fraud-flag.entity.ts create mode 100644 backend/src/modules/fraud/fraud.module.ts create mode 100644 backend/src/modules/fraud/fraud.service.ts create mode 100644 backend/src/modules/ratings/entities/rating.entity.ts create mode 100644 backend/src/modules/ratings/ratings.controller.ts create mode 100644 backend/src/modules/ratings/ratings.module.ts create mode 100644 backend/src/modules/ratings/ratings.service.ts diff --git a/backend/.env.example b/backend/.env.example index 191d694..573bca0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -27,6 +27,19 @@ JWT_SECRET=change_me_jwt_secret JWT_EXPIRES=15m JWT_REFRESH_EXPIRES=30d -# ---- Maps (انطلق) ---- +# ---- Maps (انطلق / map-saas) ---- MAPS_TILES_URL=http://martin:3000 MAPS_PROVIDER=antlaq +MAPS_BASE_URL=https://map-saas.intaleqapp.com +# مفاتيح انطلق لكل دولة (ضع القيم الحقيقية في .env على السيرفر — لا تُرفع لـ git) +MAPS_API_KEY_JO= +MAPS_API_KEY_SY= +MAPS_PLACES_API_KEY= + +# ---- OTP via Nabeh (WhatsApp) ---- +# اتركه dev لتفعيل الرمز الثابت 1234 بلا إرسال؛ اجعله false للإرسال الحقيقي عبر Nabeh +OTP_DEV_MODE=true +NABEH_BASE_URL=https://nabeh.intaleqapp.com +NABEH_EMAIL= +NABEH_PASSWORD= +NABEH_OTP_TYPE=text diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index bf87010..6287a6e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -16,6 +16,10 @@ 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'; +import { FraudModule } from './modules/fraud/fraud.module'; +import { ChatModule } from './modules/chat/chat.module'; +import { RatingsModule } from './modules/ratings/ratings.module'; +import { NabehModule } from './integrations/nabeh/nabeh.module'; @Module({ imports: [ @@ -39,7 +43,8 @@ import { RealtimeModule } from './realtime/realtime.module'; ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }]), - RedisModule, // عالمي — يوفّر عميل Redis للمطابقة و OTP لاحقاً + RedisModule, // عالمي — عميل Redis للمطابقة و OTP + NabehModule, // عالمي — إرسال OTP واتساب HealthModule, TenantsModule, @@ -50,7 +55,10 @@ import { RealtimeModule } from './realtime/realtime.module'; TariffModule, DriversModule, RealtimeModule, + FraudModule, TripsModule, + ChatModule, + RatingsModule, SeedModule, ], }) diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index 6e43b01..79418f5 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -45,5 +45,24 @@ export default () => ({ maps: { tilesUrl: process.env.MAPS_TILES_URL ?? 'http://martin:3000', provider: process.env.MAPS_PROVIDER ?? 'antlaq', + // خرائط انطلق (map-saas). مفتاح افتراضي + مفاتيح لكل دولة. + baseUrl: process.env.MAPS_BASE_URL ?? 'https://map-saas.intaleqapp.com', + apiKey: process.env.MAPS_API_KEY ?? '', + apiKeyByCountry: { + jo: process.env.MAPS_API_KEY_JO ?? process.env.MAPS_API_KEY ?? '', + sy: process.env.MAPS_API_KEY_SY ?? process.env.MAPS_API_KEY ?? '', + } as Record, + placesApiKey: process.env.MAPS_PLACES_API_KEY ?? process.env.MAPS_API_KEY ?? '', }, + + // Nabeh — إرسال OTP عبر واتساب. + nabeh: { + baseUrl: process.env.NABEH_BASE_URL ?? 'https://nabeh.intaleqapp.com', + email: process.env.NABEH_EMAIL ?? '', + password: process.env.NABEH_PASSWORD ?? '', + otpType: process.env.NABEH_OTP_TYPE ?? 'text', + }, + + // رموز الاتصال الدولية لكل country pack (لتنسيق الهاتف قبل الإرسال). + callingCodes: { jo: '962', sy: '963' } as Record, }); diff --git a/backend/src/database/migrations/1721400000000-InitP1b.ts b/backend/src/database/migrations/1721400000000-InitP1b.ts new file mode 100644 index 0000000..923e3e0 --- /dev/null +++ b/backend/src/database/migrations/1721400000000-InitP1b.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * P1b: chat_messages, ratings, fraud_flags + أعمدة إلغاء على trips. + */ +export class InitP1b1721400000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS cancelled_by varchar`); + await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS cancel_fee numeric(12,3)`); + + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_chat_messages ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL, + trip_id uuid NOT NULL, + sender_id uuid NOT NULL, + sender_role varchar NOT NULL, + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + )`); + await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_chat_trip" ON tripz_chat_messages (tenant_id, trip_id)`); + + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_ratings ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL, + trip_id uuid NOT NULL, + by_user_id uuid NOT NULL, + by_role varchar NOT NULL, + target_driver_id uuid, + stars int NOT NULL, + comment text, + created_at timestamptz NOT NULL DEFAULT now() + )`); + await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_ratings_trip" ON tripz_ratings (tenant_id, trip_id)`); + await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_ratings_driver" ON tripz_ratings (tenant_id, target_driver_id)`); + + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_fraud_flags ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL, + subject_type varchar NOT NULL, + subject_id uuid NOT NULL, + trip_id uuid, + reason varchar NOT NULL, + meta jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now() + )`); + await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_fraud_subject" ON tripz_fraud_flags (tenant_id, subject_type, subject_id)`); + } + + public async down(q: QueryRunner): Promise { + await q.query(`DROP TABLE IF EXISTS tripz_fraud_flags`); + await q.query(`DROP TABLE IF EXISTS tripz_ratings`); + await q.query(`DROP TABLE IF EXISTS tripz_chat_messages`); + await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS cancel_fee`); + await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS cancelled_by`); + } +} diff --git a/backend/src/integrations/nabeh/nabeh.module.ts b/backend/src/integrations/nabeh/nabeh.module.ts new file mode 100644 index 0000000..9dd7b9c --- /dev/null +++ b/backend/src/integrations/nabeh/nabeh.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { NabehService } from './nabeh.service'; + +@Global() +@Module({ + providers: [NabehService], + exports: [NabehService], +}) +export class NabehModule {} diff --git a/backend/src/integrations/nabeh/nabeh.service.ts b/backend/src/integrations/nabeh/nabeh.service.ts new file mode 100644 index 0000000..c403783 --- /dev/null +++ b/backend/src/integrations/nabeh/nabeh.service.ts @@ -0,0 +1,74 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +/** + * محوّل Nabeh لإرسال OTP عبر واتساب. + * التدفق: login (يُخزَّن التوكن) ثم otp/send برمزنا نحن. + */ +@Injectable() +export class NabehService { + private readonly logger = new Logger('Nabeh'); + private token: string | null = null; + private tokenExp = 0; + + constructor(private readonly config: ConfigService) {} + + private get base() { + return this.config.get('nabeh.baseUrl'); + } + + private async ensureToken(): Promise { + const now = Math.floor(Date.now() / 1000); + if (this.token && now < this.tokenExp - 60) return this.token; + + const res = await fetch(`${this.base}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: this.config.get('nabeh.email'), + password: this.config.get('nabeh.password'), + }), + }); + if (!res.ok) throw new Error(`Nabeh login failed: ${res.status}`); + const data: any = await res.json(); + const token = data.token || data.access_token || data.data?.token; + if (!token) throw new Error('Nabeh login: no token in response'); + this.token = token; + // exp من JWT إن وُجد، وإلا افتراض ساعة + this.tokenExp = NabehService.jwtExp(token) ?? now + 3600; + return token; + } + + /** يرسل الرمز عبر واتساب. phone بصيغة دولية بلا + (مثل 9627xxxxxxx). */ + async sendOtp(phone: string, code: string): Promise { + const token = await this.ensureToken(); + const res = await fetch(`${this.base}/api/otp/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + phone, + type: this.config.get('nabeh.otpType') ?? 'text', + code, + }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Nabeh otp/send failed: ${res.status} ${body}`); + } + this.logger.log(`OTP sent via WhatsApp to ${phone}`); + } + + private static jwtExp(token: string): number | null { + try { + const payload = JSON.parse( + Buffer.from(token.split('.')[1], 'base64').toString('utf8'), + ); + return typeof payload.exp === 'number' ? payload.exp : null; + } catch { + return null; + } + } +} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 565a98d..711a4a9 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -1,9 +1,13 @@ -import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { REDIS } from '../../common/redis/redis.module'; import { UsersService } from '../users/users.service'; import { User } from '../users/entities/user.entity'; import { TenantsService } from '../tenants/tenants.service'; +import { Tenant } from '../../database/entities/tenant.entity'; +import { NabehService } from '../../integrations/nabeh/nabeh.service'; @Injectable() export class AuthService { @@ -14,44 +18,78 @@ export class AuthService { private jwtService: JwtService, private config: ConfigService, private tenantsService: TenantsService, + private nabeh: NabehService, + @Inject(REDIS) private readonly redis: Redis, ) {} - /** يحوّل الـ slug القادم من الهيدر إلى UUID المستأجر (tenant_id). */ - private async resolveTenantId(tenantSlugOrId: string): Promise { - const tenant = await this.tenantsService.resolve(tenantSlugOrId); + private async resolveTenant(slugOrId: string): Promise { + const tenant = await this.tenantsService.resolve(slugOrId); if (!tenant) throw new UnauthorizedException('Unknown tenant'); - return tenant.id; + return tenant; } - private get devCode(): string { - // رمز التطوير الثابت — يُستبدل بمحوّل SMS في P2 (راجع docs/07). - return '1234'; + private get devMode(): boolean { + return this.config.get('auth.otpDevMode') !== false; } - async sendOtp(tenantId: string, phone: string) { - // وضع تطوير: الرمز ثابت ويُطبع باللوغ بلا مزوّد SMS. - this.logger.log(`OTP for tenant=${tenantId} phone=${phone} => ${this.devCode} (dev)`); - return { - success: true, - message: 'OTP sent', - dev_code: this.config.get('auth.otpDevMode') ? this.devCode : undefined, - }; + private otpKey(tenantId: string, phone: string): string { + return `otp:${tenantId}:${phone}`; + } + + private genCode(): string { + if (this.devMode) return '1234'; + const len = this.config.get('auth.otpLength') ?? 4; + let c = ''; + for (let i = 0; i < len; i++) c += Math.floor(Math.random() * 10); + return c; + } + + /** يصيغ الهاتف لصيغة دولية بلا + (مثل 962790000000) حسب دولة المستأجر. */ + private formatPhone(phone: string, countryPack: string): string { + const codes = this.config.get>('callingCodes') ?? {}; + const cc = codes[countryPack] ?? '962'; + let p = phone.replace(/\D/g, ''); + if (p.startsWith('00')) p = p.slice(2); + if (p.startsWith('0')) p = cc + p.slice(1); + else if (!p.startsWith(cc)) p = cc + p; + return p; + } + + async sendOtp(tenantSlug: string, phone: string) { + const tenant = await this.resolveTenant(tenantSlug); + const code = this.genCode(); + const ttl = this.config.get('auth.otpTtl') ?? 300; + await this.redis.set(this.otpKey(tenant.id, phone), code, 'EX', ttl); + + if (this.devMode) { + this.logger.log(`OTP (dev) tenant=${tenant.slug} phone=${phone} => ${code}`); + return { success: true, message: 'OTP sent (dev)', dev_code: code }; + } + + // إرسال حقيقي عبر واتساب (Nabeh) + const intl = this.formatPhone(phone, tenant.countryPack); + await this.nabeh.sendOtp(intl, code); + return { success: true, message: 'OTP sent via WhatsApp' }; } async verifyOtp(tenantSlug: string, phone: string, code: string) { - if (code !== this.devCode) { - throw new UnauthorizedException('Invalid OTP code'); + const tenant = await this.resolveTenant(tenantSlug); + + // في وضع التطوير: الرمز الثابت 1234 يمرّ دائماً (تسهيل الاختبار). + const devBypass = this.devMode && code === '1234'; + if (!devBypass) { + const stored = await this.redis.get(this.otpKey(tenant.id, phone)); + if (!stored || stored !== code) { + throw new UnauthorizedException('Invalid or expired OTP code'); + } + await this.redis.del(this.otpKey(tenant.id, phone)); } - // الهيدر يحمل slug — نحوّله لـ UUID قبل أي استعلام على tenant_id. - const tenantId = await this.resolveTenantId(tenantSlug); - - let user = await this.usersService.findByPhone(tenantId, phone); + let user = await this.usersService.findByPhone(tenant.id, phone); if (!user) { - user = await this.usersService.create(tenantId, phone); + user = await this.usersService.create(tenant.id, phone); } - - return this.issueTokens(user, tenantId); + return this.issueTokens(user, tenant.id); } async refresh(refreshToken: string) { @@ -69,7 +107,6 @@ export class AuthService { return this.issueTokens(user, user.tenant_id); } - /** يصدر access + refresh معاً. */ private issueTokens(user: User, tenantId: string) { const base = { sub: user.id, diff --git a/backend/src/modules/chat/chat.controller.ts b/backend/src/modules/chat/chat.controller.ts new file mode 100644 index 0000000..f5378fa --- /dev/null +++ b/backend/src/modules/chat/chat.controller.ts @@ -0,0 +1,27 @@ +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ChatService } from './chat.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; + +@ApiTags('chat') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('trips') +export class ChatController { + constructor(private readonly chat: ChatService) {} + + @Post(':id/messages') + send( + @CurrentUser() user: AuthUser, + @Param('id') tripId: string, + @Body('body') body: string, + ) { + return this.chat.send(user.tenantId, tripId, user.userId, body); + } + + @Get(':id/messages') + list(@CurrentUser() user: AuthUser, @Param('id') tripId: string) { + return this.chat.list(user.tenantId, tripId, user.userId); + } +} diff --git a/backend/src/modules/chat/chat.module.ts b/backend/src/modules/chat/chat.module.ts new file mode 100644 index 0000000..e62f326 --- /dev/null +++ b/backend/src/modules/chat/chat.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ChatMessage } from './entities/chat-message.entity'; +import { ChatService } from './chat.service'; +import { ChatController } from './chat.controller'; +import { TripsModule } from '../trips/trips.module'; +import { DriversModule } from '../drivers/drivers.module'; +import { RealtimeModule } from '../../realtime/realtime.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ChatMessage]), + TripsModule, + DriversModule, + RealtimeModule, + ], + controllers: [ChatController], + providers: [ChatService], +}) +export class ChatModule {} diff --git a/backend/src/modules/chat/chat.service.ts b/backend/src/modules/chat/chat.service.ts new file mode 100644 index 0000000..278770c --- /dev/null +++ b/backend/src/modules/chat/chat.service.ts @@ -0,0 +1,76 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ChatMessage } from './entities/chat-message.entity'; +import { TripsService } from '../trips/trips.service'; +import { DriversService } from '../drivers/drivers.service'; +import { RealtimeGateway } from '../../realtime/realtime.gateway'; + +@Injectable() +export class ChatService { + constructor( + @InjectRepository(ChatMessage) private readonly repo: Repository, + private readonly trips: TripsService, + private readonly drivers: DriversService, + private readonly gateway: RealtimeGateway, + ) {} + + /** يحدد دور المستخدم في الرحلة (rider/driver) أو null إن لم يكن طرفاً. */ + private async participant( + tenantId: string, + tripId: string, + userId: string, + ): Promise<{ role: 'rider' | 'driver'; riderId: string; driverUserId?: string }> { + const trip = await this.trips.get(tenantId, tripId); + if (!trip) throw new NotFoundException('Trip not found'); + if (trip.rider_id === userId) return { role: 'rider', riderId: trip.rider_id }; + if (trip.driver_id) { + const drv = await this.drivers.findById(tenantId, trip.driver_id); + if (drv && drv.user_id === userId) { + return { role: 'driver', riderId: trip.rider_id, driverUserId: drv.user_id }; + } + } + throw new ForbiddenException('Not a participant of this trip'); + } + + async send(tenantId: string, tripId: string, userId: string, body: string) { + if (!body?.trim()) throw new BadRequestException('body is required'); + const p = await this.participant(tenantId, tripId, userId); + const msg = await this.repo.save( + this.repo.create({ + tenant_id: tenantId, + trip_id: tripId, + sender_id: userId, + sender_role: p.role, + body: body.trim(), + }), + ); + + // بث للطرف الآخر + const trip = await this.trips.get(tenantId, tripId); + const targets: string[] = []; + if (p.role === 'rider' && trip?.driver_id) { + const drv = await this.drivers.findById(tenantId, trip.driver_id); + if (drv) targets.push(drv.user_id); + } else if (p.role === 'driver') { + targets.push(p.riderId); + } + for (const t of targets) { + this.gateway.tripUpdate(tenantId, t, { type: 'chat', tripId, message: msg }); + } + return msg; + } + + async list(tenantId: string, tripId: string, userId: string) { + await this.participant(tenantId, tripId, userId); + return this.repo.find({ + where: { tenant_id: tenantId, trip_id: tripId }, + order: { created_at: 'ASC' }, + }); + } +} diff --git a/backend/src/modules/chat/entities/chat-message.entity.ts b/backend/src/modules/chat/entities/chat-message.entity.ts new file mode 100644 index 0000000..80233a4 --- /dev/null +++ b/backend/src/modules/chat/entities/chat-message.entity.ts @@ -0,0 +1,33 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** رسالة دردشة داخل الرحلة. الجدول: tripz_chat_messages. */ +@Entity('chat_messages') +@Index(['tenant_id', 'trip_id']) +export class ChatMessage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + trip_id: string; + + @Column({ type: 'uuid' }) + sender_id: string; + + @Column() + sender_role: string; // rider | driver + + @Column({ type: 'text' }) + body: string; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/modules/drivers/drivers.service.ts b/backend/src/modules/drivers/drivers.service.ts index 64daf69..f2f40c5 100644 --- a/backend/src/modules/drivers/drivers.service.ts +++ b/backend/src/modules/drivers/drivers.service.ts @@ -48,6 +48,13 @@ export class DriversService { return driver; } + async setRating(tenantId: string, driverId: string, rating: number): Promise { + await this.repo.update( + { tenant_id: tenantId, id: driverId }, + { rating: Number(rating.toFixed(2)) }, + ); + } + async approve(tenantId: string, driverId: string): Promise { const driver = await this.findById(tenantId, driverId); if (!driver) throw new NotFoundException('Driver not found'); diff --git a/backend/src/modules/fraud/entities/fraud-flag.entity.ts b/backend/src/modules/fraud/entities/fraud-flag.entity.ts new file mode 100644 index 0000000..ea9a153 --- /dev/null +++ b/backend/src/modules/fraud/entities/fraud-flag.entity.ts @@ -0,0 +1,36 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** سجل بلاغات الاحتيال/الإساءة. الجدول: tripz_fraud_flags. */ +@Entity('fraud_flags') +@Index(['tenant_id', 'subject_type', 'subject_id']) +export class FraudFlag { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column() + subject_type: string; // rider | driver + + @Column({ type: 'uuid' }) + subject_id: string; // user id + + @Column({ type: 'uuid', nullable: true }) + trip_id: string | null; + + @Column() + reason: string; + + @Column({ type: 'jsonb', default: {} }) + meta: Record; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/modules/fraud/fraud.module.ts b/backend/src/modules/fraud/fraud.module.ts new file mode 100644 index 0000000..782bcf5 --- /dev/null +++ b/backend/src/modules/fraud/fraud.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FraudFlag } from './entities/fraud-flag.entity'; +import { FraudService } from './fraud.service'; +import { MapsModule } from '../maps/maps.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([FraudFlag]), MapsModule], + providers: [FraudService], + exports: [FraudService], +}) +export class FraudModule {} diff --git a/backend/src/modules/fraud/fraud.service.ts b/backend/src/modules/fraud/fraud.service.ts new file mode 100644 index 0000000..a11ad6a --- /dev/null +++ b/backend/src/modules/fraud/fraud.service.ts @@ -0,0 +1,119 @@ +import { ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import Redis from 'ioredis'; +import { REDIS } from '../../common/redis/redis.module'; +import { FraudFlag } from './entities/fraud-flag.entity'; +import { MapsService } from '../maps/maps.service'; + +/** + * كشف الاحتيال/الإساءة من الطرفين (docs/13). حدود قابلة للضبط: + * - إلغاء متكرر (راكب/سائق) خلال نافذة زمنية. + * - "وصل" بينما السائق بعيد عن نقطة الالتقاط (تزوير). + * - إنهاء رحلة سريع جداً مقارنة بالمسافة. + */ +@Injectable() +export class FraudService { + private readonly logger = new Logger('Fraud'); + + // حدود (يمكن نقلها للإعداد لاحقاً) + private readonly CANCEL_WINDOW_SEC = 3600; + private readonly CANCEL_SOFT = 3; // بلاغ + private readonly CANCEL_HARD = 6; // حظر مؤقت + private readonly ARRIVED_MAX_M = 350; // أقصى بعد مقبول عند "وصل" + private readonly FAST_MIN_SEC = 30; // إنهاء أسرع من هذا يُشتبه به + + constructor( + @InjectRepository(FraudFlag) private readonly repo: Repository, + @Inject(REDIS) private readonly redis: Redis, + private readonly maps: MapsService, + ) {} + + async flag( + tenantId: string, + subjectType: 'rider' | 'driver', + subjectId: string, + reason: string, + meta: Record = {}, + tripId?: string, + ) { + await this.repo.save( + this.repo.create({ + tenant_id: tenantId, + subject_type: subjectType, + subject_id: subjectId, + trip_id: tripId ?? null, + reason, + meta, + }), + ); + this.logger.warn(`FLAG ${subjectType}=${subjectId} reason=${reason}`); + } + + /** يسجّل إلغاءً؛ يرمي عند تجاوز الحد الصارم، ويبلّغ عند الحد الليّن. */ + async recordCancellation( + tenantId: string, + subjectType: 'rider' | 'driver', + userId: string, + tripId?: string, + ) { + const key = `cancels:${tenantId}:${userId}`; + const count = await this.redis.incr(key); + if (count === 1) await this.redis.expire(key, this.CANCEL_WINDOW_SEC); + + if (count >= this.CANCEL_HARD) { + await this.flag(tenantId, subjectType, userId, 'cancel_abuse_hard', { count }, tripId); + throw new ForbiddenException('Too many cancellations — temporarily blocked'); + } + if (count >= this.CANCEL_SOFT) { + await this.flag(tenantId, subjectType, userId, 'cancel_abuse_soft', { count }, tripId); + } + } + + /** يتحقق من قرب السائق عند "وصل"؛ يبلّغ إن كان بعيداً. */ + async checkArrivedProximity( + tenantId: string, + driverUserId: string, + driver: { last_lat?: number | null; last_lng?: number | null }, + pickup: { lat: number; lng: number }, + tripId: string, + ) { + if (driver.last_lat == null || driver.last_lng == null) return; + const km = MapsService.haversineKm( + { lat: driver.last_lat, lng: driver.last_lng }, + pickup, + ); + if (km * 1000 > this.ARRIVED_MAX_M) { + await this.flag( + tenantId, + 'driver', + driverUserId, + 'arrived_far_from_pickup', + { meters: Math.round(km * 1000) }, + tripId, + ); + } + } + + /** يتحقق من إنهاء سريع مشبوه. */ + async checkFastCompletion( + tenantId: string, + driverUserId: string, + startedAt: Date | null, + distanceKm: number | null, + tripId: string, + ) { + if (!startedAt) return; + const sec = (Date.now() - startedAt.getTime()) / 1000; + if (sec < this.FAST_MIN_SEC && (distanceKm ?? 0) > 1) { + await this.flag( + tenantId, + 'driver', + driverUserId, + 'completed_too_fast', + { seconds: Math.round(sec), distanceKm }, + tripId, + ); + } + } +} diff --git a/backend/src/modules/maps/maps.controller.ts b/backend/src/modules/maps/maps.controller.ts index e547f42..222dbf2 100644 --- a/backend/src/modules/maps/maps.controller.ts +++ b/backend/src/modules/maps/maps.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Query, BadRequestException } from '@nestjs/common'; +import { Body, Controller, Get, Post, Query, BadRequestException } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { MapsService } from './maps.service'; @@ -7,21 +7,53 @@ import { MapsService } from './maps.service'; 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, + @Query('country') country?: 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'); + throw new BadRequestException('fromLat, fromLng, toLat, toLng required'); } return this.maps.route( { lat: nums[0], lng: nums[1] }, { lat: nums[2], lng: nums[3] }, + country, ); } + + @Get('geocode') + geocode( + @Query('q') q: string, + @Query('country') country: string, + @Query('radius') radius?: string, + @Query('lat') lat?: string, + @Query('lng') lng?: string, + ) { + if (!q || !country) throw new BadRequestException('q and country required'); + return this.maps.geocodeSearch(q, country, { + radius: radius ? Number(radius) : undefined, + lat: lat ? Number(lat) : undefined, + lng: lng ? Number(lng) : undefined, + }); + } + + @Get('reverse') + reverse( + @Query('lat') lat: string, + @Query('lng') lng: string, + @Query('country') country?: string, + ) { + if (!lat || !lng) throw new BadRequestException('lat and lng required'); + return this.maps.reverse(Number(lat), Number(lng), country); + } + + @Post('places') + addPlace(@Body() body: any) { + return this.maps.addPlace(body); + } } diff --git a/backend/src/modules/maps/maps.service.ts b/backend/src/modules/maps/maps.service.ts index 997bff7..f5a0df6 100644 --- a/backend/src/modules/maps/maps.service.ts +++ b/backend/src/modules/maps/maps.service.ts @@ -1,4 +1,5 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; export interface LatLng { lat: number; @@ -12,38 +13,119 @@ export interface RouteResult { } /** - * وكيل الخرائط. الهدف النهائي: توجيه/ترميز من خرائط انطلق الذاتية (docs/07). - * حالياً (P1) توجيه تقديري بخط الطول الجغرافي (haversine) + سرعة متوسطة — - * يفكّ ارتباط بقية النظام (التعرفة/الرحلات) عن تفاصيل انطلق حتى نربطها. - * الاستبدال لاحقاً = تنفيذ route() عبر واجهة انطلق دون تغيير من يستدعيها. + * وكيل خرائط انطلق (map-saas). التوجيه/الترميز من واجهات انطلق الحقيقية، + * مع رجوع آمن لخط مستقيم (haversine) عند فشل الاتصال حتى لا تتعطّل الرحلات. */ @Injectable() export class MapsService { - private readonly avgSpeedKmh = 30; // تقدير حضري + private readonly logger = new Logger('Maps'); + private readonly avgSpeedKmh = 30; - route(from: LatLng, to: LatLng): RouteResult { + constructor(private readonly config: ConfigService) {} + + private get base(): string { + return this.config.get('maps.baseUrl') ?? 'https://map-saas.intaleqapp.com'; + } + + private keyFor(country?: string): string { + const byCountry = this.config.get>('maps.apiKeyByCountry') ?? {}; + return (country && byCountry[country]) || this.config.get('maps.apiKey') || ''; + } + + private headers(key: string): Record { + return { 'x-api-key': key, 'Content-Type': 'application/json' }; + } + + /** توجيه حقيقي من انطلق مع رجوع آمن لخط مستقيم. */ + async route(from: LatLng, to: LatLng, country?: string): Promise { + const key = this.keyFor(country); + if (key) { + try { + const url = + `${this.base}/api/maps/route?fromLat=${from.lat}&fromLng=${from.lng}` + + `&toLat=${to.lat}&toLng=${to.lng}&steps=false&locale=ar`; + const res = await fetch(url, { headers: this.headers(key) }); + if (res.ok) { + const json: any = await res.json(); + const parsed = MapsService.parseRoute(json); + if (parsed) return { ...parsed, provider: 'antlaq' }; + } else { + this.logger.warn(`antlaq route ${res.status} — fallback`); + } + } catch (e: any) { + this.logger.warn(`antlaq route error: ${e?.message} — fallback`); + } + } + return this.straightLine(from, to); + } + + async geocodeSearch(q: string, country: string, opts?: { radius?: number; lat?: number; lng?: number }) { + const key = this.keyFor(country); + const params = new URLSearchParams({ q, country }); + if (opts?.radius) params.set('radius', String(opts.radius)); + if (opts?.lat != null) params.set('lat', String(opts.lat)); + if (opts?.lng != null) params.set('lng', String(opts.lng)); + const res = await fetch(`${this.base}/api/geocoding/search?${params}`, { + headers: this.headers(key), + }); + return res.json(); + } + + async reverse(lat: number, lng: number, country?: string) { + const key = this.keyFor(country); + const res = await fetch( + `${this.base}/api/geocoding/reverse?lat=${lat}&lng=${lng}`, + { headers: this.headers(key) }, + ); + return res.json(); + } + + async addPlace(body: any) { + const key = this.config.get('maps.placesApiKey') || this.keyFor(body?.country); + const res = await fetch(`${this.base}/api/geocoding/places`, { + method: 'POST', + headers: this.headers(key), + body: JSON.stringify(body), + }); + return res.json(); + } + + private straightLine(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' + durationMin: Number(((distanceKm / this.avgSpeedKmh) * 60).toFixed(1)), + provider: 'straight-line', }; } + /** يستخرج المسافة/الزمن من أشكال استجابة محتملة (OSRM-like أو مخصّصة). */ + private static parseRoute(json: any): { distanceKm: number; durationMin: number } | null { + const r = json?.routes?.[0] ?? json?.data?.routes?.[0] ?? json?.data ?? json; + const meters = r?.distance ?? r?.distanceMeters ?? r?.distance_m; + const seconds = r?.duration ?? r?.durationSeconds ?? r?.duration_s; + if (typeof meters === 'number' && typeof seconds === 'number') { + return { + distanceKm: Number((meters / 1000).toFixed(3)), + durationMin: Number((seconds / 60).toFixed(1)), + }; + } + return null; + } + 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); + Math.sin(dLng / 2) ** 2 * + Math.cos(MapsService.rad(a.lat)) * + Math.cos(MapsService.rad(b.lat)); return 2 * R * Math.asin(Math.sqrt(h)); } - private static rad(deg: number): number { - return (deg * Math.PI) / 180; + private static rad(d: number): number { + return (d * Math.PI) / 180; } } diff --git a/backend/src/modules/ratings/entities/rating.entity.ts b/backend/src/modules/ratings/entities/rating.entity.ts new file mode 100644 index 0000000..5d3dd33 --- /dev/null +++ b/backend/src/modules/ratings/entities/rating.entity.ts @@ -0,0 +1,40 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +/** تقييم بعد الرحلة (من الراكب أو السائق). الجدول: tripz_ratings. */ +@Entity('ratings') +@Index(['tenant_id', 'trip_id']) +@Index(['tenant_id', 'target_driver_id']) +export class Rating { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + trip_id: string; + + @Column({ type: 'uuid' }) + by_user_id: string; + + @Column() + by_role: string; // rider | driver + + @Column({ type: 'uuid', nullable: true }) + target_driver_id: string | null; + + @Column({ type: 'int' }) + stars: number; + + @Column({ type: 'text', nullable: true }) + comment: string | null; + + @CreateDateColumn() + created_at: Date; +} diff --git a/backend/src/modules/ratings/ratings.controller.ts b/backend/src/modules/ratings/ratings.controller.ts new file mode 100644 index 0000000..0918838 --- /dev/null +++ b/backend/src/modules/ratings/ratings.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { RatingsService } from './ratings.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator'; + +@ApiTags('ratings') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('trips') +export class RatingsController { + constructor(private readonly ratings: RatingsService) {} + + @Post(':id/rate') + rate( + @CurrentUser() user: AuthUser, + @Param('id') tripId: string, + @Body('stars') stars: number, + @Body('comment') comment?: string, + ) { + return this.ratings.rate(user.tenantId, tripId, user.userId, Number(stars), comment); + } +} diff --git a/backend/src/modules/ratings/ratings.module.ts b/backend/src/modules/ratings/ratings.module.ts new file mode 100644 index 0000000..55f1f1c --- /dev/null +++ b/backend/src/modules/ratings/ratings.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Rating } from './entities/rating.entity'; +import { RatingsService } from './ratings.service'; +import { RatingsController } from './ratings.controller'; +import { TripsModule } from '../trips/trips.module'; +import { DriversModule } from '../drivers/drivers.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Rating]), TripsModule, DriversModule], + controllers: [RatingsController], + providers: [RatingsService], +}) +export class RatingsModule {} diff --git a/backend/src/modules/ratings/ratings.service.ts b/backend/src/modules/ratings/ratings.service.ts new file mode 100644 index 0000000..0536d90 --- /dev/null +++ b/backend/src/modules/ratings/ratings.service.ts @@ -0,0 +1,81 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Rating } from './entities/rating.entity'; +import { TripsService } from '../trips/trips.service'; +import { DriversService } from '../drivers/drivers.service'; + +@Injectable() +export class RatingsService { + constructor( + @InjectRepository(Rating) private readonly repo: Repository, + private readonly trips: TripsService, + private readonly drivers: DriversService, + ) {} + + async rate( + tenantId: string, + tripId: string, + byUserId: string, + stars: number, + comment?: string, + ) { + if (!(stars >= 1 && stars <= 5)) { + throw new BadRequestException('stars must be 1..5'); + } + const trip = await this.trips.get(tenantId, tripId); + if (!trip) throw new NotFoundException('Trip not found'); + if (!['completed', 'paid'].includes(trip.status)) { + throw new BadRequestException('Can only rate a finished trip'); + } + + // تحديد دور المقيِّم + let role: 'rider' | 'driver' | null = null; + if (trip.rider_id === byUserId) role = 'rider'; + else if (trip.driver_id) { + const drv = await this.drivers.findById(tenantId, trip.driver_id); + if (drv && drv.user_id === byUserId) role = 'driver'; + } + if (!role) throw new ForbiddenException('Not a participant of this trip'); + + // منع التقييم المزدوج من نفس الطرف + const dup = await this.repo.findOne({ + where: { tenant_id: tenantId, trip_id: tripId, by_user_id: byUserId }, + }); + if (dup) throw new BadRequestException('Already rated'); + + const targetDriverId = role === 'rider' ? trip.driver_id : null; + const rating = await this.repo.save( + this.repo.create({ + tenant_id: tenantId, + trip_id: tripId, + by_user_id: byUserId, + by_role: role, + target_driver_id: targetDriverId, + stars, + comment: comment ?? null, + }), + ); + + // تحديث متوسط تقييم السائق عند تقييم الراكب له + if (role === 'rider' && targetDriverId) { + const raw = await this.repo + .createQueryBuilder('r') + .select('AVG(r.stars)', 'avg') + .where('r.tenant_id = :t AND r.target_driver_id = :d', { + t: tenantId, + d: targetDriverId, + }) + .getRawOne<{ avg: string }>(); + if (raw?.avg) { + await this.drivers.setRating(tenantId, targetDriverId, Number(raw.avg)); + } + } + return rating; + } +} diff --git a/backend/src/modules/trips/entities/trip.entity.ts b/backend/src/modules/trips/entities/trip.entity.ts index ad8f061..d423f58 100644 --- a/backend/src/modules/trips/entities/trip.entity.ts +++ b/backend/src/modules/trips/entities/trip.entity.ts @@ -82,6 +82,12 @@ export class Trip { @Column({ default: 'cash' }) payment_method: string; + @Column({ nullable: true }) + cancelled_by: string | null; // rider | driver + + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) + cancel_fee: number | null; + @CreateDateColumn() requested_at: Date; diff --git a/backend/src/modules/trips/trips.controller.ts b/backend/src/modules/trips/trips.controller.ts index 3fe3bd2..a4cbc4d 100644 --- a/backend/src/modules/trips/trips.controller.ts +++ b/backend/src/modules/trips/trips.controller.ts @@ -48,6 +48,6 @@ export class TripsController { @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); + return this.trips.cancel(user.tenantId, id, actor, user.userId); } } diff --git a/backend/src/modules/trips/trips.module.ts b/backend/src/modules/trips/trips.module.ts index 8038689..b88740d 100644 --- a/backend/src/modules/trips/trips.module.ts +++ b/backend/src/modules/trips/trips.module.ts @@ -9,6 +9,7 @@ import { TariffModule } from '../tariff/tariff.module'; import { MatchingModule } from '../matching/matching.module'; import { DriversModule } from '../drivers/drivers.module'; import { RealtimeModule } from '../../realtime/realtime.module'; +import { FraudModule } from '../fraud/fraud.module'; @Module({ imports: [ @@ -18,6 +19,7 @@ import { RealtimeModule } from '../../realtime/realtime.module'; MatchingModule, DriversModule, RealtimeModule, + FraudModule, ], controllers: [TripsController], providers: [TripsService], diff --git a/backend/src/modules/trips/trips.service.ts b/backend/src/modules/trips/trips.service.ts index dd3ddc7..d303cef 100644 --- a/backend/src/modules/trips/trips.service.ts +++ b/backend/src/modules/trips/trips.service.ts @@ -14,6 +14,15 @@ import { TariffService } from '../tariff/tariff.service'; import { MatchingService } from '../matching/matching.service'; import { DriversService } from '../drivers/drivers.service'; import { RealtimeGateway } from '../../realtime/realtime.gateway'; +import { FraudService } from '../fraud/fraud.service'; + +/** رسوم الإلغاء حسب مرحلة الرحلة (docs/04). */ +const CANCEL_FEE_BY_STAGE: Partial> = { + searching: 0, + assigned: 0.5, + driver_arriving: 0.5, + driver_arrived: 1.0, +}; /** الانتقالات المسموحة في آلة حالة الرحلة (docs/02). */ const TRANSITIONS: Record = { @@ -48,6 +57,7 @@ export class TripsService { private readonly matching: MatchingService, private readonly drivers: DriversService, private readonly gateway: RealtimeGateway, + private readonly fraud: FraudService, ) {} get(tenantId: string, id: string): Promise { @@ -75,7 +85,7 @@ export class TripsService { } const serviceClass = dto.service_class ?? 'economy'; const city = dto.city ?? 'default'; - const route = this.maps.route(dto.origin, dto.destination); + const route = await this.maps.route(dto.origin, dto.destination); // تسعير (اختياري — لو ما في تعرفة مفعّلة نكمل بلا سعر مقفول) let quotedFare: number | null = null; @@ -171,9 +181,35 @@ export class TripsService { 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: النهائي = المقفول + + // كشف احتيال عند نقاط حسّاسة + if (toStatus === 'driver_arrived' && trip.driver_id) { + const drv = await this.drivers.findById(tenantId, trip.driver_id); + if (drv) { + await this.fraud.checkArrivedProximity( + tenantId, + drv.user_id, + { last_lat: drv.last_lat, last_lng: drv.last_lng }, + { lat: trip.origin_lat, lng: trip.origin_lng }, + trip.id, + ); + } + } + if (toStatus === 'completed') { + trip.completed_at = new Date(); + if (trip.quoted_fare != null) trip.final_fare = trip.quoted_fare; + if (trip.driver_id) { + const drv = await this.drivers.findById(tenantId, trip.driver_id); + if (drv) { + await this.fraud.checkFastCompletion( + tenantId, + drv.user_id, + trip.assigned_at, + trip.distance_km == null ? null : Number(trip.distance_km), + trip.id, + ); + } + } } await this.applyTransition(trip, toStatus, actor); @@ -186,10 +222,40 @@ export class TripsService { return trip; } - async cancel(tenantId: string, tripId: string, actor: 'rider' | 'driver') { + /** إلغاء من الراكب أو السائق: كشف إساءة + رسم إلغاء حسب المرحلة. */ + async cancel( + tenantId: string, + tripId: string, + actor: 'rider' | 'driver', + actorUserId: string, + ) { const trip = await this.getOr404(tenantId, tripId); + + // كشف إساءة الإلغاء (قد يرمي عند الحد الصارم) + await this.fraud.recordCancellation(tenantId, actor, actorUserId, trip.id); + + const fee = CANCEL_FEE_BY_STAGE[trip.status] ?? 0; + trip.cancelled_by = actor; + trip.cancel_fee = fee; await this.applyTransition(trip, 'cancelled', actor); - this.gateway.tripUpdate(tenantId, trip.rider_id, { tripId: trip.id, status: 'cancelled' }); + + // أبلغ الطرف الآخر + this.gateway.tripUpdate(tenantId, trip.rider_id, { + tripId: trip.id, + status: 'cancelled', + cancelledBy: actor, + cancelFee: fee, + }); + 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: 'cancelled', + cancelledBy: actor, + }); + } + } return trip; }