From 877f844be6eb1710a8f811cd8b3754298bd49e5b Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:01:17 -0600 Subject: [PATCH] feat: add email ingestion database schema and types (refs #154) - Create email_ingestion_queue table with UNIQUE email_id constraint - Create pending_vehicle_associations table with documents FK - Seed 3 email templates: receipt_processed, receipt_failed, receipt_pending_vehicle - Add TypeScript types for queue records, associations, and Resend webhook payloads - Register email-ingestion in migration runner order Co-Authored-By: Claude Opus 4.6 --- backend/src/_system/migrations/run-all.ts | 1 + .../domain/email-ingestion.types.ts | 103 ++++++++ .../001_create_email_ingestion_tables.sql | 71 ++++++ .../migrations/002_create_email_templates.sql | 241 ++++++++++++++++++ 4 files changed, 416 insertions(+) create mode 100644 backend/src/features/email-ingestion/domain/email-ingestion.types.ts create mode 100644 backend/src/features/email-ingestion/migrations/001_create_email_ingestion_tables.sql create mode 100644 backend/src/features/email-ingestion/migrations/002_create_email_templates.sql diff --git a/backend/src/_system/migrations/run-all.ts b/backend/src/_system/migrations/run-all.ts index a5d6e49..2afd27c 100644 --- a/backend/src/_system/migrations/run-all.ts +++ b/backend/src/_system/migrations/run-all.ts @@ -26,6 +26,7 @@ const MIGRATION_ORDER = [ 'features/admin', // Admin role management and oversight; depends on update_updated_at_column() 'features/backup', // Admin backup feature; depends on update_updated_at_column() 'features/notifications', // Depends on maintenance and documents + 'features/email-ingestion', // Depends on documents, notifications (extends email_templates) 'features/terms-agreement', // Terms & Conditions acceptance audit trail 'features/audit-log', // Centralized audit logging; independent 'features/ownership-costs', // Depends on vehicles and documents; TCO recurring costs diff --git a/backend/src/features/email-ingestion/domain/email-ingestion.types.ts b/backend/src/features/email-ingestion/domain/email-ingestion.types.ts new file mode 100644 index 0000000..30792ad --- /dev/null +++ b/backend/src/features/email-ingestion/domain/email-ingestion.types.ts @@ -0,0 +1,103 @@ +/** + * @ai-summary TypeScript types for the email ingestion feature + * @ai-context Covers database records, status enums, and Resend webhook payloads + */ + +// ======================== +// Status Enums +// ======================== + +export type EmailIngestionStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +export type PendingAssociationStatus = 'pending' | 'resolved' | 'expired'; + +export type EmailRecordType = 'fuel_log' | 'maintenance_record'; + +// ======================== +// Database Records +// ======================== + +export interface EmailIngestionQueueRecord { + id: string; + emailId: string; + senderEmail: string; + userId: string; + receivedAt: string; + subject: string | null; + status: EmailIngestionStatus; + processingResult: EmailProcessingResult | null; + errorMessage: string | null; + retryCount: number; + createdAt: string; + updatedAt: string; +} + +export interface PendingVehicleAssociation { + id: string; + userId: string; + recordType: EmailRecordType; + extractedData: ExtractedReceiptData; + documentId: string | null; + status: PendingAssociationStatus; + createdAt: string; + resolvedAt: string | null; +} + +// ======================== +// Processing Results +// ======================== + +export interface EmailProcessingResult { + recordType: EmailRecordType; + vehicleId: string | null; + recordId: string | null; + documentId: string | null; + pendingAssociationId: string | null; + extractedData: ExtractedReceiptData; +} + +export interface ExtractedReceiptData { + vendor: string | null; + date: string | null; + total: number | null; + odometerReading: number | null; + /** Fuel-specific fields */ + gallons: number | null; + pricePerGallon: number | null; + fuelType: string | null; + /** Maintenance-specific fields */ + category: string | null; + subtypes: string[] | null; + shopName: string | null; + description: string | null; +} + +// ======================== +// Resend Webhook Payloads +// ======================== + +/** Top-level Resend webhook event envelope */ +export interface ResendWebhookEvent { + type: string; + created_at: string; + data: ResendWebhookEventData; +} + +/** Resend email.received webhook event data */ +export interface ResendWebhookEventData { + email_id: string; + from: string; + to: string[]; + subject: string; + text: string | null; + html: string | null; + created_at: string; + attachments: ResendEmailAttachment[]; +} + +/** Attachment metadata from Resend inbound email */ +export interface ResendEmailAttachment { + filename: string; + content_type: string; + content: string; +} diff --git a/backend/src/features/email-ingestion/migrations/001_create_email_ingestion_tables.sql b/backend/src/features/email-ingestion/migrations/001_create_email_ingestion_tables.sql new file mode 100644 index 0000000..f3c8788 --- /dev/null +++ b/backend/src/features/email-ingestion/migrations/001_create_email_ingestion_tables.sql @@ -0,0 +1,71 @@ +/** + * Migration: Create email ingestion tables + * @ai-summary Creates email_ingestion_queue and pending_vehicle_associations tables + * @ai-context Supports inbound email receipt processing via Resend webhooks + */ + +-- email_ingestion_queue: Tracks inbound emails from Resend webhooks +CREATE TABLE IF NOT EXISTS email_ingestion_queue ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email_id VARCHAR(255) NOT NULL, + sender_email VARCHAR(255) NOT NULL, + user_id VARCHAR(255) NOT NULL, + received_at TIMESTAMP WITH TIME ZONE NOT NULL, + subject VARCHAR(500), + status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ( + 'pending', 'processing', 'completed', 'failed' + )), + processing_result JSONB, + error_message TEXT, + retry_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Unique constraint on email_id to prevent duplicate processing +ALTER TABLE email_ingestion_queue + ADD CONSTRAINT uq_email_ingestion_queue_email_id UNIQUE (email_id); + +-- Trigger for updated_at (reuses update_updated_at_column() from vehicles feature) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'set_timestamp_email_ingestion_queue' + ) THEN + CREATE TRIGGER set_timestamp_email_ingestion_queue + BEFORE UPDATE ON email_ingestion_queue + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + END IF; +END $$; + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_user_id ON email_ingestion_queue(user_id); +CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_status ON email_ingestion_queue(status); +CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_sender ON email_ingestion_queue(sender_email); +CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_received_at ON email_ingestion_queue(received_at DESC); + +-- pending_vehicle_associations: Holds records needing vehicle selection (multi-vehicle users) +CREATE TABLE IF NOT EXISTS pending_vehicle_associations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id VARCHAR(255) NOT NULL, + record_type VARCHAR(30) NOT NULL CHECK (record_type IN ( + 'fuel_log', 'maintenance_record' + )), + extracted_data JSONB NOT NULL, + document_id UUID REFERENCES documents(id) ON DELETE SET NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ( + 'pending', 'resolved', 'expired' + )), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + resolved_at TIMESTAMP WITH TIME ZONE +); + +-- Trigger for pending_vehicle_associations does not need updated_at (uses resolved_at instead) + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_pending_vehicle_assoc_user_id ON pending_vehicle_associations(user_id); +CREATE INDEX IF NOT EXISTS idx_pending_vehicle_assoc_status ON pending_vehicle_associations(status) + WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS idx_pending_vehicle_assoc_document_id ON pending_vehicle_associations(document_id) + WHERE document_id IS NOT NULL; diff --git a/backend/src/features/email-ingestion/migrations/002_create_email_templates.sql b/backend/src/features/email-ingestion/migrations/002_create_email_templates.sql new file mode 100644 index 0000000..3df64e0 --- /dev/null +++ b/backend/src/features/email-ingestion/migrations/002_create_email_templates.sql @@ -0,0 +1,241 @@ +/** + * Migration: Add email ingestion email templates + * @ai-summary Extends email_templates CHECK constraint and seeds 3 receipt templates + * @ai-context Templates for receipt processing confirmations, failures, and pending vehicle selection + */ + +-- Extend template_key CHECK constraint to include email ingestion templates +ALTER TABLE email_templates +DROP CONSTRAINT IF EXISTS email_templates_template_key_check; + +ALTER TABLE email_templates +ADD CONSTRAINT email_templates_template_key_check +CHECK (template_key IN ( + 'maintenance_due_soon', 'maintenance_overdue', + 'document_expiring', 'document_expired', + 'payment_failed_immediate', 'payment_failed_7day', 'payment_failed_1day', + 'subscription_tier_change', + 'receipt_processed', 'receipt_failed', 'receipt_pending_vehicle' +)); + +-- Insert email ingestion templates +INSERT INTO email_templates (template_key, name, description, subject, body, variables, html_body) VALUES + ( + 'receipt_processed', + 'Receipt Processed Successfully', + 'Sent when an emailed receipt is successfully processed and recorded', + 'MotoVaultPro: Receipt Processed for {{vehicleName}}', + 'Hi {{userName}}, + +Your emailed receipt has been successfully processed. + +Vehicle: {{vehicleName}} +Record Type: {{recordType}} +Date: {{receiptDate}} +Amount: ${{amount}} + +The record has been added to your vehicle history. + +Best regards, +MotoVaultPro Team', + '["userName", "vehicleName", "recordType", "receiptDate", "amount"]', + ' + + + + + Receipt Processed + + + + + + +
+ + + + + + + + + + +
+

Receipt Processed

+
+

Hi {{userName}},

+

Your emailed receipt has been successfully processed.

+ + + + +
+

Vehicle: {{vehicleName}}

+

Record Type: {{recordType}}

+

Date: {{receiptDate}}

+

Amount: ${{amount}}

+
+

The record has been added to your vehicle history.

+
+

Best regards,
MotoVaultPro Team

+
+
+ +' + ), + ( + 'receipt_failed', + 'Receipt Processing Failed', + 'Sent when an emailed receipt fails OCR processing or validation', + 'MotoVaultPro: Unable to Process Your Receipt', + 'Hi {{userName}}, + +We were unable to process the receipt you emailed to us. + +Subject: {{emailSubject}} +Error: {{errorReason}} + +Please try again with a clearer image or PDF, or upload the receipt directly through the app. + +Tips for better results: +- Use a well-lit, high-contrast photo +- Ensure text is legible and not cut off +- PDF receipts work best + +Best regards, +MotoVaultPro Team', + '["userName", "emailSubject", "errorReason"]', + ' + + + + + Receipt Processing Failed + + + + + + +
+ + + + + + + + + + +
+

Processing Failed

+
+

Hi {{userName}},

+

We were unable to process the receipt you emailed to us.

+ + + + +
+

Subject: {{emailSubject}}

+

Error: {{errorReason}}

+
+

Please try again with a clearer image or PDF, or upload the receipt directly through the app.

+
+

Tips for better results:

+
    +
  • Use a well-lit, high-contrast photo
  • +
  • Ensure text is legible and not cut off
  • +
  • PDF receipts work best
  • +
+
+
+

Best regards,
MotoVaultPro Team

+
+
+ +' + ), + ( + 'receipt_pending_vehicle', + 'Receipt Pending Vehicle Selection', + 'Sent when a multi-vehicle user needs to select which vehicle a receipt belongs to', + 'MotoVaultPro: Select Vehicle for Your Receipt', + 'Hi {{userName}}, + +Your emailed receipt has been processed, but we need your help to complete the record. + +Since you have multiple vehicles, please log in to MotoVaultPro and select which vehicle this receipt belongs to. + +Record Type: {{recordType}} +Date: {{receiptDate}} +Amount: ${{amount}} + +You can find the pending receipt in your notifications. + +Best regards, +MotoVaultPro Team', + '["userName", "recordType", "receiptDate", "amount"]', + ' + + + + + Select Vehicle for Receipt + + + + + + +
+ + + + + + + + + + +
+

Vehicle Selection Needed

+
+

Hi {{userName}},

+

Your emailed receipt has been processed, but we need your help to complete the record.

+
+

Action Required

+

Since you have multiple vehicles, please select which vehicle this receipt belongs to.

+
+ + + + +
+

Record Type: {{recordType}}

+

Date: {{receiptDate}}

+

Amount: ${{amount}}

+
+

You can find the pending receipt in your notifications.

+ +
+

Best regards,
MotoVaultPro Team

+
+
+ +' + ) +ON CONFLICT (template_key) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + subject = EXCLUDED.subject, + body = EXCLUDED.body, + variables = EXCLUDED.variables, + html_body = EXCLUDED.html_body, + updated_at = NOW();