feat(documents): full regional doc set (front/back + criminal record) + CS data-entry + DB pool bump
- doc types with side (front/back/single): license, national_id, vehicle_registration (both sides), criminal_record, selfie, vehicle_photo - fields: side, holder_name(encrypted), issue_date, extra jsonb; REQUIRED_DOCS checklist + /requirements - CS/admin: POST /admin/drivers/:id/documents (upload for driver), list, review auto-approves when complete - face-match endpoint stub (Gemini/Face provider later) - DB pool extra.max=30 (raises concurrency ceiling); migration DocumentsFields Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,8 @@ import { DocumentsModule } from './modules/documents/documents.module';
|
|||||||
database: cfg.get<string>('db.name'),
|
database: cfg.get<string>('db.name'),
|
||||||
username: cfg.get<string>('db.user'),
|
username: cfg.get<string>('db.user'),
|
||||||
password: cfg.get<string>('db.password'),
|
password: cfg.get<string>('db.password'),
|
||||||
|
// بركة اتصالات أكبر — يرفع سقف التزامن تحت الحمل العالي
|
||||||
|
extra: { max: parseInt(process.env.DB_POOL_MAX ?? '30', 10) },
|
||||||
// بادئة الجداول (tripz_) لعزل السيرفر المشترك — راجع docs/14
|
// بادئة الجداول (tripz_) لعزل السيرفر المشترك — راجع docs/14
|
||||||
entityPrefix: cfg.get<string>('db.tablePrefix'),
|
entityPrefix: cfg.get<string>('db.tablePrefix'),
|
||||||
synchronize: cfg.get<boolean>('db.synchronize'),
|
synchronize: cfg.get<boolean>('db.synchronize'),
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/** حقول إضافية للوثائق: الوجه، اسم الحامل، تاريخ الإصدار، حقول إضافية. */
|
||||||
|
export class DocumentsFields1721750000000 implements MigrationInterface {
|
||||||
|
public async up(q: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,39 +19,73 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator
|
|||||||
|
|
||||||
@ApiTags('documents')
|
@ApiTags('documents')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('drivers/documents')
|
@Controller()
|
||||||
export class DocumentsController {
|
export class DocumentsController {
|
||||||
constructor(private readonly documents: DocumentsService) {}
|
constructor(private readonly documents: DocumentsService) {}
|
||||||
|
|
||||||
// السائق يرفع وثيقة (multipart: file + type + number + expiry_date)
|
// ===== السائق نفسه =====
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post()
|
@Post('drivers/documents')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
upload(
|
upload(@CurrentUser() user: AuthUser, @UploadedFile() file: any, @Body() body: any) {
|
||||||
@CurrentUser() user: AuthUser,
|
return this.documents.uploadSelf(user.tenantId, user.userId, file, body);
|
||||||
@UploadedFile() file: any,
|
|
||||||
@Body() body: any,
|
|
||||||
) {
|
|
||||||
return this.documents.upload(user.tenantId, user.userId, file, body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('mine')
|
@Get('drivers/documents/mine')
|
||||||
mine(@CurrentUser() user: AuthUser) {
|
mine(@CurrentUser() user: AuthUser) {
|
||||||
return this.documents.listMine(user.tenantId, user.userId);
|
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)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles('admin', 'dispatcher')
|
@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) {
|
pending(@CurrentUser() user: AuthUser) {
|
||||||
return this.documents.pending(user.tenantId);
|
return this.documents.pending(user.tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles('admin', 'dispatcher')
|
@Roles('admin', 'dispatcher')
|
||||||
@Patch(':id/review')
|
@Patch('admin/documents/:id/review')
|
||||||
review(
|
review(
|
||||||
@CurrentUser() user: AuthUser,
|
@CurrentUser() user: AuthUser,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -60,4 +94,11 @@ export class DocumentsController {
|
|||||||
) {
|
) {
|
||||||
return this.documents.review(user.tenantId, id, user.userId, status, note);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from '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 { StorageService } from '../../common/storage/storage.service';
|
||||||
import { DriversService } from '../drivers/drivers.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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DocumentsService {
|
export class DocumentsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -13,36 +38,65 @@ export class DocumentsService {
|
|||||||
private readonly drivers: DriversService,
|
private readonly drivers: DriversService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async upload(
|
/** رفع من السائق نفسه. */
|
||||||
|
async uploadSelf(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
file: { buffer: Buffer; originalname?: string },
|
file: { buffer: Buffer; originalname?: string },
|
||||||
body: { type: DocType; number?: string; expiry_date?: string },
|
body: DocFields,
|
||||||
) {
|
) {
|
||||||
const driver = await this.drivers.findByUser(tenantId, userId);
|
const driver = await this.drivers.findByUser(tenantId, userId);
|
||||||
if (!driver) throw new ForbiddenException('driver profile required');
|
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(
|
const doc = await this.repo.save(
|
||||||
this.repo.create({
|
this.repo.create({
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
driver_id: driver.id,
|
driver_id: driverId,
|
||||||
type: body.type,
|
type: body.type,
|
||||||
|
side: body.side ?? 'single',
|
||||||
file_key: key,
|
file_key: key,
|
||||||
number: body.number ?? null,
|
number: body.number ?? null,
|
||||||
|
holder_name: body.holder_name ?? null,
|
||||||
|
issue_date: body.issue_date ?? null,
|
||||||
expiry_date: body.expiry_date ?? null,
|
expiry_date: body.expiry_date ?? null,
|
||||||
|
extra: body.extra ?? {},
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return this.withUrl(doc);
|
return this.withUrl(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listMine(tenantId: string, userId: string) {
|
listMine(tenantId: string, userId: string) {
|
||||||
const driver = await this.drivers.findByUser(tenantId, userId);
|
return this.drivers.findByUser(tenantId, userId).then((d) =>
|
||||||
if (!driver) return [];
|
d ? this.listByDriver(tenantId, d.id) : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listByDriver(tenantId: string, driverId: string) {
|
||||||
const docs = await this.repo.find({
|
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' },
|
order: { created_at: 'DESC' },
|
||||||
});
|
});
|
||||||
return docs.map((d) => this.withUrl(d));
|
return docs.map((d) => this.withUrl(d));
|
||||||
@@ -56,7 +110,25 @@ export class DocumentsService {
|
|||||||
return docs.map((d) => this.withUrl(d));
|
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(
|
async review(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
id: string,
|
id: string,
|
||||||
@@ -71,20 +143,34 @@ export class DocumentsService {
|
|||||||
doc.reviewed_by = reviewerId;
|
doc.reviewed_by = reviewerId;
|
||||||
await this.repo.save(doc);
|
await this.repo.save(doc);
|
||||||
|
|
||||||
|
// عند اكتمال كل الوثائق المطلوبة معتمَدةً → يُعتمد السائق
|
||||||
if (status === 'approved') {
|
if (status === 'approved') {
|
||||||
const remaining = await this.repo.count({
|
const req = await this.requirements(tenantId, doc.driver_id);
|
||||||
where: { tenant_id: tenantId, driver_id: doc.driver_id, status: 'pending' },
|
if (req.complete) await this.drivers.approve(tenantId, doc.driver_id);
|
||||||
});
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return this.withUrl(doc);
|
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) {
|
private withUrl(d: DriverDocument) {
|
||||||
return { ...d, url: this.storage.url(d.file_key) };
|
return { ...d, url: this.storage.url(d.file_key) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import {
|
|||||||
import { EncryptedTransformer } from '../../../common/crypto/crypto.util';
|
import { EncryptedTransformer } from '../../../common/crypto/crypto.util';
|
||||||
|
|
||||||
export type DocType =
|
export type DocType =
|
||||||
| 'license'
|
| 'license' // رخصة القيادة (وجهين)
|
||||||
| 'national_id'
|
| 'national_id' // هوية الأحوال المدنية (وجهين)
|
||||||
| 'vehicle_registration'
|
| 'vehicle_registration' // دفتر/بطاقة المركبة (وجهين)
|
||||||
| 'insurance'
|
| 'insurance' // تأمين
|
||||||
| 'selfie'
|
| 'criminal_record' // عدم محكومية (البحث الجنائي)
|
||||||
| 'vehicle_photo';
|
| 'selfie' // صورة شخصية
|
||||||
|
| 'vehicle_photo'; // صورة/صور السيارة
|
||||||
|
export type DocSide = 'front' | 'back' | 'single';
|
||||||
export type DocStatus = 'pending' | 'approved' | 'rejected';
|
export type DocStatus = 'pending' | 'approved' | 'rejected';
|
||||||
|
|
||||||
/** وثيقة سائق (صورة + بيانات). الجدول: tripz_driver_documents. */
|
/** وثيقة سائق (صورة + بيانات). الجدول: tripz_driver_documents. */
|
||||||
@@ -34,6 +36,9 @@ export class DriverDocument {
|
|||||||
@Column()
|
@Column()
|
||||||
type: DocType;
|
type: DocType;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', default: 'single' })
|
||||||
|
side: DocSide; // front | back | single
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
file_key: string; // مفتاح التخزين (سيرفر التوطين لاحقاً)
|
file_key: string; // مفتاح التخزين (سيرفر التوطين لاحقاً)
|
||||||
|
|
||||||
@@ -41,9 +46,20 @@ export class DriverDocument {
|
|||||||
@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer })
|
@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer })
|
||||||
number: string | null;
|
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 })
|
@Column({ type: 'date', nullable: true })
|
||||||
expiry_date: string | null;
|
expiry_date: string | null;
|
||||||
|
|
||||||
|
// حقول إضافية يدخلها موظف خدمة العملاء (سلطة إصدار، ملاحظات...)
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
extra: Record<string, any>;
|
||||||
|
|
||||||
@Column({ type: 'varchar', default: 'pending' })
|
@Column({ type: 'varchar', default: 'pending' })
|
||||||
status: DocStatus;
|
status: DocStatus;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user