feat: driver documents (sovereignty storage adapter) + load-test script

- documents: upload (multipart) + review (admin/CS) + auto-approve driver when all docs approved
- StorageService: per-tenant local volume now, in-country storage swap via STORAGE_BASE_URL
- doc.number encrypted (AES-256-GCM); migration InitDocuments; tripz-storage volume
- scripts/loadtest.mjs: concurrent full trip-cycle benchmark (throughput + latency percentiles)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 19:36:27 +03:00
co-authored by Claude Opus 4.8
parent f3383c3cf8
commit 7a58a01417
11 changed files with 424 additions and 0 deletions
@@ -0,0 +1,63 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { DocumentsService } from './documents.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
@ApiTags('documents')
@ApiBearerAuth()
@Controller('drivers/documents')
export class DocumentsController {
constructor(private readonly documents: DocumentsService) {}
// السائق يرفع وثيقة (multipart: file + type + number + expiry_date)
@UseGuards(JwtAuthGuard)
@Post()
@UseInterceptors(FileInterceptor('file'))
upload(
@CurrentUser() user: AuthUser,
@UploadedFile() file: any,
@Body() body: any,
) {
return this.documents.upload(user.tenantId, user.userId, file, body);
}
@UseGuards(JwtAuthGuard)
@Get('mine')
mine(@CurrentUser() user: AuthUser) {
return this.documents.listMine(user.tenantId, user.userId);
}
// مراجعة: خدمة العملاء/الأدمن
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'dispatcher')
@Get('pending')
pending(@CurrentUser() user: AuthUser) {
return this.documents.pending(user.tenantId);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'dispatcher')
@Patch(':id/review')
review(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body('status') status: 'approved' | 'rejected',
@Body('note') note: string,
) {
return this.documents.review(user.tenantId, id, user.userId, status, note);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DriverDocument } from './entities/driver-document.entity';
import { DocumentsService } from './documents.service';
import { DocumentsController } from './documents.controller';
import { DriversModule } from '../drivers/drivers.module';
@Module({
imports: [TypeOrmModule.forFeature([DriverDocument]), DriversModule],
controllers: [DocumentsController],
providers: [DocumentsService],
exports: [DocumentsService],
})
export class DocumentsModule {}
@@ -0,0 +1,91 @@
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 { StorageService } from '../../common/storage/storage.service';
import { DriversService } from '../drivers/drivers.service';
@Injectable()
export class DocumentsService {
constructor(
@InjectRepository(DriverDocument) private readonly repo: Repository<DriverDocument>,
private readonly storage: StorageService,
private readonly drivers: DriversService,
) {}
async upload(
tenantId: string,
userId: string,
file: { buffer: Buffer; originalname?: string },
body: { type: DocType; number?: string; expiry_date?: string },
) {
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');
const key = await this.storage.save(tenantId, `docs/${driver.id}`, file.buffer, file.originalname);
const doc = await this.repo.save(
this.repo.create({
tenant_id: tenantId,
driver_id: driver.id,
type: body.type,
file_key: key,
number: body.number ?? null,
expiry_date: body.expiry_date ?? null,
status: 'pending',
}),
);
return this.withUrl(doc);
}
async listMine(tenantId: string, userId: string) {
const driver = await this.drivers.findByUser(tenantId, userId);
if (!driver) return [];
const docs = await this.repo.find({
where: { tenant_id: tenantId, driver_id: driver.id },
order: { created_at: 'DESC' },
});
return docs.map((d) => this.withUrl(d));
}
async pending(tenantId: string) {
const docs = await this.repo.find({
where: { tenant_id: tenantId, status: 'pending' },
order: { created_at: 'ASC' },
});
return docs.map((d) => this.withUrl(d));
}
/** مراجعة خدمة العملاء/الأدمن. عند اعتماد كل الوثائق يُعتمد السائق. */
async review(
tenantId: string,
id: string,
reviewerId: string,
status: DocStatus,
note?: string,
) {
const doc = await this.repo.findOne({ where: { tenant_id: tenantId, id } });
if (!doc) throw new NotFoundException('document not found');
doc.status = status;
doc.review_note = note ?? null;
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);
}
}
return this.withUrl(doc);
}
private withUrl(d: DriverDocument) {
return { ...d, url: this.storage.url(d.file_key) };
}
}
@@ -0,0 +1,61 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { EncryptedTransformer } from '../../../common/crypto/crypto.util';
export type DocType =
| 'license'
| 'national_id'
| 'vehicle_registration'
| 'insurance'
| 'selfie'
| 'vehicle_photo';
export type DocStatus = 'pending' | 'approved' | 'rejected';
/** وثيقة سائق (صورة + بيانات). الجدول: tripz_driver_documents. */
@Entity('driver_documents')
@Index(['tenant_id', 'driver_id'])
@Index(['tenant_id', 'status'])
export class DriverDocument {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
driver_id: string;
@Column()
type: DocType;
@Column()
file_key: string; // مفتاح التخزين (سيرفر التوطين لاحقاً)
// رقم الرخصة/الهوية — مشفّر at-rest (AES-256-GCM)
@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer })
number: string | null;
@Column({ type: 'date', nullable: true })
expiry_date: string | null;
@Column({ type: 'varchar', default: 'pending' })
status: DocStatus;
@Column({ type: 'varchar', nullable: true })
review_note: string | null;
@Column({ type: 'uuid', nullable: true })
reviewed_by: string | null;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}