chore: أرشفة باك إند NestJS إلى backend-archive

قرار المالك 2026-07-27: الباك إند المعتمد صار باك إند سيرو PHP، ويُنقل
إلى هذا المستودع كنسخة جديدة باسم «انطلق» على api.intaleqapp.com.

backend/ → backend-archive/ (305 ملفاً، إعادة تسمية بلا تعديل محتوى)
+ README-ARCHIVE.md يوضّح سبب الأرشفة وسبب عدم الحذف: scripts/e2e-test.mjs
  هو أدق توثيق سلوكي لدورة الرحلة كاملة، ويبقى مرجعاً عند بناء الوحدات
  الناقصة في باك إند PHP (المحفظتان · الاستحقاقات · محرّك التسعير · الوحدات).

مسارات backend/ في docs/*.md لم تُعدَّل عمداً — سجلّ تاريخي.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-27 04:54:35 +03:00
co-authored by Claude Opus 5
parent 75b80bd299
commit fff76c949f
307 changed files with 47 additions and 0 deletions
@@ -0,0 +1,166 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Server, Socket } from 'socket.io';
import { DriversService } from '../modules/drivers/drivers.service';
import { Trip } from '../modules/trips/entities/trip.entity';
/**
* البوابة الحية (Socket.IO) — docs/09. مصادقة JWT + غرف مُنطَّقة بالمستأجر،
* بث مواقع السائقين لحظياً، وإشارات مكالمات WebRTC (offer/answer/ice) عبر غرفة الرحلة.
*/
@WebSocketGateway({ cors: true })
export class RealtimeGateway implements OnGatewayConnection {
private readonly logger = new Logger('Realtime');
@WebSocketServer()
server: Server;
constructor(
private readonly jwt: JwtService,
private readonly drivers: DriversService,
@InjectRepository(Trip) private readonly trips: Repository<Trip>,
) {}
handleConnection(client: Socket) {
try {
const token =
(client.handshake.auth?.token as string) ||
(client.handshake.headers?.authorization as string)?.replace('Bearer ', '');
if (!token) throw new Error('no token');
const p: any = this.jwt.verify(token);
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 فقط (لا قاعدة — docs/17 H1)، والبثّ لغرفة الرحلة
* **فقط عند حركة ذات دلالة**؛ سائق واقف يرسل كل ثانية لا يُغرق الراكب
* بتحديثات متطابقة.
*/
@SubscribeMessage('driver:location')
async onDriverLocation(
@ConnectedSocket() c: Socket,
@MessageBody()
body: { lat: number; lng: number; heading?: number; speed?: number; tripId?: string },
) {
const u = c.data.user;
if (!u || u.role !== 'driver' || body?.lat == null || body?.lng == null) return;
const { significant } = await this.drivers.updateLocation(
u.tenantId,
u.userId,
body.lat,
body.lng,
{ heading: body.heading, speed: body.speed },
);
if (body.tripId && significant) {
c.to(this.tripRoom(u.tenantId, body.tripId)).emit('driver:location', {
tripId: body.tripId,
lat: body.lat,
lng: body.lng,
heading: body.heading ?? 0,
speed: body.speed ?? 0,
});
}
return { ok: true };
}
// ---- إشارات WebRTC (مكالمة مجانية بين الطرفين عبر غرفة الرحلة) ----
/**
* `offer` هو حدث **بدء الاتصال** فعلياً (answer/ice/end إشارات تفاوض
* لاحقة على نفس المكالمة) — لذا هنا فقط نُسجّل إشارة الاحتيال (docs/17 —
* E1). الكتابة على القاعدة مباشرة لا عبر Redis: تكرار المكالمات لا يقارن
* بتكرار نبضات الموقع، فلا حاجة لتجميع (نمط H) هنا.
*/
@SubscribeMessage('call:offer')
async onCallOffer(@ConnectedSocket() c: Socket, @MessageBody() b: any) {
this.relay(c, 'call:offer', b);
const u = c.data.user;
if (!u || !b?.tripId) return;
try {
await this.trips
.createQueryBuilder()
.update(Trip)
.set({
last_call_at: () => 'now()',
last_call_by: u.role,
call_count: () => 'call_count + 1',
...(u.role === 'driver' ? { driver_called_passenger: true } : {}),
})
.where('tenant_id = :tenantId AND id = :tripId', { tenantId: u.tenantId, tripId: b.tripId })
.execute();
} catch (e: any) {
// تسجيل الإشارة لا يجوز أن يقطع المكالمة نفسها — أُعيد المحاولة لاحقاً
// غير ممكنة هنا، فنكتفي باللوغ.
this.logger.warn(`call signal record failed: ${e?.message}`);
}
}
@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);
}
/** العرض مات (قبِله غيره أو أُلغي) — يزيله من شاشة السائق والـoverlay. */
offerTaken(tenantId: string, driverUserId: string, payload: any) {
this.server.to(`tenant:${tenantId}:user:${driverUserId}`).emit('trip:offer_taken', 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);
}
}
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RealtimeGateway } from './realtime.gateway';
import { DriversModule } from '../modules/drivers/drivers.module';
import { Trip } from '../modules/trips/entities/trip.entity';
@Module({
imports: [
DriversModule,
// تسجيل الكيان مباشرة لا وحدة TripsModule كاملة — TripsModule تستورد
// RealtimeModule أصلاً، فاستيراد معاكس يصنع دورة (docs/17 — E1).
TypeOrmModule.forFeature([Trip]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (cfg: ConfigService) => ({
secret: cfg.get<string>('JWT_SECRET') || 'change_me_jwt_secret',
}),
}),
],
providers: [RealtimeGateway],
exports: [RealtimeGateway],
})
export class RealtimeModule {}
@@ -0,0 +1,46 @@
import { INestApplicationContext, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { ServerOptions } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
/**
* مُحوّل Socket.IO مدعوم بـ Redis — يجعل التطبيق قابلاً للتشغيل بعدة نسخ
* (توسّع أفقي) تتشارك اتصالات الويب سوكت عبر Redis. بلا هذا، رسالة من نسخة
* لا تصل زبوناً متصلاً بنسخة أخرى (راجع docs/09).
* مفتاح القنوات ببادئة tripz للعزل عن أي تطبيق آخر على نفس Redis.
*/
export class RedisIoAdapter extends IoAdapter {
private readonly logger = new Logger('SocketRedis');
private adapterConstructor: ReturnType<typeof createAdapter>;
constructor(
app: INestApplicationContext,
private readonly config: ConfigService,
) {
super(app);
}
async connectToRedis(): Promise<void> {
const opts = {
host: this.config.get<string>('redis.host'),
port: this.config.get<number>('redis.port'),
db: this.config.get<number>('redis.db'),
maxRetriesPerRequest: null,
};
const pub = new Redis(opts);
const sub = pub.duplicate();
pub.on('error', (e) => this.logger.warn(`pub: ${e.message}`));
sub.on('error', (e) => this.logger.warn(`sub: ${e.message}`));
// key يعزل قنوات socket.io الخاصة بـ Tripz عن غيرها على نفس Redis
this.adapterConstructor = createAdapter(pub, sub, { key: 'tripz:sio' });
this.logger.log('Socket.IO Redis adapter ready (horizontal scale enabled)');
}
createIOServer(port: number, options?: ServerOptions): any {
const server = super.createIOServer(port, options);
if (this.adapterConstructor) server.adapter(this.adapterConstructor);
return server;
}
}