feat: Gemini AI (doc OCR + face match) + role-assignment endpoints

- GeminiService (integrations/gemini, @Global): extractDocument (OCR fields) + faceMatch; graceful if no GEMINI_API_KEY
- documents: face-match now real via Gemini; POST /admin/documents/:id/ocr extracts fields for CS auto-fill
- roles: PATCH /admin/users/:id/role (admin) + POST /platform/users/role (bootstrap via x-platform-secret)
- config: gemini + platform.secret; .env.example entries

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 20:01:26 +03:00
co-authored by Claude Opus 4.8
parent 04200db438
commit 6234e66471
1541 changed files with 267713 additions and 16 deletions
@@ -101,4 +101,12 @@ export class DocumentsController {
faceMatch(@Param('driverId') driverId: string, @CurrentUser() user: AuthUser) {
return this.documents.faceMatch(user.tenantId, driverId);
}
// قراءة وثيقة بالذكاء الاصطناعي (Gemini) لملء الحقول تلقائياً
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'dispatcher')
@Post('admin/documents/:id/ocr')
ocr(@Param('id') id: string, @CurrentUser() user: AuthUser) {
return this.documents.ocr(user.tenantId, id);
}
}
@@ -4,6 +4,7 @@ import { Repository } from 'typeorm';
import { DriverDocument, DocStatus, DocType, DocSide } from './entities/driver-document.entity';
import { StorageService } from '../../common/storage/storage.service';
import { DriversService } from '../drivers/drivers.service';
import { GeminiService } from '../../integrations/gemini/gemini.service';
/** الوثائق المطلوبة معيارياً لكل سائق (الأردن/المنطقة). */
export const REQUIRED_DOCS: Array<{
@@ -36,8 +37,13 @@ export class DocumentsService {
@InjectRepository(DriverDocument) private readonly repo: Repository<DriverDocument>,
private readonly storage: StorageService,
private readonly drivers: DriversService,
private readonly gemini: GeminiService,
) {}
private mimeOf(key: string): string {
return key.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
}
/** رفع من السائق نفسه. */
async uploadSelf(
tenantId: string,
@@ -151,10 +157,7 @@ export class DocumentsService {
return this.withUrl(doc);
}
/**
* مطابقة الوجه بين السيلفي والهوية (كشف احتيال). Stub — يُربط بمزوّد
* رؤية (Gemini/Face API) لاحقاً كمحوّل. راجع docs/07.
*/
/** مطابقة الوجه بين السيلفي والهوية عبر Gemini (كشف احتيال). */
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');
@@ -162,13 +165,29 @@ export class DocumentsService {
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',
};
if (!this.gemini.enabled) return { driver_id: driverId, enabled: false, note: 'set GEMINI_API_KEY' };
const [selfieBuf, idBuf] = await Promise.all([
this.storage.read(selfie.file_key),
this.storage.read(id.file_key),
]);
const result = await this.gemini.faceMatch(
{ buffer: selfieBuf, mime: this.mimeOf(selfie.file_key) },
{ buffer: idBuf, mime: this.mimeOf(id.file_key) },
);
return { driver_id: driverId, ...result };
}
/** قراءة وثيقة بالذكاء الاصطناعي واستخراج حقولها (لملء نموذج خدمة العملاء). */
async ocr(tenantId: string, docId: string) {
const doc = await this.repo.findOne({ where: { tenant_id: tenantId, id: docId } });
if (!doc) throw new NotFoundException('document not found');
if (!this.gemini.enabled) return { enabled: false, note: 'set GEMINI_API_KEY' };
const buffer = await this.storage.read(doc.file_key);
const fields = await this.gemini.extractDocument(
{ buffer, mime: this.mimeOf(doc.file_key) },
doc.type,
);
return { document_id: docId, type: doc.type, extracted: fields };
}
private withUrl(d: DriverDocument) {