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:
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Headers,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { TenantsService } from '../tenants/tenants.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';
|
||||
|
||||
const ROLES = ['rider', 'driver', 'dispatcher', 'admin'];
|
||||
|
||||
@ApiTags('admin-users')
|
||||
@Controller()
|
||||
export class AdminUsersController {
|
||||
constructor(
|
||||
private readonly users: UsersService,
|
||||
private readonly tenants: TenantsService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
// الأدمن يعيّن دور مستخدم داخل مستأجره
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@Patch('admin/users/:id/role')
|
||||
async setRole(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body('role') role: string) {
|
||||
if (!ROLES.includes(role)) throw new BadRequestException('invalid role');
|
||||
await this.users.setRole(user.tenantId, id, role);
|
||||
return this.users.findById(user.tenantId, id);
|
||||
}
|
||||
|
||||
// تمهيد: تعيين أول أدمن قبل وجود أدمن — يُحمى بسرّ المنصة (super-admin)
|
||||
@Post('platform/users/role')
|
||||
async bootstrap(
|
||||
@Headers('x-platform-secret') secret: string,
|
||||
@Body() body: { tenantSlug: string; phone: string; role: string },
|
||||
) {
|
||||
const expected = this.config.get<string>('platform.secret');
|
||||
if (!expected || secret !== expected) {
|
||||
throw new UnauthorizedException('invalid platform secret');
|
||||
}
|
||||
if (!ROLES.includes(body.role)) throw new BadRequestException('invalid role');
|
||||
const tenant = await this.tenants.resolve(body.tenantSlug);
|
||||
if (!tenant) throw new NotFoundException('unknown tenant');
|
||||
const u = await this.users.findByPhone(tenant.id, body.phone);
|
||||
if (!u) throw new NotFoundException('user not found (must log in once first)');
|
||||
await this.users.setRole(tenant.id, u.id, body.role);
|
||||
return this.users.findById(tenant.id, u.id);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from './entities/user.entity';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import { TenantsModule } from '../tenants/tenants.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [UsersController],
|
||||
imports: [TypeOrmModule.forFeature([User]), TenantsModule],
|
||||
controllers: [UsersController, AdminUsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user