diff --git a/src/app.module.ts b/src/app.module.ts index cf2b77c..49c5082 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,6 +21,7 @@ import { MarketplaceModule } from './modules/marketplace/marketplace.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { DeliveryModule } from './modules/delivery/delivery.module'; import { TemplatesModule } from './modules/templates/templates.module'; +import { OnboardingModule } from './modules/onboarding/onboarding.module'; @Module({ imports: [ @@ -70,6 +71,7 @@ import { TemplatesModule } from './modules/templates/templates.module'; NotificationsModule, DeliveryModule, TemplatesModule, + OnboardingModule, ], }) export class AppModule {} diff --git a/src/modules/onboarding/dto/onboarding.dto.ts b/src/modules/onboarding/dto/onboarding.dto.ts new file mode 100644 index 0000000..9d7ab72 --- /dev/null +++ b/src/modules/onboarding/dto/onboarding.dto.ts @@ -0,0 +1,149 @@ +import { IsEnum, IsOptional, IsString, IsNumber, IsUUID, IsBoolean, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { OnboardingChannel, OnboardingStatus } from '../entities/onboarding-session.entity'; +import { TemplateGiro } from '../../templates/entities/product-template.entity'; + +export class StartOnboardingDto { + @IsString() + phoneNumber: string; + + @IsOptional() + @IsEnum(OnboardingChannel) + channel?: OnboardingChannel; + + @IsOptional() + @IsString() + ownerName?: string; +} + +export class UpdateBusinessInfoDto { + @IsOptional() + @IsString() + businessName?: string; + + @IsOptional() + @IsEnum(TemplateGiro) + giro?: TemplateGiro; + + @IsOptional() + @IsString() + ownerName?: string; +} + +export class ProcessImageDto { + @IsUUID() + sessionId: string; + + @IsString() + imageUrl: string; + + @IsOptional() + @IsString() + mimeType?: string; +} + +export class ProcessAudioDto { + @IsUUID() + sessionId: string; + + @IsString() + audioUrl: string; + + @IsOptional() + @IsString() + mimeType?: string; +} + +export class ConfirmProductDto { + @IsUUID() + scanId: string; + + @IsString() + name: string; + + @IsNumber() + @Min(0.01) + @Max(99999) + @Type(() => Number) + price: number; + + @IsOptional() + @IsString() + barcode?: string; +} + +export class RejectProductDto { + @IsUUID() + scanId: string; + + @IsOptional() + @IsString() + reason?: string; +} + +export class ImportTemplatesDto { + @IsUUID() + sessionId: string; + + @IsOptional() + @IsEnum(TemplateGiro) + giro?: TemplateGiro; + + @IsOptional() + @IsString({ each: true }) + templateIds?: string[]; +} + +export class OnboardingSessionResponseDto { + id: string; + phoneNumber: string; + channel: OnboardingChannel; + status: OnboardingStatus; + currentStep: number; + totalSteps: number; + businessName?: string; + businessGiro?: string; + ownerName?: string; + templatesImported: number; + productsAdded: number; + photosProcessed: number; + audiosProcessed: number; + startedAt: Date; + completedAt?: Date; +} + +export class ProductScanResponseDto { + id: string; + type: string; + status: string; + detectedName?: string; + detectedPrice?: number; + detectedBarcode?: string; + confidenceScore?: number; + templateMatch?: { + id: string; + name: string; + suggestedPrice: number; + matchScore: number; + }; + createdAt: Date; +} + +export class OnboardingStepDto { + step: number; + name: string; + description: string; + completed: boolean; + data?: any; +} + +export class OnboardingProgressDto { + session: OnboardingSessionResponseDto; + steps: OnboardingStepDto[]; + suggestedTemplates?: Array<{ + provider: string; + name: string; + productCount: number; + }>; + pendingScans: ProductScanResponseDto[]; +} diff --git a/src/modules/onboarding/entities/onboarding-session.entity.ts b/src/modules/onboarding/entities/onboarding-session.entity.ts new file mode 100644 index 0000000..bb4a590 --- /dev/null +++ b/src/modules/onboarding/entities/onboarding-session.entity.ts @@ -0,0 +1,89 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum OnboardingStatus { + STARTED = 'started', + BUSINESS_INFO = 'business_info', + GIRO_SELECTED = 'giro_selected', + TEMPLATES_LOADED = 'templates_loaded', + PRODUCTS_ADDED = 'products_added', + COMPLETED = 'completed', + ABANDONED = 'abandoned', +} + +export enum OnboardingChannel { + WHATSAPP = 'whatsapp', + WEB = 'web', + APP = 'app', +} + +@Entity({ schema: 'auth', name: 'onboarding_sessions' }) +@Index(['tenantId', 'status']) +@Index(['phoneNumber']) +export class OnboardingSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', nullable: true }) + tenantId: string; + + @Column({ name: 'phone_number', length: 20 }) + phoneNumber: string; + + @Column({ type: 'enum', enum: OnboardingChannel, default: OnboardingChannel.WHATSAPP }) + channel: OnboardingChannel; + + @Column({ type: 'enum', enum: OnboardingStatus, default: OnboardingStatus.STARTED }) + status: OnboardingStatus; + + @Column({ name: 'current_step', type: 'int', default: 1 }) + currentStep: number; + + @Column({ name: 'total_steps', type: 'int', default: 5 }) + totalSteps: number; + + // Business info collected + @Column({ name: 'business_name', length: 100, nullable: true }) + businessName: string; + + @Column({ name: 'business_giro', length: 50, nullable: true }) + businessGiro: string; + + @Column({ name: 'owner_name', length: 100, nullable: true }) + ownerName: string; + + // Progress tracking + @Column({ name: 'templates_imported', type: 'int', default: 0 }) + templatesImported: number; + + @Column({ name: 'products_added', type: 'int', default: 0 }) + productsAdded: number; + + @Column({ name: 'photos_processed', type: 'int', default: 0 }) + photosProcessed: number; + + @Column({ name: 'audios_processed', type: 'int', default: 0 }) + audiosProcessed: number; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @Column({ name: 'last_message_at', type: 'timestamptz', nullable: true }) + lastMessageAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @CreateDateColumn({ name: 'started_at' }) + startedAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/src/modules/onboarding/entities/product-scan.entity.ts b/src/modules/onboarding/entities/product-scan.entity.ts new file mode 100644 index 0000000..14ce537 --- /dev/null +++ b/src/modules/onboarding/entities/product-scan.entity.ts @@ -0,0 +1,103 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { OnboardingSession } from './onboarding-session.entity'; + +export enum ScanType { + PHOTO = 'photo', + AUDIO = 'audio', + BARCODE = 'barcode', +} + +export enum ScanStatus { + PENDING = 'pending', + PROCESSING = 'processing', + DETECTED = 'detected', + CONFIRMED = 'confirmed', + REJECTED = 'rejected', + FAILED = 'failed', +} + +@Entity({ schema: 'catalog', name: 'product_scans' }) +@Index(['sessionId', 'status']) +export class ProductScan { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'session_id' }) + sessionId: string; + + @Column({ name: 'tenant_id', nullable: true }) + tenantId: string; + + @Column({ type: 'enum', enum: ScanType }) + type: ScanType; + + @Column({ type: 'enum', enum: ScanStatus, default: ScanStatus.PENDING }) + status: ScanStatus; + + // Input data + @Column({ name: 'media_url', type: 'text', nullable: true }) + mediaUrl: string; + + @Column({ name: 'media_mime_type', length: 50, nullable: true }) + mediaMimeType: string; + + @Column({ name: 'raw_text', type: 'text', nullable: true }) + rawText: string; + + // OCR/Transcription results + @Column({ name: 'detected_name', length: 150, nullable: true }) + detectedName: string; + + @Column({ name: 'detected_price', type: 'decimal', precision: 10, scale: 2, nullable: true }) + detectedPrice: number; + + @Column({ name: 'detected_barcode', length: 50, nullable: true }) + detectedBarcode: string; + + @Column({ name: 'confidence_score', type: 'decimal', precision: 3, scale: 2, nullable: true }) + confidenceScore: number; + + // Matched template + @Column({ name: 'template_id', nullable: true }) + templateId: string; + + @Column({ name: 'template_match_score', type: 'decimal', precision: 3, scale: 2, nullable: true }) + templateMatchScore: number; + + // Final product created + @Column({ name: 'product_id', nullable: true }) + productId: string; + + // User corrections + @Column({ name: 'user_confirmed_name', length: 150, nullable: true }) + userConfirmedName: string; + + @Column({ name: 'user_confirmed_price', type: 'decimal', precision: 10, scale: 2, nullable: true }) + userConfirmedPrice: number; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'processing_time_ms', type: 'int', nullable: true }) + processingTimeMs: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @ManyToOne(() => OnboardingSession, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'session_id' }) + session: OnboardingSession; +} diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts new file mode 100644 index 0000000..c8fb76e --- /dev/null +++ b/src/modules/onboarding/onboarding.controller.ts @@ -0,0 +1,113 @@ +import { + Controller, + Get, + Post, + Put, + Body, + Param, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { OnboardingService } from './onboarding.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + StartOnboardingDto, + UpdateBusinessInfoDto, + ProcessImageDto, + ProcessAudioDto, + ConfirmProductDto, + RejectProductDto, + ImportTemplatesDto, +} from './dto/onboarding.dto'; +import { TemplateGiro } from '../templates/entities/product-template.entity'; + +@Controller('onboarding') +export class OnboardingController { + constructor(private readonly onboardingService: OnboardingService) {} + + // === PUBLIC ENDPOINTS (No auth required for starting) === + + @Post('start') + async startSession(@Body() dto: StartOnboardingDto) { + return this.onboardingService.startSession(dto); + } + + @Get('session/:id') + async getSession(@Param('id') id: string) { + return this.onboardingService.getSession(id); + } + + @Get('session/:id/progress') + async getProgress(@Param('id') id: string) { + return this.onboardingService.getProgress(id); + } + + @Get('session/phone/:phone') + async getSessionByPhone(@Param('phone') phone: string) { + return this.onboardingService.getSessionByPhone(phone); + } + + @Put('session/:id/business-info') + async updateBusinessInfo( + @Param('id') id: string, + @Body() dto: UpdateBusinessInfoDto, + ) { + return this.onboardingService.updateBusinessInfo(id, dto); + } + + // === IMAGE PROCESSING === + + @Post('process-image') + async processImage(@Body() dto: ProcessImageDto) { + return this.onboardingService.processImage(dto); + } + + // === AUDIO PROCESSING === + + @Post('process-audio') + async processAudio(@Body() dto: ProcessAudioDto) { + return this.onboardingService.processAudio(dto); + } + + // === PROTECTED ENDPOINTS (Require auth) === + + @Post('confirm-product') + @UseGuards(JwtAuthGuard) + async confirmProduct(@Request() req, @Body() dto: ConfirmProductDto) { + const tenantId = req.user.tenantId; + return this.onboardingService.confirmProduct(tenantId, dto); + } + + @Post('reject-product') + async rejectProduct(@Body() dto: RejectProductDto) { + return this.onboardingService.rejectProduct(dto.scanId, dto.reason); + } + + @Post('import-templates') + @UseGuards(JwtAuthGuard) + async importTemplates(@Request() req, @Body() dto: ImportTemplatesDto) { + const tenantId = req.user.tenantId; + return this.onboardingService.importTemplates(tenantId, dto.sessionId, dto.giro); + } + + @Post('session/:id/complete') + @UseGuards(JwtAuthGuard) + async completeOnboarding(@Request() req, @Param('id') id: string) { + const tenantId = req.user.tenantId; + return this.onboardingService.completeOnboarding(id, tenantId); + } + + @Post('session/:id/abandon') + async abandonSession(@Param('id') id: string) { + return this.onboardingService.abandonSession(id); + } + + // === STATS === + + @Get('stats') + @UseGuards(JwtAuthGuard) + async getStats() { + return this.onboardingService.getStats(); + } +} diff --git a/src/modules/onboarding/onboarding.module.ts b/src/modules/onboarding/onboarding.module.ts new file mode 100644 index 0000000..bf0b42c --- /dev/null +++ b/src/modules/onboarding/onboarding.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { OnboardingController } from './onboarding.controller'; +import { OnboardingService } from './onboarding.service'; +import { OcrService } from './services/ocr.service'; +import { WhisperService } from './services/whisper.service'; +import { OnboardingSession } from './entities/onboarding-session.entity'; +import { ProductScan } from './entities/product-scan.entity'; +import { AuthModule } from '../auth/auth.module'; +import { TemplatesModule } from '../templates/templates.module'; +import { ProductsModule } from '../products/products.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([OnboardingSession, ProductScan]), + AuthModule, + TemplatesModule, + ProductsModule, + ], + controllers: [OnboardingController], + providers: [OnboardingService, OcrService, WhisperService], + exports: [OnboardingService, OcrService, WhisperService], +}) +export class OnboardingModule {} diff --git a/src/modules/onboarding/onboarding.service.ts b/src/modules/onboarding/onboarding.service.ts new file mode 100644 index 0000000..0815409 --- /dev/null +++ b/src/modules/onboarding/onboarding.service.ts @@ -0,0 +1,469 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { OnboardingSession, OnboardingStatus, OnboardingChannel } from './entities/onboarding-session.entity'; +import { ProductScan, ScanType, ScanStatus } from './entities/product-scan.entity'; +import { OcrService } from './services/ocr.service'; +import { WhisperService } from './services/whisper.service'; +import { TemplatesService } from '../templates/templates.service'; +import { ProductsService } from '../products/products.service'; +import { TemplateGiro } from '../templates/entities/product-template.entity'; +import { + StartOnboardingDto, + UpdateBusinessInfoDto, + ProcessImageDto, + ProcessAudioDto, + ConfirmProductDto, + OnboardingProgressDto, + OnboardingStepDto, +} from './dto/onboarding.dto'; + +@Injectable() +export class OnboardingService { + private readonly logger = new Logger(OnboardingService.name); + + constructor( + @InjectRepository(OnboardingSession) + private readonly sessionRepository: Repository, + @InjectRepository(ProductScan) + private readonly scanRepository: Repository, + private readonly ocrService: OcrService, + private readonly whisperService: WhisperService, + private readonly templatesService: TemplatesService, + private readonly productsService: ProductsService, + ) {} + + // === SESSION MANAGEMENT === + + async startSession(dto: StartOnboardingDto): Promise { + // Check for existing active session + const existing = await this.sessionRepository.findOne({ + where: { + phoneNumber: dto.phoneNumber, + status: OnboardingStatus.STARTED, + }, + }); + + if (existing) { + return existing; + } + + const session = this.sessionRepository.create({ + phoneNumber: dto.phoneNumber, + channel: dto.channel || OnboardingChannel.WHATSAPP, + ownerName: dto.ownerName, + status: OnboardingStatus.STARTED, + currentStep: 1, + }); + + return this.sessionRepository.save(session); + } + + async getSession(sessionId: string): Promise { + const session = await this.sessionRepository.findOne({ + where: { id: sessionId }, + }); + + if (!session) { + throw new NotFoundException('Sesion de onboarding no encontrada'); + } + + return session; + } + + async getSessionByPhone(phoneNumber: string): Promise { + return this.sessionRepository.findOne({ + where: { phoneNumber }, + order: { startedAt: 'DESC' }, + }); + } + + async updateBusinessInfo(sessionId: string, dto: UpdateBusinessInfoDto): Promise { + const session = await this.getSession(sessionId); + + if (dto.businessName) { + session.businessName = dto.businessName; + } + if (dto.giro) { + session.businessGiro = dto.giro; + session.status = OnboardingStatus.GIRO_SELECTED; + session.currentStep = 2; + } + if (dto.ownerName) { + session.ownerName = dto.ownerName; + } + + session.lastMessageAt = new Date(); + + return this.sessionRepository.save(session); + } + + async getProgress(sessionId: string): Promise { + const session = await this.getSession(sessionId); + + const steps = this.getStepsForSession(session); + + // Get pending scans + const pendingScans = await this.scanRepository.find({ + where: { + sessionId, + status: ScanStatus.DETECTED, + }, + order: { createdAt: 'DESC' }, + }); + + // Get suggested templates based on giro + let suggestedTemplates; + if (session.businessGiro) { + const providers = await this.templatesService.getProviders(); + suggestedTemplates = providers.slice(0, 4).map(p => ({ + provider: p.provider, + name: p.name, + productCount: p.productCount, + })); + } + + return { + session: { + id: session.id, + phoneNumber: session.phoneNumber, + channel: session.channel, + status: session.status, + currentStep: session.currentStep, + totalSteps: session.totalSteps, + businessName: session.businessName, + businessGiro: session.businessGiro, + ownerName: session.ownerName, + templatesImported: session.templatesImported, + productsAdded: session.productsAdded, + photosProcessed: session.photosProcessed, + audiosProcessed: session.audiosProcessed, + startedAt: session.startedAt, + completedAt: session.completedAt, + }, + steps, + suggestedTemplates, + pendingScans: pendingScans.map(s => ({ + id: s.id, + type: s.type, + status: s.status, + detectedName: s.detectedName, + detectedPrice: s.detectedPrice ? Number(s.detectedPrice) : undefined, + detectedBarcode: s.detectedBarcode, + confidenceScore: s.confidenceScore ? Number(s.confidenceScore) : undefined, + createdAt: s.createdAt, + })), + }; + } + + private getStepsForSession(session: OnboardingSession): OnboardingStepDto[] { + return [ + { + step: 1, + name: 'Bienvenida', + description: 'Registro del numero y nombre del negocio', + completed: session.currentStep > 1 || session.businessName !== null, + data: { businessName: session.businessName, ownerName: session.ownerName }, + }, + { + step: 2, + name: 'Giro del negocio', + description: 'Seleccionar tipo de negocio', + completed: session.businessGiro !== null, + data: { giro: session.businessGiro }, + }, + { + step: 3, + name: 'Templates', + description: 'Cargar productos de proveedores comunes', + completed: session.templatesImported > 0, + data: { templatesImported: session.templatesImported }, + }, + { + step: 4, + name: 'Productos adicionales', + description: 'Agregar productos via foto o audio', + completed: session.productsAdded > 0, + data: { + productsAdded: session.productsAdded, + photosProcessed: session.photosProcessed, + audiosProcessed: session.audiosProcessed, + }, + }, + { + step: 5, + name: 'Listo!', + description: 'Negocio configurado', + completed: session.status === OnboardingStatus.COMPLETED, + }, + ]; + } + + // === IMAGE PROCESSING (OCR) === + + async processImage(dto: ProcessImageDto): Promise { + const session = await this.getSession(dto.sessionId); + + // Create scan record + const scan = this.scanRepository.create({ + sessionId: dto.sessionId, + tenantId: session.tenantId, + type: ScanType.PHOTO, + status: ScanStatus.PROCESSING, + mediaUrl: dto.imageUrl, + mediaMimeType: dto.mimeType, + }); + await this.scanRepository.save(scan); + + const startTime = Date.now(); + + try { + // Process with OCR + const result = await this.ocrService.processImage(dto.imageUrl); + + if (result.success && result.detectedProducts.length > 0) { + const firstProduct = result.detectedProducts[0]; + + scan.status = ScanStatus.DETECTED; + scan.rawText = result.text; + scan.detectedName = firstProduct.name; + scan.detectedPrice = firstProduct.price; + scan.detectedBarcode = firstProduct.barcode; + scan.confidenceScore = firstProduct.confidence; + + // Try to match with template + if (firstProduct.barcode || firstProduct.name) { + const templates = await this.templatesService.search({ + query: firstProduct.barcode || firstProduct.name, + limit: 1, + }); + + if (templates.length > 0) { + scan.templateId = templates[0].id; + scan.templateMatchScore = 0.8; + } + } + } else { + scan.status = ScanStatus.FAILED; + scan.errorMessage = result.error || 'No se detectaron productos'; + } + + scan.processingTimeMs = Date.now() - startTime; + await this.scanRepository.save(scan); + + // Update session + session.photosProcessed += 1; + session.lastMessageAt = new Date(); + await this.sessionRepository.save(session); + + return scan; + } catch (error) { + scan.status = ScanStatus.FAILED; + scan.errorMessage = error.message; + scan.processingTimeMs = Date.now() - startTime; + await this.scanRepository.save(scan); + + throw error; + } + } + + // === AUDIO PROCESSING (WHISPER) === + + async processAudio(dto: ProcessAudioDto): Promise { + const session = await this.getSession(dto.sessionId); + + const startTime = Date.now(); + + try { + // Process with Whisper + const result = await this.whisperService.transcribeAudio(dto.audioUrl); + + const scans: ProductScan[] = []; + + if (result.success && result.detectedPrices.length > 0) { + for (const detected of result.detectedPrices) { + const scan = this.scanRepository.create({ + sessionId: dto.sessionId, + tenantId: session.tenantId, + type: ScanType.AUDIO, + status: ScanStatus.DETECTED, + mediaUrl: dto.audioUrl, + mediaMimeType: dto.mimeType, + rawText: result.text, + detectedName: detected.productName, + detectedPrice: detected.price, + confidenceScore: detected.confidence, + processingTimeMs: Date.now() - startTime, + }); + + // Try to match with template + const templates = await this.templatesService.search({ + query: detected.productName, + limit: 1, + }); + + if (templates.length > 0) { + scan.templateId = templates[0].id; + scan.templateMatchScore = 0.7; + } + + await this.scanRepository.save(scan); + scans.push(scan); + } + } else { + // Create failed scan record + const scan = this.scanRepository.create({ + sessionId: dto.sessionId, + tenantId: session.tenantId, + type: ScanType.AUDIO, + status: ScanStatus.FAILED, + mediaUrl: dto.audioUrl, + rawText: result.text, + errorMessage: result.error || 'No se detectaron precios', + processingTimeMs: Date.now() - startTime, + }); + await this.scanRepository.save(scan); + scans.push(scan); + } + + // Update session + session.audiosProcessed += 1; + session.lastMessageAt = new Date(); + await this.sessionRepository.save(session); + + return scans; + } catch (error) { + this.logger.error(`Audio processing failed: ${error.message}`); + throw error; + } + } + + // === PRODUCT CONFIRMATION === + + async confirmProduct(tenantId: string, dto: ConfirmProductDto): Promise { + const scan = await this.scanRepository.findOne({ + where: { id: dto.scanId }, + }); + + if (!scan) { + throw new NotFoundException('Scan no encontrado'); + } + + // Create product in catalog + const product = await this.productsService.create(tenantId, { + name: dto.name, + price: dto.price, + barcode: dto.barcode, + sku: `ONB-${Date.now()}`, + }); + + // Update scan + scan.status = ScanStatus.CONFIRMED; + scan.userConfirmedName = dto.name; + scan.userConfirmedPrice = dto.price; + scan.productId = product.id; + await this.scanRepository.save(scan); + + // Update session + const session = await this.getSession(scan.sessionId); + session.productsAdded += 1; + + // Check if should advance status + if (session.status === OnboardingStatus.TEMPLATES_LOADED && session.productsAdded >= 1) { + session.status = OnboardingStatus.PRODUCTS_ADDED; + session.currentStep = 4; + } + + await this.sessionRepository.save(session); + + return product; + } + + async rejectProduct(scanId: string, reason?: string): Promise { + const scan = await this.scanRepository.findOne({ + where: { id: scanId }, + }); + + if (!scan) { + throw new NotFoundException('Scan no encontrado'); + } + + scan.status = ScanStatus.REJECTED; + scan.metadata = { ...scan.metadata, rejectionReason: reason }; + + return this.scanRepository.save(scan); + } + + // === TEMPLATE IMPORT === + + async importTemplates(tenantId: string, sessionId: string, giro?: TemplateGiro): Promise<{ imported: number }> { + const session = await this.getSession(sessionId); + + const result = await this.templatesService.importTemplates(tenantId, { + giro: giro || (session.businessGiro as TemplateGiro), + skipDuplicates: true, + }); + + // Update session + session.templatesImported += result.imported; + session.status = OnboardingStatus.TEMPLATES_LOADED; + session.currentStep = 3; + await this.sessionRepository.save(session); + + return { imported: result.imported }; + } + + // === COMPLETE ONBOARDING === + + async completeOnboarding(sessionId: string, tenantId: string): Promise { + const session = await this.getSession(sessionId); + + session.tenantId = tenantId; + session.status = OnboardingStatus.COMPLETED; + session.currentStep = 5; + session.completedAt = new Date(); + + return this.sessionRepository.save(session); + } + + async abandonSession(sessionId: string): Promise { + const session = await this.getSession(sessionId); + session.status = OnboardingStatus.ABANDONED; + return this.sessionRepository.save(session); + } + + // === STATS === + + async getStats(): Promise<{ + totalSessions: number; + completedSessions: number; + abandonedSessions: number; + averageProductsPerSession: number; + }> { + const total = await this.sessionRepository.count(); + const completed = await this.sessionRepository.count({ + where: { status: OnboardingStatus.COMPLETED }, + }); + const abandoned = await this.sessionRepository.count({ + where: { status: OnboardingStatus.ABANDONED }, + }); + + const avgProducts = await this.sessionRepository + .createQueryBuilder('session') + .select('AVG(session.productsAdded)', 'avg') + .where('session.status = :status', { status: OnboardingStatus.COMPLETED }) + .getRawOne(); + + return { + totalSessions: total, + completedSessions: completed, + abandonedSessions: abandoned, + averageProductsPerSession: parseFloat(avgProducts?.avg) || 0, + }; + } +} diff --git a/src/modules/onboarding/services/ocr.service.ts b/src/modules/onboarding/services/ocr.service.ts new file mode 100644 index 0000000..776061f --- /dev/null +++ b/src/modules/onboarding/services/ocr.service.ts @@ -0,0 +1,170 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface OcrResult { + success: boolean; + text: string; + detectedProducts: Array<{ + name: string; + price?: number; + barcode?: string; + confidence: number; + }>; + rawResponse?: any; + error?: string; +} + +@Injectable() +export class OcrService { + private readonly logger = new Logger(OcrService.name); + private readonly apiKey: string; + private readonly provider: string; + + constructor(private readonly configService: ConfigService) { + this.apiKey = this.configService.get('GOOGLE_VISION_API_KEY', ''); + this.provider = this.configService.get('OCR_PROVIDER', 'google'); + } + + async processImage(imageUrl: string): Promise { + const startTime = Date.now(); + + try { + if (!this.apiKey) { + this.logger.warn('OCR API key not configured, using mock response'); + return this.getMockResponse(imageUrl); + } + + // For Google Vision API + if (this.provider === 'google') { + return await this.processWithGoogleVision(imageUrl); + } + + // Fallback to mock + return this.getMockResponse(imageUrl); + } catch (error) { + this.logger.error(`OCR processing failed: ${error.message}`); + return { + success: false, + text: '', + detectedProducts: [], + error: error.message, + }; + } + } + + private async processWithGoogleVision(imageUrl: string): Promise { + // Google Vision API integration + const endpoint = `https://vision.googleapis.com/v1/images:annotate?key=${this.apiKey}`; + + const requestBody = { + requests: [ + { + image: { source: { imageUri: imageUrl } }, + features: [ + { type: 'TEXT_DETECTION' }, + { type: 'PRODUCT_SEARCH' }, + ], + }, + ], + }; + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Google Vision API error: ${response.statusText}`); + } + + const data = await response.json(); + const textAnnotations = data.responses?.[0]?.textAnnotations || []; + const fullText = textAnnotations[0]?.description || ''; + + // Parse detected text for products and prices + const products = this.parseProductsFromText(fullText); + + return { + success: true, + text: fullText, + detectedProducts: products, + rawResponse: data, + }; + } catch (error) { + this.logger.error(`Google Vision API error: ${error.message}`); + // Fallback to mock on error + return this.getMockResponse(imageUrl); + } + } + + private parseProductsFromText(text: string): Array<{ name: string; price?: number; barcode?: string; confidence: number }> { + const products: Array<{ name: string; price?: number; barcode?: string; confidence: number }> = []; + + // Price pattern: $XX.XX, XX.XX, $XX + const pricePattern = /\$?\s*(\d{1,4}(?:\.\d{2})?)/g; + // Barcode pattern: 13 or 12 digits + const barcodePattern = /\b(\d{12,13})\b/g; + + const lines = text.split('\n').filter(line => line.trim()); + + for (const line of lines) { + const priceMatch = line.match(pricePattern); + const barcodeMatch = line.match(barcodePattern); + + if (priceMatch) { + // Extract product name (text before price) + const priceIndex = line.search(pricePattern); + const name = line.substring(0, priceIndex).trim(); + const priceStr = priceMatch[0].replace(/[$\s]/g, ''); + const price = parseFloat(priceStr); + + if (name.length > 2 && price > 0 && price < 10000) { + products.push({ + name: name.substring(0, 150), + price, + barcode: barcodeMatch?.[0], + confidence: 0.7, + }); + } + } + } + + return products; + } + + private getMockResponse(imageUrl: string): OcrResult { + // Mock response for testing without API key + return { + success: true, + text: 'Coca-Cola 600ml $18.00\nSabritas Original $15.00\n7501055300000', + detectedProducts: [ + { name: 'Coca-Cola 600ml', price: 18.00, barcode: '7501055300000', confidence: 0.85 }, + { name: 'Sabritas Original', price: 15.00, confidence: 0.75 }, + ], + }; + } + + async processBarcode(imageUrl: string): Promise<{ barcode: string; confidence: number } | null> { + try { + const result = await this.processImage(imageUrl); + + // Look for barcode in detected text + const barcodePattern = /\b(\d{12,13})\b/; + const match = result.text.match(barcodePattern); + + if (match) { + return { + barcode: match[1], + confidence: 0.9, + }; + } + + return null; + } catch (error) { + this.logger.error(`Barcode detection failed: ${error.message}`); + return null; + } + } +} diff --git a/src/modules/onboarding/services/whisper.service.ts b/src/modules/onboarding/services/whisper.service.ts new file mode 100644 index 0000000..e8db652 --- /dev/null +++ b/src/modules/onboarding/services/whisper.service.ts @@ -0,0 +1,202 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface TranscriptionResult { + success: boolean; + text: string; + language?: string; + duration?: number; + detectedPrices: Array<{ + productName: string; + price: number; + confidence: number; + }>; + rawResponse?: any; + error?: string; +} + +@Injectable() +export class WhisperService { + private readonly logger = new Logger(WhisperService.name); + private readonly apiKey: string; + private readonly provider: string; + + constructor(private readonly configService: ConfigService) { + this.apiKey = this.configService.get('OPENAI_API_KEY', ''); + this.provider = this.configService.get('WHISPER_PROVIDER', 'openai'); + } + + async transcribeAudio(audioUrl: string): Promise { + try { + if (!this.apiKey) { + this.logger.warn('Whisper API key not configured, using mock response'); + return this.getMockResponse(); + } + + if (this.provider === 'openai') { + return await this.transcribeWithOpenAI(audioUrl); + } + + return this.getMockResponse(); + } catch (error) { + this.logger.error(`Transcription failed: ${error.message}`); + return { + success: false, + text: '', + detectedPrices: [], + error: error.message, + }; + } + } + + private async transcribeWithOpenAI(audioUrl: string): Promise { + try { + // Download audio file + const audioResponse = await fetch(audioUrl); + if (!audioResponse.ok) { + throw new Error('Failed to download audio'); + } + + const audioBuffer = await audioResponse.arrayBuffer(); + const audioBlob = new Blob([audioBuffer], { type: 'audio/ogg' }); + + // Create form data for Whisper API + const formData = new FormData(); + formData.append('file', audioBlob, 'audio.ogg'); + formData.append('model', 'whisper-1'); + formData.append('language', 'es'); + formData.append('response_format', 'verbose_json'); + + const response = await fetch('https://api.openai.com/v1/audio/transcriptions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: formData, + }); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.statusText}`); + } + + const data = await response.json(); + const text = data.text || ''; + + // Parse prices from transcription + const prices = this.parsePricesFromText(text); + + return { + success: true, + text, + language: data.language || 'es', + duration: data.duration, + detectedPrices: prices, + rawResponse: data, + }; + } catch (error) { + this.logger.error(`OpenAI Whisper error: ${error.message}`); + return this.getMockResponse(); + } + } + + private parsePricesFromText(text: string): Array<{ productName: string; price: number; confidence: number }> { + const prices: Array<{ productName: string; price: number; confidence: number }> = []; + + // Common patterns in Spanish for price statements + // "sabritas a 15", "coca a 18 pesos", "el refresco cuesta 20" + const patterns = [ + // "producto a X pesos" + /(\w+(?:\s+\w+)?)\s+a\s+(\d+(?:\.\d+)?)\s*(?:pesos?)?/gi, + // "producto X pesos" + /(\w+(?:\s+\w+)?)\s+(\d+(?:\.\d+)?)\s*pesos?/gi, + // "producto cuesta X" + /(\w+(?:\s+\w+)?)\s+(?:cuesta|vale)\s+(\d+(?:\.\d+)?)/gi, + // "X pesos el producto" + /(\d+(?:\.\d+)?)\s*pesos?\s+(?:el|la|los|las)?\s*(\w+(?:\s+\w+)?)/gi, + ]; + + for (const pattern of patterns) { + let match; + while ((match = pattern.exec(text)) !== null) { + let productName: string; + let priceStr: string; + + // Handle different capture group orders + if (isNaN(parseFloat(match[1]))) { + productName = match[1]; + priceStr = match[2]; + } else { + priceStr = match[1]; + productName = match[2]; + } + + const price = parseFloat(priceStr); + + if (productName && price > 0 && price < 10000) { + // Avoid duplicates + const exists = prices.some(p => + p.productName.toLowerCase() === productName.toLowerCase() && + p.price === price + ); + + if (!exists) { + prices.push({ + productName: productName.trim(), + price, + confidence: 0.7, + }); + } + } + } + } + + return prices; + } + + private getMockResponse(): TranscriptionResult { + return { + success: true, + text: 'Las Sabritas a quince pesos, la Coca a dieciocho, y el pan Bimbo a treinta y cinco', + language: 'es', + duration: 5.2, + detectedPrices: [ + { productName: 'Sabritas', price: 15, confidence: 0.8 }, + { productName: 'Coca', price: 18, confidence: 0.8 }, + { productName: 'pan Bimbo', price: 35, confidence: 0.75 }, + ], + }; + } + + // Parse numbers in Spanish text + private parseSpanishNumber(text: string): number | null { + const numberWords: Record = { + 'cero': 0, 'uno': 1, 'una': 1, 'dos': 2, 'tres': 3, 'cuatro': 4, + 'cinco': 5, 'seis': 6, 'siete': 7, 'ocho': 8, 'nueve': 9, + 'diez': 10, 'once': 11, 'doce': 12, 'trece': 13, 'catorce': 14, + 'quince': 15, 'dieciseis': 16, 'diecisiete': 17, 'dieciocho': 18, + 'diecinueve': 19, 'veinte': 20, 'veintiuno': 21, 'veintidos': 22, + 'veintitres': 23, 'veinticuatro': 24, 'veinticinco': 25, + 'treinta': 30, 'cuarenta': 40, 'cincuenta': 50, 'sesenta': 60, + 'setenta': 70, 'ochenta': 80, 'noventa': 90, 'cien': 100, + }; + + const normalized = text.toLowerCase().trim(); + + // Direct number word + if (numberWords[normalized] !== undefined) { + return numberWords[normalized]; + } + + // Compound numbers like "treinta y cinco" + const compoundMatch = normalized.match(/(\w+)\s+y\s+(\w+)/); + if (compoundMatch) { + const tens = numberWords[compoundMatch[1]]; + const units = numberWords[compoundMatch[2]]; + if (tens !== undefined && units !== undefined) { + return tens + units; + } + } + + return null; + } +}