P1: drivers + trips(★state machine) + matching(Redis GEO) + tariff engine + maps + realtime + seed

- drivers: apply/approve/status/location (+Redis GEO presence)
- trips: request→accept→status transitions + trip_events audit + offers via socket
- matching: Redis GEO nearby drivers
- tariff: JSON-rule engine (time_or_distance/window/rounding) + quote endpoint
- maps: straight-line routing seam (انطلق later)
- realtime: Socket.IO gateway (JWT auth, tenant rooms)
- seed: demo tenant siro + default tariff on boot
- migration InitP1 (drivers/tariffs/trips/trip_events)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 16:41:58 +03:00
co-authored by Claude Opus 4.8
parent 4397b07d2f
commit 00870a8cc3
26 changed files with 1358 additions and 1 deletions
@@ -0,0 +1,43 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { DriversService } from './drivers.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
@ApiTags('drivers')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('drivers')
export class DriversController {
constructor(private readonly drivers: DriversService) {}
@Post('apply')
apply(@CurrentUser() user: AuthUser, @Body() body: any) {
return this.drivers.apply(user.tenantId, user.userId, body);
}
@Get('me')
me(@CurrentUser() user: AuthUser) {
return this.drivers.findByUser(user.tenantId, user.userId);
}
@Patch('status')
setStatus(@CurrentUser() user: AuthUser, @Body('online') online: boolean) {
return this.drivers.setOnline(user.tenantId, user.userId, !!online);
}
@Post('location')
location(
@CurrentUser() user: AuthUser,
@Body('lat') lat: number,
@Body('lng') lng: number,
) {
return this.drivers.updateLocation(user.tenantId, user.userId, Number(lat), Number(lng));
}
// للأدمن/المشغّل — تبسيط P1: أي مستخدم مصادَق؛ يُقيَّد بدور لاحقاً.
@Patch(':id/approve')
approve(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.drivers.approve(user.tenantId, id);
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Driver } from './entities/driver.entity';
import { DriversService } from './drivers.service';
import { DriversController } from './drivers.controller';
import { UsersModule } from '../users/users.module';
import { MatchingModule } from '../matching/matching.module';
@Module({
imports: [TypeOrmModule.forFeature([Driver]), UsersModule, MatchingModule],
controllers: [DriversController],
providers: [DriversService],
exports: [DriversService],
})
export class DriversModule {}
@@ -0,0 +1,93 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Driver } from './entities/driver.entity';
import { UsersService } from '../users/users.service';
import { MatchingService } from '../matching/matching.service';
@Injectable()
export class DriversService {
constructor(
@InjectRepository(Driver)
private readonly repo: Repository<Driver>,
private readonly users: UsersService,
private readonly matching: MatchingService,
) {}
findByUser(tenantId: string, userId: string): Promise<Driver | null> {
return this.repo.findOne({ where: { tenant_id: tenantId, user_id: userId } });
}
findById(tenantId: string, id: string): Promise<Driver | null> {
return this.repo.findOne({ where: { tenant_id: tenantId, id } });
}
/** تسجيل السائق بالوثائق — ينشئ سجل driver ويرفع دور المستخدم إلى driver. */
async apply(
tenantId: string,
userId: string,
data: Partial<Driver>,
): Promise<Driver> {
let driver = await this.findByUser(tenantId, userId);
if (!driver) {
driver = this.repo.create({
tenant_id: tenantId,
user_id: userId,
service_class: data.service_class ?? 'economy',
vehicle_make: data.vehicle_make,
vehicle_model: data.vehicle_model,
vehicle_plate: data.vehicle_plate,
vehicle_color: data.vehicle_color,
docs: data.docs ?? {},
verification_status: 'pending',
});
driver = await this.repo.save(driver);
}
// يصير دور المستخدم "driver" (يظهر في التوكن عند إعادة الدخول).
await this.users.setRole(tenantId, userId, 'driver');
return driver;
}
async approve(tenantId: string, driverId: string): Promise<Driver> {
const driver = await this.findById(tenantId, driverId);
if (!driver) throw new NotFoundException('Driver not found');
driver.verification_status = 'approved';
return this.repo.save(driver);
}
/** يبدّل حالة الاتصال؛ عند الاتصال يُضاف لفهرس GEO، وعند الفصل يُزال. */
async setOnline(
tenantId: string,
userId: string,
online: boolean,
): Promise<Driver> {
const driver = await this.findByUser(tenantId, userId);
if (!driver) throw new NotFoundException('Driver profile not found');
driver.is_online = online;
await this.repo.save(driver);
if (!online) {
await this.matching.removeDriver(tenantId, 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);
}
return driver;
}
/** تحديث الموقع الجاري: Redis GEO + لقطة في Postgres. */
async updateLocation(
tenantId: string,
userId: string,
lat: number,
lng: number,
): Promise<{ ok: boolean }> {
const driver = await this.findByUser(tenantId, userId);
if (!driver) throw new NotFoundException('Driver profile not found');
driver.last_lat = lat;
driver.last_lng = lng;
await this.repo.save(driver);
if (driver.is_online) {
await this.matching.addDriver(tenantId, driver.id, lat, lng);
}
return { ok: true };
}
}
@@ -0,0 +1,66 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export type VerificationStatus = 'pending' | 'approved' | 'rejected';
/**
* السائق = مستخدم بدور driver + بيانات مركبة ووثائق. الجدول: tripz_drivers.
* الموقع الجاري يعيش في Redis (المطابقة)؛ هنا نحفظ آخر لقطة فقط (docs/09).
*/
@Entity('drivers')
@Index(['tenant_id', 'user_id'], { unique: true })
export class Driver {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
user_id: string;
@Column({ default: 'economy' })
service_class: string;
@Column({ nullable: true })
vehicle_make: string;
@Column({ nullable: true })
vehicle_model: string;
@Column({ nullable: true })
vehicle_plate: string;
@Column({ nullable: true })
vehicle_color: string;
@Column({ type: 'jsonb', default: {} })
docs: Record<string, any>;
@Column({ type: 'varchar', default: 'pending' })
verification_status: VerificationStatus;
@Column({ default: false })
is_online: boolean;
@Column({ type: 'numeric', precision: 3, scale: 2, default: 5 })
rating: number;
@Column({ type: 'double precision', nullable: true })
last_lat: number;
@Column({ type: 'double precision', nullable: true })
last_lng: number;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}