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
+74 -13
View File
@@ -1,16 +1,19 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Server, Socket } from 'socket.io';
import { DriversService } from '../modules/drivers/drivers.service';
/**
* البوابة الحية (Socket.IO) — docs/09. المصادقة على الاتصال بـ JWT،
* والانضمام لغرف مُنطَّقة بالمستأجر (لا تسريب بين المستأجرين).
* غرف: tenant:{id}:driver:{driverId} · tenant:{id}:trip:{tripId} · tenant:{id}:dispatch
* البوابة الحية (Socket.IO) — docs/09. مصادقة JWT + غرف مُنطَّقة بالمستأجر،
* بث مواقع السائقين لحظياً، وإشارات مكالمات WebRTC (offer/answer/ice) عبر غرفة الرحلة.
*/
@WebSocketGateway({ cors: true })
export class RealtimeGateway implements OnGatewayConnection {
@@ -19,7 +22,10 @@ export class RealtimeGateway implements OnGatewayConnection {
@WebSocketServer()
server: Server;
constructor(private readonly jwt: JwtService) {}
constructor(
private readonly jwt: JwtService,
private readonly drivers: DriversService,
) {}
handleConnection(client: Socket) {
try {
@@ -28,29 +34,84 @@ export class RealtimeGateway implements OnGatewayConnection {
(client.handshake.headers?.authorization as string)?.replace('Bearer ', '');
if (!token) throw new Error('no token');
const p: any = this.jwt.verify(token);
const tenantId = p.tenant_id;
client.data.user = { userId: p.sub, tenantId, role: p.role };
client.join(`tenant:${tenantId}:user:${p.sub}`);
if (p.role === 'driver') client.join(`tenant:${tenantId}:drivers`);
this.logger.debug(`connected user=${p.sub} tenant=${tenantId} role=${p.role}`);
client.data.user = { userId: p.sub, tenantId: p.tenant_id, role: p.role };
client.join(`tenant:${p.tenant_id}:user:${p.sub}`);
if (p.role === 'driver') client.join(`tenant:${p.tenant_id}:drivers`);
} catch {
client.disconnect(true);
}
}
// ---- مساعدات البث التي تستدعيها الخدمات ----
private tripRoom(tenantId: string, tripId: string) {
return `tenant:${tenantId}:trip:${tripId}`;
}
/** عرض رحلة لسائق محدد. */
// ---- اشتراكات الويب سوكت ----
/** الطرفان ينضمّان لغرفة الرحلة لاستقبال التتبع والدردشة والمكالمات. */
@SubscribeMessage('trip:join')
onTripJoin(@ConnectedSocket() c: Socket, @MessageBody() body: { tripId: string }) {
const u = c.data.user;
if (!u || !body?.tripId) return;
c.join(this.tripRoom(u.tenantId, body.tripId));
return { ok: true };
}
/** موقع السائق الحي: يحدّث Redis GEO ويبثّ لغرفة الرحلة. */
@SubscribeMessage('driver:location')
async onDriverLocation(
@ConnectedSocket() c: Socket,
@MessageBody() body: { lat: number; lng: number; tripId?: string },
) {
const u = c.data.user;
if (!u || u.role !== 'driver' || body?.lat == null || body?.lng == null) return;
await this.drivers.updateLocation(u.tenantId, u.userId, body.lat, body.lng);
if (body.tripId) {
c.to(this.tripRoom(u.tenantId, body.tripId)).emit('driver:location', {
tripId: body.tripId,
lat: body.lat,
lng: body.lng,
});
}
return { ok: true };
}
// ---- إشارات WebRTC (مكالمة مجانية بين الطرفين عبر غرفة الرحلة) ----
@SubscribeMessage('call:offer')
onCallOffer(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
this.relay(c, 'call:offer', b);
}
@SubscribeMessage('call:answer')
onCallAnswer(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
this.relay(c, 'call:answer', b);
}
@SubscribeMessage('call:ice')
onCallIce(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
this.relay(c, 'call:ice', b);
}
@SubscribeMessage('call:end')
onCallEnd(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
this.relay(c, 'call:end', b);
}
private relay(c: Socket, event: string, b: any) {
const u = c.data.user;
if (!u || !b?.tripId) return;
c.to(this.tripRoom(u.tenantId, b.tripId)).emit(event, { from: u.userId, ...b });
}
// ---- مساعدات البث التي تستدعيها الخدمات ----
offerToDriver(tenantId: string, driverUserId: string, payload: any) {
this.server.to(`tenant:${tenantId}:user:${driverUserId}`).emit('trip:offer', payload);
}
/** تحديث حالة الرحلة للراكب أو السائق. */
tripUpdate(tenantId: string, userId: string, payload: any) {
this.server.to(`tenant:${tenantId}:user:${userId}`).emit('trip:update', payload);
}
/** بث للوحة المشغّل. */
dispatch(tenantId: string, event: string, payload: any) {
this.server.to(`tenant:${tenantId}:dispatch`).emit(event, payload);
}
+2
View File
@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RealtimeGateway } from './realtime.gateway';
import { DriversModule } from '../modules/drivers/drivers.module';
@Module({
imports: [
DriversModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],