From fce60759cfb6fbb59b0a0d794ff43b7e31b22178 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Fri, 13 Feb 2026 08:53:08 -0600 Subject: [PATCH] feat: add vehicle association and record creation (refs #158) Co-Authored-By: Claude Opus 4.6 --- .../domain/email-ingestion.service.ts | 242 ++++++++++++++++-- 1 file changed, 222 insertions(+), 20 deletions(-) diff --git a/backend/src/features/email-ingestion/domain/email-ingestion.service.ts b/backend/src/features/email-ingestion/domain/email-ingestion.service.ts index 17d70dd..349a028 100644 --- a/backend/src/features/email-ingestion/domain/email-ingestion.service.ts +++ b/backend/src/features/email-ingestion/domain/email-ingestion.service.ts @@ -17,6 +17,13 @@ import { EmailService } from '../../notifications/domain/email.service'; import { ocrService } from '../../ocr/domain/ocr.service'; import type { ReceiptExtractionResponse } from '../../ocr/domain/ocr.types'; import { ReceiptClassifier } from './receipt-classifier'; +import { FuelLogsService } from '../../fuel-logs/domain/fuel-logs.service'; +import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository'; +import { FuelType } from '../../fuel-logs/domain/fuel-logs.types'; +import type { EnhancedCreateFuelLogRequest } from '../../fuel-logs/domain/fuel-logs.types'; +import { MaintenanceService } from '../../maintenance/domain/maintenance.service'; +import type { MaintenanceCategory } from '../../maintenance/domain/maintenance.types'; +import { validateSubtypes, getSubtypesForCategory } from '../../maintenance/domain/maintenance.types'; import type { ResendWebhookEvent, EmailProcessingResult, @@ -53,6 +60,8 @@ export class EmailIngestionService { private templateService: TemplateService; private emailService: EmailService; private classifier: ReceiptClassifier; + private fuelLogsService: FuelLogsService; + private maintenanceService: MaintenanceService; constructor(dbPool?: Pool) { const p = dbPool || pool; @@ -64,6 +73,8 @@ export class EmailIngestionService { this.templateService = new TemplateService(); this.emailService = new EmailService(); this.classifier = new ReceiptClassifier(); + this.fuelLogsService = new FuelLogsService(new FuelLogsRepository(p)); + this.maintenanceService = new MaintenanceService(); } // ======================== @@ -437,7 +448,9 @@ export class EmailIngestionService { /** * Handle vehicle association based on user's vehicle count. - * Single vehicle: auto-associate. Multiple: create pending association. + * No vehicles: send error email. + * Single vehicle: auto-associate and create record. + * Multiple vehicles: create pending association for user selection. */ private async handleVehicleAssociation( userId: string, @@ -448,31 +461,62 @@ export class EmailIngestionService { ): Promise { const vehicles = await this.vehiclesRepository.findByUserId(userId); - if (vehicles.length === 1) { - // Auto-associate with the single vehicle - const vehicleId = vehicles[0].id; - - // Create in-app notification - await this.notificationsRepository.insertUserNotification({ - userId, - notificationType: 'email_ingestion', - title: 'Receipt Processed', - message: `Your emailed ${recordType === 'fuel_log' ? 'fuel' : 'maintenance'} receipt has been processed and associated with your vehicle.`, - referenceType: recordType, - vehicleId, - }); - + // No vehicles: user must add a vehicle first + if (vehicles.length === 0) { + await this.sendNoVehiclesEmail(userEmail, userName); return { recordType, - vehicleId, - recordId: null, // Record creation handled by later sub-issue + vehicleId: null, + recordId: null, documentId: null, pendingAssociationId: null, extractedData, }; } - // Multiple vehicles (or none): create pending association + // Single vehicle: auto-associate and create record + if (vehicles.length === 1) { + const vehicleId = vehicles[0].id; + let recordId: string | null = null; + + try { + recordId = await this.createRecord(userId, vehicleId, recordType, extractedData); + } catch (error) { + logger.error('Failed to create record from email receipt', { + userId, + vehicleId, + recordType, + error: error instanceof Error ? error.message : String(error), + }); + // Record creation failed but association succeeded; notify user of partial success + } + + const recordLabel = recordType === 'fuel_log' ? 'fuel' : 'maintenance'; + const message = recordId + ? `Your emailed ${recordLabel} receipt has been processed and a ${recordLabel} record was created for your vehicle.` + : `Your emailed ${recordLabel} receipt was processed but the record could not be created automatically. Please add it manually.`; + + await this.notificationsRepository.insertUserNotification({ + userId, + notificationType: 'email_ingestion', + title: recordId ? 'Receipt Processed' : 'Receipt Partially Processed', + message, + referenceType: recordType, + referenceId: recordId ?? undefined, + vehicleId, + }); + + return { + recordType, + vehicleId, + recordId, + documentId: null, + pendingAssociationId: null, + extractedData, + }; + } + + // Multiple vehicles: create pending association for user selection const pendingAssociation = await this.repository.insertPendingAssociation({ userId, recordType, @@ -480,7 +524,6 @@ export class EmailIngestionService { documentId: null, }); - // Create in-app notification for vehicle selection await this.notificationsRepository.insertUserNotification({ userId, notificationType: 'email_ingestion', @@ -490,7 +533,6 @@ export class EmailIngestionService { referenceId: pendingAssociation.id, }); - // Send pending vehicle email await this.sendPendingVehicleEmail(userEmail, userName, recordType, extractedData); return { @@ -503,6 +545,135 @@ export class EmailIngestionService { }; } + // ======================== + // Record Creation + // ======================== + + /** + * Create a fuel log or maintenance record from extracted receipt data. + * Returns the created record ID. + */ + private async createRecord( + userId: string, + vehicleId: string, + recordType: EmailRecordType, + extractedData: ExtractedReceiptData + ): Promise { + if (recordType === 'fuel_log') { + return this.createFuelLogRecord(userId, vehicleId, extractedData); + } + return this.createMaintenanceRecord(userId, vehicleId, extractedData); + } + + /** + * Map extracted receipt data to EnhancedCreateFuelLogRequest and create fuel log. + */ + private async createFuelLogRecord( + userId: string, + vehicleId: string, + data: ExtractedReceiptData + ): Promise { + const fuelUnits = data.gallons ?? 0; + const costPerUnit = data.pricePerGallon ?? (data.total && fuelUnits > 0 ? data.total / fuelUnits : 0); + + const request: EnhancedCreateFuelLogRequest = { + vehicleId, + dateTime: data.date || new Date().toISOString(), + fuelType: this.mapFuelType(data.fuelType), + fuelUnits, + costPerUnit, + odometerReading: data.odometerReading ?? undefined, + locationData: data.vendor ? { stationName: data.vendor } : undefined, + notes: 'Created from emailed receipt', + }; + + logger.info('Creating fuel log from email receipt', { userId, vehicleId, fuelUnits, costPerUnit }); + const result = await this.fuelLogsService.createFuelLog(request, userId); + return result.id; + } + + /** + * Map extracted receipt data to CreateMaintenanceRecordRequest and create maintenance record. + */ + private async createMaintenanceRecord( + userId: string, + vehicleId: string, + data: ExtractedReceiptData + ): Promise { + const category = this.mapMaintenanceCategory(data.category); + const subtypes = this.resolveMaintenanceSubtypes(category, data.subtypes); + + const record = await this.maintenanceService.createRecord(userId, { + vehicleId, + category, + subtypes, + date: data.date || new Date().toISOString().split('T')[0], + odometerReading: data.odometerReading ?? undefined, + cost: data.total ?? undefined, + shopName: data.shopName || data.vendor || undefined, + notes: data.description + ? `${data.description}\n\nCreated from emailed receipt` + : 'Created from emailed receipt', + }); + + logger.info('Created maintenance record from email receipt', { userId, vehicleId, recordId: record.id, category }); + return record.id; + } + + /** + * Map OCR fuel type string to FuelType enum. Defaults to gasoline. + */ + private mapFuelType(fuelTypeStr: string | null): FuelType { + if (!fuelTypeStr) return FuelType.GASOLINE; + + const normalized = fuelTypeStr.toLowerCase().trim(); + if (normalized.includes('diesel') || normalized === '#1' || normalized === '#2') { + return FuelType.DIESEL; + } + if (normalized.includes('electric') || normalized.includes('ev')) { + return FuelType.ELECTRIC; + } + return FuelType.GASOLINE; + } + + /** + * Map OCR category string to MaintenanceCategory. Defaults to routine_maintenance. + */ + private mapMaintenanceCategory(categoryStr: string | null): MaintenanceCategory { + if (!categoryStr) return 'routine_maintenance'; + + const normalized = categoryStr.toLowerCase().trim(); + if (normalized.includes('repair')) return 'repair'; + if (normalized.includes('performance') || normalized.includes('upgrade')) return 'performance_upgrade'; + return 'routine_maintenance'; + } + + /** + * Validate and resolve maintenance subtypes. Falls back to first valid + * subtype for the category if OCR subtypes are invalid or missing. + */ + private resolveMaintenanceSubtypes( + category: MaintenanceCategory, + ocrSubtypes: string[] | null + ): string[] { + if (ocrSubtypes && ocrSubtypes.length > 0 && validateSubtypes(category, ocrSubtypes)) { + return ocrSubtypes; + } + + // Attempt to match OCR subtypes against valid options (case-insensitive) + if (ocrSubtypes && ocrSubtypes.length > 0) { + const validOptions = getSubtypesForCategory(category); + const matched = ocrSubtypes + .map(s => validOptions.find(v => v.toLowerCase() === s.toLowerCase().trim())) + .filter((v): v is string => v !== undefined); + if (matched.length > 0) return matched; + } + + // Default to first subtype of category + const defaults = getSubtypesForCategory(category); + return [defaults[0] as string]; + } + // ======================== // Error Handling & Retries // ======================== @@ -690,6 +861,37 @@ export class EmailIngestionService { } } + private async sendNoVehiclesEmail( + recipientEmail: string, + userName: string + ): Promise { + try { + const template = await this.notificationsRepository.getEmailTemplateByKey('receipt_failed'); + if (!template || !template.isActive) { + logger.warn('receipt_failed template not found or inactive'); + return; + } + + const variables = { + userName, + emailSubject: 'Receipt Submission', + errorReason: 'You do not have any vehicles registered in MotoVaultPro. Please add a vehicle first, then re-send your receipt.', + }; + + const renderedSubject = this.templateService.render(template.subject, variables); + const renderedHtml = this.templateService.renderEmailHtml(template.body, variables); + + await this.emailService.send(recipientEmail, renderedSubject, renderedHtml); + + logger.info('No-vehicles error email sent', { recipientEmail }); + } catch (error) { + logger.error('Failed to send no-vehicles error email', { + recipientEmail, + error: error instanceof Error ? error.message : String(error), + }); + } + } + private async sendPendingVehicleEmail( recipientEmail: string, userName: string,