P2: ride types + class-aware matching + round-trip + dispatch + wallet + FCM + WS live location/WebRTC

- ride-types: per-tenant catalog (economy/comfort/electric/family_van/women/scooter), seeded
- matching now class-scoped (geo:drivers:{tenant}:{class}); trips match by requested type
- trips: is_round_trip (doubles distance for fare) + payment_method + wallet settlement on paid
- dispatch: operator creates trip for phone customer (role-guarded)
- wallet: balance + credit/debit + txns + self topup; wallet-paid trips settle rider→driver
- notifications: FCM device tokens + best-effort push (assign event)
- realtime: trip:join, live driver:location broadcast, WebRTC call signaling (offer/answer/ice/end)
- migration InitP2 (ride_types, wallets, wallet_txns, device_tokens, trips.is_round_trip)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 17:39:56 +03:00
co-authored by Claude Opus 4.8
parent ccb80d9e53
commit 16df6258c1
30 changed files with 737 additions and 27 deletions
@@ -0,0 +1,21 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { DispatchService, DispatchOrderDto } from './dispatch.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
@ApiTags('dispatch')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('dispatcher', 'admin')
@Controller('dispatch')
export class DispatchController {
constructor(private readonly dispatch: DispatchService) {}
@Post('orders')
create(@CurrentUser() user: AuthUser, @Body() body: DispatchOrderDto) {
return this.dispatch.createOrder(user.tenantId, body);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DispatchService } from './dispatch.service';
import { DispatchController } from './dispatch.controller';
import { UsersModule } from '../users/users.module';
import { TripsModule } from '../trips/trips.module';
@Module({
imports: [UsersModule, TripsModule],
controllers: [DispatchController],
providers: [DispatchService],
})
export class DispatchModule {}
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { TripsService, RequestTripDto } from '../trips/trips.service';
export interface DispatchOrderDto extends RequestTripDto {
phone: string; // هاتف الراكب الذي اتصل
name?: string;
}
/**
* لوحة المشغّل: إنشاء رحلة نيابةً عن راكب اتصل هاتفياً (docs/02).
* يجد/ينشئ مستخدم الراكب بالهاتف ثم يطلب الرحلة باسمه.
*/
@Injectable()
export class DispatchService {
constructor(
private readonly users: UsersService,
private readonly trips: TripsService,
) {}
async createOrder(tenantId: string, dto: DispatchOrderDto) {
const rider = await this.users.findOrCreate(tenantId, dto.phone, 'rider');
return this.trips.request(tenantId, rider.id, dto);
}
}
@@ -73,9 +73,15 @@ export class DriversService {
driver.is_online = online;
await this.repo.save(driver);
if (!online) {
await this.matching.removeDriver(tenantId, driver.id);
await this.matching.removeDriver(tenantId, driver.service_class, driver.id);
} else if (driver.last_lat != null && driver.last_lng != null) {
await this.matching.addDriver(tenantId, driver.id, driver.last_lat, driver.last_lng);
await this.matching.addDriver(
tenantId,
driver.service_class,
driver.id,
driver.last_lat,
driver.last_lng,
);
}
return driver;
}
@@ -93,7 +99,7 @@ export class DriversService {
driver.last_lng = lng;
await this.repo.save(driver);
if (driver.is_online) {
await this.matching.addDriver(tenantId, driver.id, lat, lng);
await this.matching.addDriver(tenantId, driver.service_class, driver.id, lat, lng);
}
return { ok: true };
}
@@ -16,27 +16,35 @@ export interface NearbyDriver {
export class MatchingService {
constructor(@Inject(REDIS) private readonly redis: Redis) {}
private key(tenantId: string): string {
return `geo:drivers:${tenantId}`;
// فهرس GEO لكل (مستأجر × فئة خدمة) — المطابقة تحترم نوع الرحلة.
private key(tenantId: string, serviceClass: string): string {
return `geo:drivers:${tenantId}:${serviceClass}`;
}
async addDriver(tenantId: string, driverId: string, lat: number, lng: number) {
async addDriver(
tenantId: string,
serviceClass: string,
driverId: string,
lat: number,
lng: number,
) {
await this.redis.call(
'GEOADD',
this.key(tenantId),
this.key(tenantId, serviceClass),
String(lng),
String(lat),
driverId,
);
}
async removeDriver(tenantId: string, driverId: string) {
await this.redis.call('ZREM', this.key(tenantId), driverId);
async removeDriver(tenantId: string, serviceClass: string, driverId: string) {
await this.redis.call('ZREM', this.key(tenantId, serviceClass), driverId);
}
/** أقرب السائقين ضمن نصف قطر (كم)، مرتبين بالأقرب. */
/** أقرب السائقين من نفس فئة الخدمة ضمن نصف قطر (كم). */
async findNearby(
tenantId: string,
serviceClass: string,
lat: number,
lng: number,
radiusKm = 5,
@@ -44,7 +52,7 @@ export class MatchingService {
): Promise<NearbyDriver[]> {
const res = (await this.redis.call(
'GEOSEARCH',
this.key(tenantId),
this.key(tenantId, serviceClass),
'FROMLONLAT',
String(lng),
String(lat),
@@ -0,0 +1,30 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
/** توكن جهاز لإشعارات FCM. الجدول: tripz_device_tokens. */
@Entity('device_tokens')
@Index(['tenant_id', 'user_id'])
export class DeviceToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
user_id: string;
@Column({ unique: true })
token: string;
@Column({ default: 'android' })
platform: string;
@CreateDateColumn()
created_at: Date;
}
@@ -0,0 +1,22 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { NotificationsService } from './notifications.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
@ApiTags('notifications')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('notifications')
export class NotificationsController {
constructor(private readonly notifications: NotificationsService) {}
@Post('token')
register(
@CurrentUser() user: AuthUser,
@Body('token') token: string,
@Body('platform') platform: string,
) {
return this.notifications.register(user.tenantId, user.userId, token, platform);
}
}
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DeviceToken } from './entities/device-token.entity';
import { NotificationsService } from './notifications.service';
import { NotificationsController } from './notifications.controller';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([DeviceToken])],
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}
@@ -0,0 +1,59 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DeviceToken } from './entities/device-token.entity';
/**
* إشعارات FCM. تسجيل التوكنات + إرسال أفضل جهد (best-effort).
* بلا FCM_SERVER_KEY: يُسجَّل فقط دون إرسال.
*/
@Injectable()
export class NotificationsService {
private readonly logger = new Logger('Notifications');
constructor(
@InjectRepository(DeviceToken) private readonly tokens: Repository<DeviceToken>,
private readonly config: ConfigService,
) {}
async register(tenantId: string, userId: string, token: string, platform = 'android') {
const existing = await this.tokens.findOne({ where: { token } });
if (existing) {
existing.tenant_id = tenantId;
existing.user_id = userId;
existing.platform = platform;
return this.tokens.save(existing);
}
return this.tokens.save(
this.tokens.create({ tenant_id: tenantId, user_id: userId, token, platform }),
);
}
async sendToUser(
tenantId: string,
userId: string,
title: string,
body: string,
data: Record<string, any> = {},
) {
const key = this.config.get<string>('fcm.serverKey');
const rows = await this.tokens.find({ where: { tenant_id: tenantId, user_id: userId } });
if (!key || rows.length === 0) {
this.logger.debug(`push skip (key=${!!key} tokens=${rows.length}) "${title}"`);
return;
}
const endpoint = this.config.get<string>('fcm.endpoint')!;
for (const r of rows) {
try {
await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `key=${key}` },
body: JSON.stringify({ to: r.token, notification: { title, body }, data }),
});
} catch (e: any) {
this.logger.warn(`push failed: ${e?.message}`);
}
}
}
}
@@ -0,0 +1,52 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
/**
* كتالوج أنواع الرحلات/السيارات لكل مستأجر (economy, comfort, electric,
* family_van, women, scooter...). الجدول: tripz_ride_types.
* كل نوع = service_class يُطابَق مع تعرفة وسائقين من نفس الفئة.
*/
@Entity('ride_types')
@Index(['tenant_id', 'code'], { unique: true })
export class RideType {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column()
code: string; // economy | comfort | electric | family_van | women | scooter
@Column()
name_ar: string;
@Column({ nullable: true })
name_en: string;
@Column({ default: 'car' })
vehicle_kind: string; // car | van | scooter | bike
@Column({ default: false })
women_only: boolean;
@Column({ default: true })
round_trip_supported: boolean;
@Column({ nullable: true })
icon: string;
@Column({ type: 'int', default: 0 })
sort: number;
@Column({ default: true })
active: boolean;
@CreateDateColumn()
created_at: Date;
}
@@ -0,0 +1,30 @@
import { Body, Controller, Get, Post, Headers, UnauthorizedException } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RideTypesService } from './ride-types.service';
import { TenantsService } from '../tenants/tenants.service';
@ApiTags('ride-types')
@Controller('ride-types')
export class RideTypesController {
constructor(
private readonly rideTypes: RideTypesService,
private readonly tenants: TenantsService,
) {}
// عام للتطبيق: أنواع الرحلات المتاحة للمستأجر (عبر x-tenant-id slug)
@Get()
async list(@Headers('x-tenant-id') slug: string) {
if (!slug) throw new UnauthorizedException('x-tenant-id required');
const tenant = await this.tenants.resolve(slug);
if (!tenant) throw new UnauthorizedException('Unknown tenant');
return this.rideTypes.listActive(tenant.id);
}
// للأدمن: إنشاء نوع (يُقيَّد بدور لاحقاً)
@Post()
async create(@Headers('x-tenant-id') slug: string, @Body() body: any) {
const tenant = await this.tenants.resolve(slug);
if (!tenant) throw new UnauthorizedException('Unknown tenant');
return this.rideTypes.create({ ...body, tenant_id: tenant.id });
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RideType } from './entities/ride-type.entity';
import { RideTypesService } from './ride-types.service';
import { RideTypesController } from './ride-types.controller';
import { TenantsModule } from '../tenants/tenants.module';
@Module({
imports: [TypeOrmModule.forFeature([RideType]), TenantsModule],
controllers: [RideTypesController],
providers: [RideTypesService],
exports: [RideTypesService],
})
export class RideTypesModule {}
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RideType } from './entities/ride-type.entity';
@Injectable()
export class RideTypesService {
constructor(
@InjectRepository(RideType) private readonly repo: Repository<RideType>,
) {}
listActive(tenantId: string): Promise<RideType[]> {
return this.repo.find({
where: { tenant_id: tenantId, active: true },
order: { sort: 'ASC' },
});
}
findByCode(tenantId: string, code: string): Promise<RideType | null> {
return this.repo.findOne({ where: { tenant_id: tenantId, code } });
}
create(data: Partial<RideType>): Promise<RideType> {
return this.repo.save(this.repo.create(data));
}
/** يُنشئ النوع إن لم يوجد (للـ seed). */
async ensure(data: Partial<RideType>): Promise<RideType> {
const existing = await this.findByCode(data.tenant_id!, data.code!);
return existing ?? this.create(data);
}
}
@@ -40,6 +40,9 @@ export class Trip {
@Column({ default: 'economy' })
service_class: string;
@Column({ default: false })
is_round_trip: boolean;
@Column({ nullable: true })
city: string;
@@ -10,6 +10,7 @@ import { MatchingModule } from '../matching/matching.module';
import { DriversModule } from '../drivers/drivers.module';
import { RealtimeModule } from '../../realtime/realtime.module';
import { FraudModule } from '../fraud/fraud.module';
import { WalletModule } from '../wallet/wallet.module';
@Module({
imports: [
@@ -20,6 +21,7 @@ import { FraudModule } from '../fraud/fraud.module';
DriversModule,
RealtimeModule,
FraudModule,
WalletModule,
],
controllers: [TripsController],
providers: [TripsService],
+36 -2
View File
@@ -15,6 +15,8 @@ import { MatchingService } from '../matching/matching.service';
import { DriversService } from '../drivers/drivers.service';
import { RealtimeGateway } from '../../realtime/realtime.gateway';
import { FraudService } from '../fraud/fraud.service';
import { WalletService } from '../wallet/wallet.service';
import { NotificationsService } from '../notifications/notifications.service';
/** رسوم الإلغاء حسب مرحلة الرحلة (docs/04). */
const CANCEL_FEE_BY_STAGE: Partial<Record<TripStatus, number>> = {
@@ -43,6 +45,8 @@ export interface RequestTripDto {
destination: { lat: number; lng: number };
service_class?: string;
city?: string;
is_round_trip?: boolean;
rider_id?: string; // يُستخدم من dispatch لإنشاء رحلة نيابةً عن راكب
}
@Injectable()
@@ -58,6 +62,8 @@ export class TripsService {
private readonly drivers: DriversService,
private readonly gateway: RealtimeGateway,
private readonly fraud: FraudService,
private readonly wallet: WalletService,
private readonly notifications: NotificationsService,
) {}
get(tenantId: string, id: string): Promise<Trip | null> {
@@ -85,7 +91,13 @@ export class TripsService {
}
const serviceClass = dto.service_class ?? 'economy';
const city = dto.city ?? 'default';
const route = await this.maps.route(dto.origin, dto.destination);
const isRound = !!dto.is_round_trip;
const oneWay = await this.maps.route(dto.origin, dto.destination);
// ذهاب وعودة: يُضاعف المسافة والزمن للتسعير
const route = {
distanceKm: isRound ? Number((oneWay.distanceKm * 2).toFixed(3)) : oneWay.distanceKm,
durationMin: isRound ? Number((oneWay.durationMin * 2).toFixed(1)) : oneWay.durationMin,
};
// تسعير (اختياري — لو ما في تعرفة مفعّلة نكمل بلا سعر مقفول)
let quotedFare: number | null = null;
@@ -109,6 +121,7 @@ export class TripsService {
tenant_id: tenantId,
rider_id: riderId,
service_class: serviceClass,
is_round_trip: isRound,
city,
origin_lat: dto.origin.lat,
origin_lng: dto.origin.lng,
@@ -121,13 +134,15 @@ export class TripsService {
tariff_version: tariffVersion,
quoted_fare: quotedFare,
currency: currency ?? undefined,
payment_method: (dto as any).payment_method ?? 'cash',
});
trip = await this.trips.save(trip);
await this.recordEvent(trip, null, 'searching', 'rider');
// مطابقة وبث عروض للسائقين القريبين
// مطابقة وبث عروض للسائقين القريبين من نفس فئة الخدمة
const nearby = await this.matching.findNearby(
tenantId,
serviceClass,
dto.origin.lat,
dto.origin.lng,
);
@@ -170,6 +185,13 @@ export class TripsService {
status: 'assigned',
driverId: driver.id,
});
await this.notifications.sendToUser(
tenantId,
trip.rider_id,
'تم قبول رحلتك',
'سائقك في الطريق إليك',
{ tripId: trip.id, type: 'trip_assigned' },
);
return trip;
}
@@ -213,6 +235,18 @@ export class TripsService {
}
await this.applyTransition(trip, toStatus, actor);
// تسوية المحفظة عند الدفع (payment_method === wallet)
if (toStatus === 'paid' && trip.payment_method === 'wallet' && trip.final_fare != null) {
const fare = Number(trip.final_fare);
const drv = trip.driver_id ? await this.drivers.findById(tenantId, trip.driver_id) : null;
try {
await this.wallet.debit(tenantId, trip.rider_id, fare, 'trip_fare', trip.id);
if (drv) await this.wallet.credit(tenantId, drv.user_id, fare, 'trip_earning', trip.id);
} catch (e: any) {
this.logger.warn(`wallet settle failed: ${e?.message}`);
}
}
// بث لطرفَي الرحلة
this.gateway.tripUpdate(tenantId, trip.rider_id, { tripId: trip.id, status: toStatus });
if (trip.driver_id) {
@@ -23,6 +23,11 @@ export class UsersService {
return this.userRepository.save(user);
}
async findOrCreate(tenantId: string, phone: string, role: string = 'rider'): Promise<User> {
const existing = await this.findByPhone(tenantId, phone);
return existing ?? this.create(tenantId, phone, role);
}
async listByTenant(tenantId: string): Promise<User[]> {
return this.userRepository.find({ where: { tenant_id: tenantId } });
}
@@ -0,0 +1,36 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
/** حركة محفظة. الجدول: tripz_wallet_txns. */
@Entity('wallet_txns')
@Index(['tenant_id', 'wallet_id'])
export class WalletTxn {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
wallet_id: string;
@Column({ type: 'numeric', precision: 12, scale: 3 })
amount: number;
@Column()
type: string; // credit | debit
@Column()
reason: string;
@Column({ nullable: true })
ref: string;
@CreateDateColumn()
created_at: Date;
}
@@ -0,0 +1,34 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/** محفظة المستخدم (راكب/سائق). الجدول: tripz_wallets. */
@Entity('wallets')
@Index(['tenant_id', 'user_id'], { unique: true })
export class Wallet {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
user_id: string;
@Column({ type: 'numeric', precision: 12, scale: 3, default: 0 })
balance: number;
@Column({ default: 'JOD' })
currency: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,30 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { WalletService } from './wallet.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
@ApiTags('wallet')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('wallet')
export class WalletController {
constructor(private readonly wallet: WalletService) {}
@Get()
async me(@CurrentUser() user: AuthUser) {
return this.wallet.getOrCreate(user.tenantId, user.userId);
}
@Get('transactions')
async history(@CurrentUser() user: AuthUser) {
const w = await this.wallet.getOrCreate(user.tenantId, user.userId);
return this.wallet.history(user.tenantId, w.id);
}
// شحن ذاتي تجريبي (كاش-إن). للإنتاج يُربط بمزوّد دفع أو يُقيَّد للأدمن.
@Post('topup')
topup(@CurrentUser() user: AuthUser, @Body('amount') amount: number) {
return this.wallet.credit(user.tenantId, user.userId, Number(amount), 'topup');
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Wallet } from './entities/wallet.entity';
import { WalletTxn } from './entities/wallet-txn.entity';
import { WalletService } from './wallet.service';
import { WalletController } from './wallet.controller';
@Module({
imports: [TypeOrmModule.forFeature([Wallet, WalletTxn])],
controllers: [WalletController],
providers: [WalletService],
exports: [WalletService],
})
export class WalletModule {}
@@ -0,0 +1,58 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Wallet } from './entities/wallet.entity';
import { WalletTxn } from './entities/wallet-txn.entity';
@Injectable()
export class WalletService {
constructor(
@InjectRepository(Wallet) private readonly wallets: Repository<Wallet>,
@InjectRepository(WalletTxn) private readonly txns: Repository<WalletTxn>,
) {}
async getOrCreate(tenantId: string, userId: string, currency = 'JOD'): Promise<Wallet> {
let w = await this.wallets.findOne({ where: { tenant_id: tenantId, user_id: userId } });
if (!w) {
w = await this.wallets.save(
this.wallets.create({ tenant_id: tenantId, user_id: userId, balance: 0, currency }),
);
}
return w;
}
private num(v: number | string): number {
return typeof v === 'string' ? Number(v) : v;
}
async credit(tenantId: string, userId: string, amount: number, reason: string, ref?: string) {
if (amount <= 0) throw new BadRequestException('amount must be > 0');
const w = await this.getOrCreate(tenantId, userId);
w.balance = Number((this.num(w.balance) + amount).toFixed(3));
await this.wallets.save(w);
await this.txns.save(
this.txns.create({ tenant_id: tenantId, wallet_id: w.id, amount, type: 'credit', reason, ref }),
);
return w;
}
async debit(tenantId: string, userId: string, amount: number, reason: string, ref?: string) {
if (amount <= 0) throw new BadRequestException('amount must be > 0');
const w = await this.getOrCreate(tenantId, userId);
if (this.num(w.balance) < amount) throw new BadRequestException('Insufficient balance');
w.balance = Number((this.num(w.balance) - amount).toFixed(3));
await this.wallets.save(w);
await this.txns.save(
this.txns.create({ tenant_id: tenantId, wallet_id: w.id, amount, type: 'debit', reason, ref }),
);
return w;
}
history(tenantId: string, walletId: string) {
return this.txns.find({
where: { tenant_id: tenantId, wallet_id: walletId },
order: { created_at: 'DESC' },
take: 100,
});
}
}