feat: add vehicle association and record creation (refs #158)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<EmailProcessingResult> {
|
||||
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<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
|
||||
// ========================
|
||||
@@ -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(
|
||||
recipientEmail: string,
|
||||
userName: string,
|
||||
|
||||
Reference in New Issue
Block a user