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:
+14
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user