P1 complete: real انطلق maps + Nabeh WhatsApp OTP + chat + ratings + cancel(both) + fraud detection

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 17:12:38 +03:00
co-authored by Claude Opus 4.8
parent 00870a8cc3
commit 65a2e5e275
25 changed files with 948 additions and 53 deletions
+14 -1
View File
@@ -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
+9 -1
View File
@@ -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,
],
})
+19
View File
@@ -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<string, string>,
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<string, string>,
});
@@ -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<void> {
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<void> {
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`);
}
}
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { NabehService } from './nabeh.service';
@Global()
@Module({
providers: [NabehService],
exports: [NabehService],
})
export class NabehModule {}
@@ -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<string>('nabeh.baseUrl');
}
private async ensureToken(): Promise<string> {
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<string>('nabeh.email'),
password: this.config.get<string>('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<void> {
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<string>('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;
}
}
}
+63 -26
View File
@@ -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<string> {
const tenant = await this.tenantsService.resolve(tenantSlugOrId);
private async resolveTenant(slugOrId: string): Promise<Tenant> {
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<boolean>('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<number>('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<Record<string, string>>('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<number>('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,
@@ -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);
}
}
+20
View File
@@ -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 {}
+76
View File
@@ -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<ChatMessage>,
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' },
});
}
}
@@ -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;
}
@@ -48,6 +48,13 @@ export class DriversService {
return driver;
}
async setRating(tenantId: string, driverId: string, rating: number): Promise<void> {
await this.repo.update(
{ tenant_id: tenantId, id: driverId },
{ rating: Number(rating.toFixed(2)) },
);
}
async approve(tenantId: string, driverId: string): Promise<Driver> {
const driver = await this.findById(tenantId, driverId);
if (!driver) throw new NotFoundException('Driver not found');
@@ -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<string, any>;
@CreateDateColumn()
created_at: Date;
}
+12
View File
@@ -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 {}
+119
View File
@@ -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<FraudFlag>,
@Inject(REDIS) private readonly redis: Redis,
private readonly maps: MapsService,
) {}
async flag(
tenantId: string,
subjectType: 'rider' | 'driver',
subjectId: string,
reason: string,
meta: Record<string, any> = {},
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,
);
}
}
}
+35 -3
View File
@@ -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);
}
}
+97 -15
View File
@@ -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<string>('maps.baseUrl') ?? 'https://map-saas.intaleqapp.com';
}
private keyFor(country?: string): string {
const byCountry = this.config.get<Record<string, string>>('maps.apiKeyByCountry') ?? {};
return (country && byCountry[country]) || this.config.get<string>('maps.apiKey') || '';
}
private headers(key: string): Record<string, string> {
return { 'x-api-key': key, 'Content-Type': 'application/json' };
}
/** توجيه حقيقي من انطلق مع رجوع آمن لخط مستقيم. */
async route(from: LatLng, to: LatLng, country?: string): Promise<RouteResult> {
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<string>('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;
}
}
@@ -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;
}
@@ -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);
}
}
@@ -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 {}
@@ -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<Rating>,
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;
}
}
@@ -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;
@@ -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);
}
}
@@ -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],
+72 -6
View File
@@ -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<Record<TripStatus, number>> = {
searching: 0,
assigned: 0.5,
driver_arriving: 0.5,
driver_arrived: 1.0,
};
/** الانتقالات المسموحة في آلة حالة الرحلة (docs/02). */
const TRANSITIONS: Record<TripStatus, TripStatus[]> = {
@@ -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<Trip | null> {
@@ -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;
}