import { ForbiddenException, Injectable, Logger, 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 { DriverLocationService } from '../locations/driver-location.service'; import { DriverCreditService } from '../credit/driver-credit.service'; import { EntitlementsService } from '../../common/entitlements/entitlements.service'; import { blindIndex } from '../../common/crypto/crypto.util'; @Injectable() export class DriversService { private readonly logger = new Logger('Drivers'); constructor( @InjectRepository(Driver) private readonly repo: Repository, private readonly users: UsersService, private readonly locations: DriverLocationService, private readonly credit: DriverCreditService, private readonly entitlements: EntitlementsService, ) {} findByUser(tenantId: string, userId: string): Promise { return this.repo.findOne({ where: { tenant_id: tenantId, user_id: userId } }); } findById(tenantId: string, id: string): Promise { return this.repo.findOne({ where: { tenant_id: tenantId, id } }); } /** تسجيل السائق بالوثائق — ينشئ سجل driver ويرفع دور المستخدم إلى driver. */ async apply( tenantId: string, userId: string, data: Partial, ): Promise { let driver = await this.findByUser(tenantId, userId); if (!driver) { // حدّ الباقة يُفرض على السيرفر (docs/19 — K4): السائق رقم 501 يُرفض // مهما قال التطبيق. يُفحص عند الإنشاء فقط — سائق قائم لا يُطرد بتغيير باقة. const max = await this.entitlements.limit(tenantId, 'drivers_max'); if (max != null) { const count = await this.repo.count({ where: { tenant_id: tenantId } }); if (count >= max) throw new ForbiddenException('drivers_limit_reached'); } } 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; } /** * تحديث الملف الشخصي للسائق (docs/17 — C2/C3). الرقم الوطني يُخزَّن مشفّراً * مع فهرس أعمى للتفرّد (نفس نمط الهاتف — docs/16)، وما يُدخله السائق يُحفظ * في `user_input` للمقارنة لاحقاً بمخرجات Gemini. */ async setProfile( tenantId: string, userId: string, data: Partial & { national_number?: string }, ): Promise { const driver = await this.findByUser(tenantId, userId); if (!driver) throw new NotFoundException('Driver profile not found'); const patch: Partial = {}; for (const f of [ 'gender', 'name_arabic', 'birthdate', 'address', 'license_type', 'license_categories', 'license_issue', 'license_expiry', ] as const) { if (data[f] !== undefined) (patch as any)[f] = data[f]; } if (data.national_number !== undefined) { patch.national_number = data.national_number; patch.national_number_bidx = data.national_number ? blindIndex(data.national_number) : null; } // نراكم مدخلات السائق لا نستبدلها — للمقارنة مع Gemini حقلاً بحقل. patch.user_input = { ...(driver.user_input ?? {}), ...(data.user_input ?? {}) }; await this.repo.update({ tenant_id: tenantId, id: driver.id }, patch); return (await this.findById(tenantId, driver.id))!; } /** بحث بالرقم الوطني عبر الفهرس الأعمى (لا بالعمود المشفّر). */ findByNationalNumber(tenantId: string, nationalNumber: string): Promise { return this.repo.findOne({ where: { tenant_id: tenantId, national_number_bidx: blindIndex(nationalNumber) }, }); } async setRating(tenantId: string, driverId: string, rating: number): Promise { await this.repo.update( { tenant_id: tenantId, id: driverId }, { rating: Number(rating.toFixed(2)) }, ); } async approve(tenantId: string, driverId: string): Promise { const driver = await this.findById(tenantId, driverId); if (!driver) throw new NotFoundException('Driver not found'); driver.verification_status = 'approved'; const saved = await this.repo.save(driver); // مكافأة التسجيل عند الاعتماد لا عند التقديم — حتى لا يحصدها من رُفض // (docs/18 — J6). آمنة للتكرار: القاعدة تضمن مرة واحدة لكل سائق. await this.credit.grantSignupBonus(tenantId, driver.id).catch((e: any) => this.logger.warn(`signup bonus failed for ${driver.id}: ${e?.message}`), ); return saved; } /** يبدّل حالة الاتصال؛ عند الاتصال يدخل فهرس المتاحين، وعند الفصل يُزال. */ async setOnline( tenantId: string, userId: string, online: boolean, ): Promise { const driver = await this.findByUser(tenantId, userId); if (!driver) throw new NotFoundException('Driver profile not found'); // الدين تجاوز الأرضية → لا يتصل أصلاً؛ وإلا عُرضت عليه رحلات يُرفض قبولها // فيبدو النظام معطوباً بدل أن يفهم أن عليه الشحن (docs/18 — J4). if (online && (await this.credit.isBlocked(tenantId, driver.id))) { throw new ForbiddenException('credit_exhausted'); } driver.is_online = online; await this.repo.save(driver); await this.locations.setAvailability( tenantId, driver.id, driver.service_class, online ? 'available' : 'off', ); return driver; } /** * نبضة موقع — **Redis فقط** (docs/17 — H1). كانت تكتب صف السائق في Postgres * على كل نبضة (كل ثانية/ثلاث لكل سائق متصل) — أثقل حمل كان على القاعدة. * اللقطة الدائمة يكتبها الـworker دورياً. */ async updateLocation( tenantId: string, userId: string, lat: number, lng: number, extra: { heading?: number; speed?: number } = {}, ): Promise<{ ok: boolean; significant: boolean }> { const driver = await this.findByUser(tenantId, userId); if (!driver) throw new NotFoundException('Driver profile not found'); if (!driver.is_online) return { ok: true, significant: false }; const res = await this.locations.update(tenantId, driver.id, driver.service_class, { lat, lng, heading: extra.heading, speed: extra.speed, }); return { ok: true, significant: res.significant }; } /** الموقع الحيّ للسائق — من Redis، واللقطة احتياط. */ liveLocation(tenantId: string, driverId: string) { return this.locations.get(tenantId, driverId); } }