diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 20032c0..529a4be 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -41,6 +41,8 @@ import { DocumentsModule } from './modules/documents/documents.module'; database: cfg.get('db.name'), username: cfg.get('db.user'), password: cfg.get('db.password'), + // بركة اتصالات أكبر — يرفع سقف التزامن تحت الحمل العالي + extra: { max: parseInt(process.env.DB_POOL_MAX ?? '30', 10) }, // بادئة الجداول (tripz_) لعزل السيرفر المشترك — راجع docs/14 entityPrefix: cfg.get('db.tablePrefix'), synchronize: cfg.get('db.synchronize'), diff --git a/backend/src/database/migrations/1721750000000-DocumentsFields.ts b/backend/src/database/migrations/1721750000000-DocumentsFields.ts new file mode 100644 index 0000000..673f7d7 --- /dev/null +++ b/backend/src/database/migrations/1721750000000-DocumentsFields.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** حقول إضافية للوثائق: الوجه، اسم الحامل، تاريخ الإصدار، حقول إضافية. */ +export class DocumentsFields1721750000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS side varchar NOT NULL DEFAULT 'single'`); + await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS holder_name varchar`); + await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS issue_date date`); + await q.query(`ALTER TABLE tripz_driver_documents ADD COLUMN IF NOT EXISTS extra jsonb NOT NULL DEFAULT '{}'`); + } + + public async down(q: QueryRunner): Promise { + await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS extra`); + await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS issue_date`); + await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS holder_name`); + await q.query(`ALTER TABLE tripz_driver_documents DROP COLUMN IF EXISTS side`); + } +} diff --git a/backend/src/modules/documents/documents.controller.ts b/backend/src/modules/documents/documents.controller.ts index 27a6e08..68a936a 100644 --- a/backend/src/modules/documents/documents.controller.ts +++ b/backend/src/modules/documents/documents.controller.ts @@ -19,39 +19,73 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator @ApiTags('documents') @ApiBearerAuth() -@Controller('drivers/documents') +@Controller() export class DocumentsController { constructor(private readonly documents: DocumentsService) {} - // السائق يرفع وثيقة (multipart: file + type + number + expiry_date) + // ===== السائق نفسه ===== @UseGuards(JwtAuthGuard) - @Post() + @Post('drivers/documents') @UseInterceptors(FileInterceptor('file')) - upload( - @CurrentUser() user: AuthUser, - @UploadedFile() file: any, - @Body() body: any, - ) { - return this.documents.upload(user.tenantId, user.userId, file, body); + upload(@CurrentUser() user: AuthUser, @UploadedFile() file: any, @Body() body: any) { + return this.documents.uploadSelf(user.tenantId, user.userId, file, body); } @UseGuards(JwtAuthGuard) - @Get('mine') + @Get('drivers/documents/mine') mine(@CurrentUser() user: AuthUser) { return this.documents.listMine(user.tenantId, user.userId); } - // مراجعة: خدمة العملاء/الأدمن + // قائمة الوثائق المطلوبة وحالتها للسائق الحالي + @UseGuards(JwtAuthGuard) + @Get('drivers/documents/requirements') + async myRequirements(@CurrentUser() user: AuthUser) { + const list = await this.documents.listMine(user.tenantId, user.userId); + const driverId = list[0]?.driver_id; + return driverId + ? this.documents.requirements(user.tenantId, driverId) + : this.documents.requirements(user.tenantId, '00000000-0000-0000-0000-000000000000'); + } + + // ===== خدمة العملاء / الأدمن ===== @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin', 'dispatcher') - @Get('pending') + @Post('admin/drivers/:driverId/documents') + @UseInterceptors(FileInterceptor('file')) + uploadForDriver( + @Param('driverId') driverId: string, + @CurrentUser() user: AuthUser, + @UploadedFile() file: any, + @Body() body: any, + ) { + return this.documents.uploadForDriver(user.tenantId, driverId, file, body); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'dispatcher') + @Get('admin/drivers/:driverId/documents') + driverDocs(@Param('driverId') driverId: string, @CurrentUser() user: AuthUser) { + return this.documents.listByDriver(user.tenantId, driverId); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'dispatcher') + @Get('admin/drivers/:driverId/documents/requirements') + driverRequirements(@Param('driverId') driverId: string, @CurrentUser() user: AuthUser) { + return this.documents.requirements(user.tenantId, driverId); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'dispatcher') + @Get('admin/documents/pending') pending(@CurrentUser() user: AuthUser) { return this.documents.pending(user.tenantId); } @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin', 'dispatcher') - @Patch(':id/review') + @Patch('admin/documents/:id/review') review( @CurrentUser() user: AuthUser, @Param('id') id: string, @@ -60,4 +94,11 @@ export class DocumentsController { ) { return this.documents.review(user.tenantId, id, user.userId, status, note); } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin', 'dispatcher') + @Post('admin/drivers/:driverId/face-match') + faceMatch(@Param('driverId') driverId: string, @CurrentUser() user: AuthUser) { + return this.documents.faceMatch(user.tenantId, driverId); + } } diff --git a/backend/src/modules/documents/documents.service.ts b/backend/src/modules/documents/documents.service.ts index b7af9e2..148dc32 100644 --- a/backend/src/modules/documents/documents.service.ts +++ b/backend/src/modules/documents/documents.service.ts @@ -1,10 +1,35 @@ import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { DriverDocument, DocStatus, DocType } from './entities/driver-document.entity'; +import { DriverDocument, DocStatus, DocType, DocSide } from './entities/driver-document.entity'; import { StorageService } from '../../common/storage/storage.service'; import { DriversService } from '../drivers/drivers.service'; +/** الوثائق المطلوبة معيارياً لكل سائق (الأردن/المنطقة). */ +export const REQUIRED_DOCS: Array<{ + type: DocType; + name_ar: string; + sides: DocSide[]; + min?: number; +}> = [ + { type: 'license', name_ar: 'رخصة القيادة', sides: ['front', 'back'] }, + { type: 'national_id', name_ar: 'الهوية الشخصية', sides: ['front', 'back'] }, + { type: 'vehicle_registration', name_ar: 'دفتر/بطاقة المركبة', sides: ['front', 'back'] }, + { type: 'criminal_record', name_ar: 'عدم محكومية', sides: ['single'] }, + { type: 'selfie', name_ar: 'صورة شخصية', sides: ['single'] }, + { type: 'vehicle_photo', name_ar: 'صور السيارة', sides: ['single'], min: 1 }, +]; + +export interface DocFields { + type: DocType; + side?: DocSide; + number?: string; + holder_name?: string; + issue_date?: string; + expiry_date?: string; + extra?: Record; +} + @Injectable() export class DocumentsService { constructor( @@ -13,36 +38,65 @@ export class DocumentsService { private readonly drivers: DriversService, ) {} - async upload( + /** رفع من السائق نفسه. */ + async uploadSelf( tenantId: string, userId: string, file: { buffer: Buffer; originalname?: string }, - body: { type: DocType; number?: string; expiry_date?: string }, + body: DocFields, ) { const driver = await this.drivers.findByUser(tenantId, userId); if (!driver) throw new ForbiddenException('driver profile required'); - if (!file?.buffer) throw new NotFoundException('file is required'); + return this.store(tenantId, driver.id, file, body); + } - const key = await this.storage.save(tenantId, `docs/${driver.id}`, file.buffer, file.originalname); + /** رفع/إدخال من خدمة العملاء نيابةً عن سائق محدد. */ + async uploadForDriver( + tenantId: string, + driverId: string, + file: { buffer: Buffer; originalname?: string }, + body: DocFields, + ) { + const driver = await this.drivers.findById(tenantId, driverId); + if (!driver) throw new NotFoundException('driver not found'); + return this.store(tenantId, driverId, file, body); + } + + private async store( + tenantId: string, + driverId: string, + file: { buffer: Buffer; originalname?: string }, + body: DocFields, + ) { + if (!file?.buffer) throw new NotFoundException('file is required'); + const key = await this.storage.save(tenantId, `docs/${driverId}`, file.buffer, file.originalname); const doc = await this.repo.save( this.repo.create({ tenant_id: tenantId, - driver_id: driver.id, + driver_id: driverId, type: body.type, + side: body.side ?? 'single', file_key: key, number: body.number ?? null, + holder_name: body.holder_name ?? null, + issue_date: body.issue_date ?? null, expiry_date: body.expiry_date ?? null, + extra: body.extra ?? {}, status: 'pending', }), ); return this.withUrl(doc); } - async listMine(tenantId: string, userId: string) { - const driver = await this.drivers.findByUser(tenantId, userId); - if (!driver) return []; + listMine(tenantId: string, userId: string) { + return this.drivers.findByUser(tenantId, userId).then((d) => + d ? this.listByDriver(tenantId, d.id) : [], + ); + } + + async listByDriver(tenantId: string, driverId: string) { const docs = await this.repo.find({ - where: { tenant_id: tenantId, driver_id: driver.id }, + where: { tenant_id: tenantId, driver_id: driverId }, order: { created_at: 'DESC' }, }); return docs.map((d) => this.withUrl(d)); @@ -56,7 +110,25 @@ export class DocumentsService { return docs.map((d) => this.withUrl(d)); } - /** مراجعة خدمة العملاء/الأدمن. عند اعتماد كل الوثائق يُعتمد السائق. */ + /** قائمة الوثائق المطلوبة وحالة كل منها لسائق — للتحقق من الاكتمال. */ + async requirements(tenantId: string, driverId: string) { + const docs = await this.repo.find({ where: { tenant_id: tenantId, driver_id: driverId } }); + const items = REQUIRED_DOCS.flatMap((req) => + req.sides.map((side) => { + const match = docs.find((d) => d.type === req.type && d.side === side); + return { + type: req.type, + name_ar: req.name_ar, + side, + status: match?.status ?? 'missing', + document_id: match?.id ?? null, + }; + }), + ); + const complete = items.every((i) => i.status === 'approved'); + return { complete, items }; + } + async review( tenantId: string, id: string, @@ -71,20 +143,34 @@ export class DocumentsService { doc.reviewed_by = reviewerId; await this.repo.save(doc); + // عند اكتمال كل الوثائق المطلوبة معتمَدةً → يُعتمد السائق if (status === 'approved') { - const remaining = await this.repo.count({ - where: { tenant_id: tenantId, driver_id: doc.driver_id, status: 'pending' }, - }); - const rejected = await this.repo.count({ - where: { tenant_id: tenantId, driver_id: doc.driver_id, status: 'rejected' }, - }); - if (remaining === 0 && rejected === 0) { - await this.drivers.approve(tenantId, doc.driver_id); - } + const req = await this.requirements(tenantId, doc.driver_id); + if (req.complete) await this.drivers.approve(tenantId, doc.driver_id); } return this.withUrl(doc); } + /** + * مطابقة الوجه بين السيلفي والهوية (كشف احتيال). Stub — يُربط بمزوّد + * رؤية (Gemini/Face API) لاحقاً كمحوّل. راجع docs/07. + */ + async faceMatch(tenantId: string, driverId: string) { + const docs = await this.repo.find({ where: { tenant_id: tenantId, driver_id: driverId } }); + const selfie = docs.find((d) => d.type === 'selfie'); + const id = docs.find((d) => d.type === 'national_id'); + if (!selfie || !id) { + throw new NotFoundException('selfie and national_id required for face match'); + } + // TODO: استدعاء مزوّد الرؤية الفعلي بالصورتين. + return { + driver_id: driverId, + score: null, + match: null, + note: 'stub — plug Gemini/Face provider', + }; + } + private withUrl(d: DriverDocument) { return { ...d, url: this.storage.url(d.file_key) }; } diff --git a/backend/src/modules/documents/entities/driver-document.entity.ts b/backend/src/modules/documents/entities/driver-document.entity.ts index 9ced2bb..939cfd0 100644 --- a/backend/src/modules/documents/entities/driver-document.entity.ts +++ b/backend/src/modules/documents/entities/driver-document.entity.ts @@ -9,12 +9,14 @@ import { import { EncryptedTransformer } from '../../../common/crypto/crypto.util'; export type DocType = - | 'license' - | 'national_id' - | 'vehicle_registration' - | 'insurance' - | 'selfie' - | 'vehicle_photo'; + | 'license' // رخصة القيادة (وجهين) + | 'national_id' // هوية الأحوال المدنية (وجهين) + | 'vehicle_registration' // دفتر/بطاقة المركبة (وجهين) + | 'insurance' // تأمين + | 'criminal_record' // عدم محكومية (البحث الجنائي) + | 'selfie' // صورة شخصية + | 'vehicle_photo'; // صورة/صور السيارة +export type DocSide = 'front' | 'back' | 'single'; export type DocStatus = 'pending' | 'approved' | 'rejected'; /** وثيقة سائق (صورة + بيانات). الجدول: tripz_driver_documents. */ @@ -34,6 +36,9 @@ export class DriverDocument { @Column() type: DocType; + @Column({ type: 'varchar', default: 'single' }) + side: DocSide; // front | back | single + @Column() file_key: string; // مفتاح التخزين (سيرفر التوطين لاحقاً) @@ -41,9 +46,20 @@ export class DriverDocument { @Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer }) number: string | null; + // اسم حامل الوثيقة — مشفّر at-rest + @Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer }) + holder_name: string | null; + + @Column({ type: 'date', nullable: true }) + issue_date: string | null; + @Column({ type: 'date', nullable: true }) expiry_date: string | null; + // حقول إضافية يدخلها موظف خدمة العملاء (سلطة إصدار، ملاحظات...) + @Column({ type: 'jsonb', default: {} }) + extra: Record; + @Column({ type: 'varchar', default: 'pending' }) status: DocStatus;