From 7a58a01417ba8bed678bbe8332a9298637d1cb26 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 16 Jul 2026 19:36:27 +0300 Subject: [PATCH] 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 --- backend/docker-compose.yml | 3 + backend/scripts/loadtest.mjs | 99 +++++++++++++++++++ backend/src/app.module.ts | 4 + backend/src/common/storage/storage.module.ts | 9 ++ backend/src/common/storage/storage.service.ts | 46 +++++++++ backend/src/config/configuration.ts | 6 ++ .../migrations/1721700000000-InitDocuments.ts | 28 ++++++ .../modules/documents/documents.controller.ts | 63 ++++++++++++ .../src/modules/documents/documents.module.ts | 14 +++ .../modules/documents/documents.service.ts | 91 +++++++++++++++++ .../entities/driver-document.entity.ts | 61 ++++++++++++ 11 files changed, 424 insertions(+) create mode 100644 backend/scripts/loadtest.mjs create mode 100644 backend/src/common/storage/storage.module.ts create mode 100644 backend/src/common/storage/storage.service.ts create mode 100644 backend/src/database/migrations/1721700000000-InitDocuments.ts create mode 100644 backend/src/modules/documents/documents.controller.ts create mode 100644 backend/src/modules/documents/documents.module.ts create mode 100644 backend/src/modules/documents/documents.service.ts create mode 100644 backend/src/modules/documents/entities/driver-document.entity.ts diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index b1f2e5e..61d89ca 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -51,6 +51,8 @@ services: depends_on: [postgres, redis] ports: - "${API_PORT:-4010}:4010" + volumes: + - tripz-storage:/app/storage networks: [tripz-net] worker: @@ -68,6 +70,7 @@ services: volumes: tripz-pgdata: tripz-redisdata: + tripz-storage: networks: tripz-net: diff --git a/backend/scripts/loadtest.mjs b/backend/scripts/loadtest.mjs new file mode 100644 index 0000000..9c52f02 --- /dev/null +++ b/backend/scripts/loadtest.mjs @@ -0,0 +1,99 @@ +// اختبار تحمّل: يشغّل دورة رحلة كاملة (طلب→قبول→حالات→دفع) بالتوازي ويقيس القدرة. +// يستخدم fetch المدمج (Node 18+) — بلا تبعيات. يغطّي: تعرفة + خرائط + مطابقة GEO +// + آلة الحالة + كتابة القاعدة + بث السوكت (خادمياً). +// +// التشغيل (حاوية node مؤقتة على شبكة tripz): +// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \ +// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/loadtest.mjs 300 30 +// الوسائط: <إجمالي الرحلات> <التزامن> + +const BASE = process.env.BASE || 'http://localhost:4010/api'; +const TENANT = process.env.TENANT || 'siro'; +const TOTAL = parseInt(process.argv[2] || '200', 10); +const CONC = parseInt(process.argv[3] || '20', 10); +const CODE = '1234'; +const ORIGIN = { lat: 31.9539, lng: 35.9106 }; + +const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) }); +const jitter = (n) => n + (Math.random() - 0.5) * 0.01; + +async function api(method, path, token, body) { + const res = await fetch(`${BASE}${path}`, { + method, + headers: { ...H(token), 'x-tenant-id': TENANT }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${await res.text().catch(() => '')}`); + return res.json().catch(() => ({})); +} + +async function token(phone) { + const r = await api('POST', '/auth/verify-otp', null, { phone, code: CODE }); + return r.access_token; +} + +async function setupDriver(i) { + const t = await token(`0795${String(100000 + i).slice(-6)}`); + const d = await api('POST', '/drivers/apply', t, { vehicle_make: 'LoadTest', service_class: 'economy' }); + await api('PATCH', `/drivers/${d.id}/approve`, t, {}); + await api('PATCH', '/drivers/status', t, { online: true }); + await api('POST', '/drivers/location', t, { lat: jitter(ORIGIN.lat), lng: jitter(ORIGIN.lng) }); + return t; +} + +async function oneTrip(riderTok, driverTok) { + const start = Date.now(); + const { trip } = await api('POST', '/trips', riderTok, { + origin: { lat: jitter(ORIGIN.lat), lng: jitter(ORIGIN.lng) }, + destination: { lat: jitter(ORIGIN.lat + 0.03), lng: jitter(ORIGIN.lng + 0.03) }, + service_class: 'economy', + }); + await api('POST', `/trips/${trip.id}/accept`, driverTok, {}); + for (const s of ['driver_arriving', 'driver_arrived', 'in_progress', 'completed', 'paid']) { + await api('PATCH', `/trips/${trip.id}/status`, driverTok, { status: s }); + } + return Date.now() - start; +} + +async function main() { + console.log(`Setup ${CONC} drivers+riders...`); + const drivers = [], riders = []; + for (let i = 0; i < CONC; i++) { + drivers.push(await setupDriver(i)); + riders.push(await token(`0796${String(100000 + i).slice(-6)}`)); + } + + console.log(`Running ${TOTAL} trips @ concurrency ${CONC}...`); + let done = 0, errors = 0; + const lats = []; + const t0 = Date.now(); + + await Promise.all( + Array.from({ length: CONC }, (_, w) => + (async () => { + while (true) { + const idx = done++; + if (idx >= TOTAL) break; + try { + lats.push(await oneTrip(riders[w], drivers[w])); + } catch (e) { + errors++; + if (errors <= 5) console.error('ERR:', e.message); + } + } + })(), + ), + ); + + const secs = (Date.now() - t0) / 1000; + lats.sort((a, b) => a - b); + const pct = (p) => lats[Math.min(lats.length - 1, Math.floor((p / 100) * lats.length))] || 0; + const ok = lats.length; + console.log('\n===== النتائج ====='); + console.log(`رحلات ناجحة: ${ok} / ${TOTAL} · أخطاء: ${errors}`); + console.log(`الزمن: ${secs.toFixed(1)}s · الإنتاجية: ${(ok / secs).toFixed(1)} رحلة/ث (~${Math.round((ok / secs) * 86400).toLocaleString()} رحلة/يوم)`); + console.log(`زمن الرحلة الكاملة (ms): p50=${pct(50)} · p95=${pct(95)} · max=${pct(100)}`); + console.log('ملاحظة: كل رحلة = 7 نداءات API (طلب+قبول+5 حالات).'); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3d4bbff..20032c0 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -25,6 +25,8 @@ import { DispatchModule } from './modules/dispatch/dispatch.module'; import { WalletModule } from './modules/wallet/wallet.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { PaymentsModule } from './modules/payments/payments.module'; +import { StorageModule } from './common/storage/storage.module'; +import { DocumentsModule } from './modules/documents/documents.module'; @Module({ imports: [ @@ -51,6 +53,7 @@ import { PaymentsModule } from './modules/payments/payments.module'; RedisModule, // عالمي — عميل Redis للمطابقة و OTP NabehModule, // عالمي — إرسال OTP واتساب NotificationsModule, // عالمي — FCM + StorageModule, // عالمي — تخزين ملفات الوثائق HealthModule, TenantsModule, @@ -69,6 +72,7 @@ import { PaymentsModule } from './modules/payments/payments.module'; DispatchModule, ChatModule, RatingsModule, + DocumentsModule, SeedModule, ], }) diff --git a/backend/src/common/storage/storage.module.ts b/backend/src/common/storage/storage.module.ts new file mode 100644 index 0000000..8151be1 --- /dev/null +++ b/backend/src/common/storage/storage.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { StorageService } from './storage.service'; + +@Global() +@Module({ + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/backend/src/common/storage/storage.service.ts b/backend/src/common/storage/storage.service.ts new file mode 100644 index 0000000..563833e --- /dev/null +++ b/backend/src/common/storage/storage.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { promises as fs } from 'fs'; +import { join, dirname, extname } from 'path'; +import { randomUUID } from 'crypto'; + +/** + * مُحوّل تخزين الملفات. محلياً الآن (قرص/فوليوم)، وقابل للتحويل لسيرفر تخزين + * داخل البلد (السيادة الرقمية) بتغيير STORAGE_BASE_URL + STORAGE_DIR (docs/16، docs/06). + * الملفات لكل مستأجر معزولة تحت مجلده. + */ +@Injectable() +export class StorageService { + constructor(private readonly config: ConfigService) {} + + private get dir(): string { + return this.config.get('storage.dir') ?? '/app/storage'; + } + private get baseUrl(): string { + return this.config.get('storage.baseUrl') ?? ''; + } + + /** يحفظ ملفاً ويعيد مفتاحه (مسار نسبي معزول بالمستأجر). */ + async save( + tenantId: string, + scope: string, + buffer: Buffer, + originalName = '', + ): Promise { + const ext = extname(originalName) || '.bin'; + const key = `${tenantId}/${scope}/${randomUUID()}${ext}`; + const full = join(this.dir, key); + await fs.mkdir(dirname(full), { recursive: true }); + await fs.writeFile(full, buffer); + return key; + } + + /** رابط الوصول (يشير لسيرفر التوطين حين يُضبط STORAGE_BASE_URL). */ + url(key: string): string { + return this.baseUrl ? `${this.baseUrl}/${key}` : `/storage/${key}`; + } + + async read(key: string): Promise { + return fs.readFile(join(this.dir, key)); + } +} diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index f89224b..0126cd3 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -66,6 +66,12 @@ export default () => ({ // رموز الاتصال الدولية لكل country pack (لتنسيق الهاتف قبل الإرسال). callingCodes: { jo: '962', sy: '963' } as Record, + // تخزين ملفات الوثائق. baseUrl فارغ = محلي؛ يُضبط لسيرفر التوطين لاحقاً (docs/06). + storage: { + dir: process.env.STORAGE_DIR ?? '/app/storage', + baseUrl: process.env.STORAGE_BASE_URL ?? '', + }, + // إشعارات FCM (اتركه فارغاً لتعطيل الإرسال — يُسجَّل فقط). fcm: { serverKey: process.env.FCM_SERVER_KEY ?? '', diff --git a/backend/src/database/migrations/1721700000000-InitDocuments.ts b/backend/src/database/migrations/1721700000000-InitDocuments.ts new file mode 100644 index 0000000..0894cc4 --- /dev/null +++ b/backend/src/database/migrations/1721700000000-InitDocuments.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** وثائق السائق. الجدول: tripz_driver_documents. */ +export class InitDocuments1721700000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + await q.query(` + CREATE TABLE IF NOT EXISTS tripz_driver_documents ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id uuid NOT NULL, + driver_id uuid NOT NULL, + type varchar NOT NULL, + file_key varchar NOT NULL, + number varchar, + expiry_date date, + status varchar NOT NULL DEFAULT 'pending', + review_note varchar, + reviewed_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + )`); + await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_documents_driver" ON tripz_driver_documents (tenant_id, driver_id)`); + await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_documents_status" ON tripz_driver_documents (tenant_id, status)`); + } + + public async down(q: QueryRunner): Promise { + await q.query(`DROP TABLE IF EXISTS tripz_driver_documents`); + } +} diff --git a/backend/src/modules/documents/documents.controller.ts b/backend/src/modules/documents/documents.controller.ts new file mode 100644 index 0000000..27a6e08 --- /dev/null +++ b/backend/src/modules/documents/documents.controller.ts @@ -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); + } +} diff --git a/backend/src/modules/documents/documents.module.ts b/backend/src/modules/documents/documents.module.ts new file mode 100644 index 0000000..c0ca072 --- /dev/null +++ b/backend/src/modules/documents/documents.module.ts @@ -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 {} diff --git a/backend/src/modules/documents/documents.service.ts b/backend/src/modules/documents/documents.service.ts new file mode 100644 index 0000000..b7af9e2 --- /dev/null +++ b/backend/src/modules/documents/documents.service.ts @@ -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, + 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) }; + } +} diff --git a/backend/src/modules/documents/entities/driver-document.entity.ts b/backend/src/modules/documents/entities/driver-document.entity.ts new file mode 100644 index 0000000..9ced2bb --- /dev/null +++ b/backend/src/modules/documents/entities/driver-document.entity.ts @@ -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; +}