feat: add vehicle association and record creation (refs #158)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-13 08:53:08 -06:00
parent d9a40f7d37
commit fce60759cf

View File

@@ -17,6 +17,13 @@ import { EmailService } from '../../notifications/domain/email.service';
import { ocrService } from '../../ocr/domain/ocr.service'; import { ocrService } from '../../ocr/domain/ocr.service';
import type { ReceiptExtractionResponse } from '../../ocr/domain/ocr.types'; import type { ReceiptExtractionResponse } from '../../ocr/domain/ocr.types';
import { ReceiptClassifier } from './receipt-classifier'; 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 { import type {
ResendWebhookEvent, ResendWebhookEvent,
EmailProcessingResult, EmailProcessingResult,
@@ -53,6 +60,8 @@ export class EmailIngestionService {
private templateService: TemplateService; private templateService: TemplateService;
private emailService: EmailService; private emailService: EmailService;
private classifier: ReceiptClassifier; private classifier: ReceiptClassifier;
private fuelLogsService: FuelLogsService;
private maintenanceService: MaintenanceService;
constructor(dbPool?: Pool) { constructor(dbPool?: Pool) {
const p = dbPool || pool; const p = dbPool || pool;
@@ -64,6 +73,8 @@ export class EmailIngestionService {
this.templateService = new TemplateService(); this.templateService = new TemplateService();
this.emailService = new EmailService(); this.emailService = new EmailService();
this.classifier = new ReceiptClassifier(); 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. * 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( private async handleVehicleAssociation(
userId: string, userId: string,
@@ -448,31 +461,62 @@ export class EmailIngestionService {
): Promise<EmailProcessingResult> { ): Promise<EmailProcessingResult> {
const vehicles = await this.vehiclesRepository.findByUserId(userId); const vehicles = await this.vehiclesRepository.findByUserId(userId);
if (vehicles.length === 1) { // No vehicles: user must add a vehicle first
// Auto-associate with the single vehicle if (vehicles.length === 0) {
const vehicleId = vehicles[0].id; await this.sendNoVehiclesEmail(userEmail, userName);
// 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,
});
return { return {
recordType, recordType,
vehicleId, vehicleId: null,
recordId: null, // Record creation handled by later sub-issue recordId: null,
documentId: null, documentId: null,
pendingAssociationId: null, pendingAssociationId: null,
extractedData, 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({ const pendingAssociation = await this.repository.insertPendingAssociation({
userId, userId,
recordType, recordType,
@@ -480,7 +524,6 @@ export class EmailIngestionService {
documentId: null, documentId: null,
}); });
// Create in-app notification for vehicle selection
await this.notificationsRepository.insertUserNotification({ await this.notificationsRepository.insertUserNotification({
userId, userId,
notificationType: 'email_ingestion', notificationType: 'email_ingestion',
@@ -490,7 +533,6 @@ export class EmailIngestionService {
referenceId: pendingAssociation.id, referenceId: pendingAssociation.id,
}); });
// Send pending vehicle email
await this.sendPendingVehicleEmail(userEmail, userName, recordType, extractedData); await this.sendPendingVehicleEmail(userEmail, userName, recordType, extractedData);
return { 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<string> {
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<string> {
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<string> {
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 // Error Handling & Retries
// ======================== // ========================
@@ -690,6 +861,37 @@ export class EmailIngestionService {
} }
} }
private async sendNoVehiclesEmail(
recipientEmail: string,
userName: string
): Promise<void> {
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( private async sendPendingVehicleEmail(
recipientEmail: string, recipientEmail: string,
userName: string, userName: string,