diff --git a/backend/package-lock.json b/backend/package-lock.json index fb08404..93810ff 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -24,12 +24,14 @@ "get-jwks": "^11.0.3", "ioredis": "^5.4.2", "js-yaml": "^4.1.0", + "mailparser": "^3.9.3", "node-cron": "^3.0.3", "opossum": "^8.0.0", "pg": "^8.13.1", "pino": "^9.6.0", "resend": "^3.0.0", "stripe": "^20.2.0", + "svix": "^1.85.0", "tar": "^7.4.3", "zod": "^3.24.1" }, @@ -37,6 +39,7 @@ "@eslint/js": "^9.17.0", "@types/jest": "^29.5.10", "@types/js-yaml": "^4.0.9", + "@types/mailparser": "^3.4.6", "@types/node": "^22.0.0", "@types/node-cron": "^3.0.11", "@types/opossum": "^8.0.0", @@ -83,7 +86,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1766,6 +1768,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", @@ -1921,6 +1929,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mailparser": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz", + "integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "iconv-lite": "^0.6.3" + } + }, + "node_modules/@types/mailparser/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -1934,7 +1966,6 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2061,7 +2092,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -2273,6 +2303,17 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", + "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" + } + }, "node_modules/abbrev": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", @@ -2306,7 +2347,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2773,7 +2813,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3470,6 +3509,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -3566,7 +3614,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3900,6 +3947,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -4508,6 +4561,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/helmet": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", @@ -4580,6 +4642,22 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4920,7 +4998,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -5736,6 +5813,42 @@ "node": ">= 0.8.0" } }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", + "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -5780,6 +5893,15 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5827,6 +5949,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -5843,6 +5966,24 @@ "node": "20 || >=22" } }, + "node_modules/mailparser": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz", + "integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "9.0.5", + "iconv-lite": "0.7.2", + "libmime": "5.3.7", + "linkify-it": "5.0.0", + "nodemailer": "7.0.13", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -6071,6 +6212,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.11", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", @@ -6419,7 +6569,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -6785,6 +6934,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -7126,6 +7284,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -7381,6 +7540,16 @@ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/steed": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", @@ -7602,6 +7771,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.85.0.tgz", + "integrity": "sha512-4OxNw++bnNay8SoBwESgzfjMnYmurS1qBX+luhzvljr6EAPn/hqqmkdCR1pbgIe1K1+BzKZEHjAKz9OYrKJYwQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, + "node_modules/svix/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/tar": { "version": "7.5.2", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", @@ -7692,7 +7884,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7700,6 +7891,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -7841,7 +8041,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7929,7 +8128,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7962,6 +8160,12 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", diff --git a/backend/package.json b/backend/package.json index 5a9b8ba..cd5c053 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,19 +34,22 @@ "get-jwks": "^11.0.3", "ioredis": "^5.4.2", "js-yaml": "^4.1.0", + "mailparser": "^3.9.3", "node-cron": "^3.0.3", "opossum": "^8.0.0", "pg": "^8.13.1", + "pino": "^9.6.0", "resend": "^3.0.0", "stripe": "^20.2.0", + "svix": "^1.85.0", "tar": "^7.4.3", - "pino": "^9.6.0", "zod": "^3.24.1" }, "devDependencies": { "@eslint/js": "^9.17.0", "@types/jest": "^29.5.10", "@types/js-yaml": "^4.0.9", + "@types/mailparser": "^3.4.6", "@types/node": "^22.0.0", "@types/node-cron": "^3.0.11", "@types/opossum": "^8.0.0", 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/app.ts b/backend/src/app.ts index b944cb5..7e1cf84 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -35,6 +35,7 @@ import { userImportRoutes } from './features/user-import'; import { ownershipCostsRoutes } from './features/ownership-costs'; import { subscriptionsRoutes, donationsRoutes, webhooksRoutes } from './features/subscriptions'; import { ocrRoutes } from './features/ocr'; +import { emailIngestionWebhookRoutes, emailIngestionRoutes } from './features/email-ingestion'; import { pool } from './core/config/database'; import { configRoutes } from './core/config/config.routes'; @@ -96,7 +97,7 @@ async function buildApp(): Promise { status: 'healthy', timestamp: new Date().toISOString(), environment: process.env['NODE_ENV'], - features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr'] + features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr', 'email-ingestion'] }); }); @@ -106,7 +107,7 @@ async function buildApp(): Promise { status: 'healthy', scope: 'api', timestamp: new Date().toISOString(), - features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr'] + features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr', 'email-ingestion'] }); }); @@ -152,6 +153,8 @@ async function buildApp(): Promise { await app.register(subscriptionsRoutes, { prefix: '/api' }); await app.register(donationsRoutes, { prefix: '/api' }); await app.register(webhooksRoutes, { prefix: '/api' }); + await app.register(emailIngestionWebhookRoutes, { prefix: '/api' }); + await app.register(emailIngestionRoutes, { prefix: '/api' }); await app.register(ocrRoutes, { prefix: '/api' }); await app.register(configRoutes, { prefix: '/api' }); diff --git a/backend/src/core/config/config-loader.ts b/backend/src/core/config/config-loader.ts index f9aa904..30f7ca4 100644 --- a/backend/src/core/config/config-loader.ts +++ b/backend/src/core/config/config-loader.ts @@ -126,6 +126,7 @@ const secretsSchema = z.object({ auth0_management_client_secret: z.string(), google_maps_api_key: z.string(), resend_api_key: z.string(), + resend_webhook_secret: z.string().optional(), // Stripe secrets (API keys only - price IDs are config, not secrets) stripe_secret_key: z.string(), stripe_webhook_secret: z.string(), @@ -143,6 +144,10 @@ export interface AppConfiguration { getRedisUrl(): string; getAuth0Config(): { domain: string; audience: string; clientSecret: string }; getAuth0ManagementConfig(): { domain: string; clientId: string; clientSecret: string }; + getResendConfig(): { + apiKey: string; + webhookSecret: string | undefined; + }; getStripeConfig(): { secretKey: string; webhookSecret: string; @@ -185,6 +190,7 @@ class ConfigurationLoader { 'auth0-management-client-secret', 'google-maps-api-key', 'resend-api-key', + 'resend-webhook-secret', 'stripe-secret-key', 'stripe-webhook-secret', ]; @@ -250,6 +256,13 @@ class ConfigurationLoader { }; }, + getResendConfig() { + return { + apiKey: secrets.resend_api_key, + webhookSecret: secrets.resend_webhook_secret, + }; + }, + getStripeConfig() { return { secretKey: secrets.stripe_secret_key, @@ -258,8 +271,11 @@ class ConfigurationLoader { }, }; - // Set RESEND_API_KEY in environment for EmailService + // Set Resend environment variables for EmailService and webhook verification process.env['RESEND_API_KEY'] = secrets.resend_api_key; + if (secrets.resend_webhook_secret) { + process.env['RESEND_WEBHOOK_SECRET'] = secrets.resend_webhook_secret; + } logger.info('Configuration loaded successfully', { configSource: 'yaml', diff --git a/backend/src/core/config/feature-tiers.ts b/backend/src/core/config/feature-tiers.ts index f5d03a7..ca803df 100644 --- a/backend/src/core/config/feature-tiers.ts +++ b/backend/src/core/config/feature-tiers.ts @@ -36,6 +36,11 @@ export const FEATURE_TIERS: Record = { name: 'Receipt Scan', upgradePrompt: 'Upgrade to Pro to scan fuel receipts and auto-fill your fuel log entries.', }, + 'maintenance.receiptScan': { + minTier: 'pro', + name: 'Maintenance Receipt Scan', + upgradePrompt: 'Upgrade to Pro to scan maintenance receipts and extract service details automatically.', + }, } as const; /** diff --git a/backend/src/features/email-ingestion/api/email-ingestion.controller.ts b/backend/src/features/email-ingestion/api/email-ingestion.controller.ts new file mode 100644 index 0000000..5169b37 --- /dev/null +++ b/backend/src/features/email-ingestion/api/email-ingestion.controller.ts @@ -0,0 +1,188 @@ +/** + * @ai-summary Controller for Resend inbound email webhook and user-facing pending association endpoints + * @ai-context Webhook handler (public) + pending association CRUD (JWT-authenticated) + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { ResendInboundClient } from '../external/resend-inbound.client'; +import { EmailIngestionRepository } from '../data/email-ingestion.repository'; +import { EmailIngestionService } from '../domain/email-ingestion.service'; +import { logger } from '../../../core/logging/logger'; +import type { ResendWebhookEvent } from '../domain/email-ingestion.types'; + +export class EmailIngestionController { + private resendClient: ResendInboundClient; + private repository: EmailIngestionRepository; + private service: EmailIngestionService; + + constructor() { + this.resendClient = new ResendInboundClient(); + this.repository = new EmailIngestionRepository(); + this.service = new EmailIngestionService(); + } + + // ======================== + // Pending Association Endpoints (JWT-authenticated) + // ======================== + + async getPendingAssociations(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const userId = (request as any).user.sub; + const associations = await this.repository.getPendingAssociations(userId); + return reply.code(200).send(associations); + } catch (error: any) { + logger.error('Error listing pending associations', { error: error.message, userId: (request as any).user?.sub }); + return reply.code(500).send({ error: 'Failed to list pending associations' }); + } + } + + async getPendingAssociationCount(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const userId = (request as any).user.sub; + const count = await this.repository.getPendingAssociationCount(userId); + return reply.code(200).send({ count }); + } catch (error: any) { + logger.error('Error counting pending associations', { error: error.message, userId: (request as any).user?.sub }); + return reply.code(500).send({ error: 'Failed to count pending associations' }); + } + } + + async resolveAssociation( + request: FastifyRequest<{ Params: { id: string }; Body: { vehicleId: string } }>, + reply: FastifyReply + ): Promise { + try { + const userId = (request as any).user.sub; + const { id } = request.params; + const { vehicleId } = request.body; + + if (!vehicleId || typeof vehicleId !== 'string') { + return reply.code(400).send({ error: 'vehicleId is required' }); + } + + const result = await this.service.resolveAssociation(id, vehicleId, userId); + return reply.code(200).send(result); + } catch (error: any) { + const userId = (request as any).user?.sub; + logger.error('Error resolving pending association', { + error: error.message, + associationId: request.params.id, + userId, + }); + + if (error.message === 'Pending association not found' || error.message === 'Vehicle not found') { + return reply.code(404).send({ error: error.message }); + } + if (error.message === 'Unauthorized') { + return reply.code(403).send({ error: 'Not authorized' }); + } + if (error.message === 'Association already resolved') { + return reply.code(409).send({ error: error.message }); + } + + return reply.code(500).send({ error: 'Failed to resolve association' }); + } + } + + async dismissAssociation( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply + ): Promise { + try { + const userId = (request as any).user.sub; + const { id } = request.params; + + await this.service.dismissAssociation(id, userId); + return reply.code(204).send(); + } catch (error: any) { + const userId = (request as any).user?.sub; + logger.error('Error dismissing pending association', { + error: error.message, + associationId: request.params.id, + userId, + }); + + if (error.message === 'Pending association not found') { + return reply.code(404).send({ error: error.message }); + } + if (error.message === 'Unauthorized') { + return reply.code(403).send({ error: 'Not authorized' }); + } + if (error.message === 'Association already resolved') { + return reply.code(409).send({ error: error.message }); + } + + return reply.code(500).send({ error: 'Failed to dismiss association' }); + } + } + + // ======================== + // Webhook Endpoint (Public) + // ======================== + + async handleInboundWebhook(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const rawBody = (request as any).rawBody; + if (!rawBody) { + logger.error('Missing raw body in Resend webhook request'); + return reply.status(400).send({ error: 'Missing raw body' }); + } + + // Extract Svix headers for signature verification + const headers: Record = { + 'svix-id': (request.headers['svix-id'] as string) || '', + 'svix-timestamp': (request.headers['svix-timestamp'] as string) || '', + 'svix-signature': (request.headers['svix-signature'] as string) || '', + }; + + // Verify webhook signature + let event: ResendWebhookEvent; + try { + event = this.resendClient.verifyWebhookSignature(rawBody, headers); + } catch (error: any) { + logger.warn('Invalid Resend webhook signature', { error: error.message }); + return reply.status(400).send({ error: 'Invalid signature' }); + } + + const emailId = event.data.email_id; + const senderEmail = event.data.from; + + // Idempotency check: reject if email_id already exists in queue + const existing = await this.repository.findByEmailId(emailId); + if (existing) { + logger.info('Duplicate email webhook received, skipping', { emailId }); + return reply.status(200).send({ received: true, duplicate: true }); + } + + // Insert queue record with status=pending via repository + await this.repository.insertQueueEntry({ + emailId, + senderEmail, + userId: senderEmail, // Resolved to auth0_sub during processing + receivedAt: event.data.created_at || new Date().toISOString(), + subject: event.data.subject, + }); + + logger.info('Inbound email queued for processing', { emailId, senderEmail }); + + // Return 200 immediately before processing begins + reply.status(200).send({ received: true }); + + // Trigger async processing via setImmediate + setImmediate(() => { + this.service.processEmail(emailId, event).catch((error) => { + logger.error('Async email processing failed', { + emailId, + error: error instanceof Error ? error.message : String(error), + }); + }); + }); + } catch (error: any) { + logger.error('Resend webhook handler error', { + error: error.message, + stack: error.stack, + }); + return reply.status(500).send({ error: 'Webhook processing failed' }); + } + } +} diff --git a/backend/src/features/email-ingestion/api/email-ingestion.routes.ts b/backend/src/features/email-ingestion/api/email-ingestion.routes.ts new file mode 100644 index 0000000..9d608f0 --- /dev/null +++ b/backend/src/features/email-ingestion/api/email-ingestion.routes.ts @@ -0,0 +1,60 @@ +/** + * @ai-summary Resend inbound webhook + user-facing pending association routes + * @ai-context Public webhook (no JWT) + authenticated CRUD for pending vehicle associations + */ + +import { FastifyPluginAsync } from 'fastify'; +import { EmailIngestionController } from './email-ingestion.controller'; + +/** Public webhook route - no JWT auth, uses Svix signature verification */ +export const emailIngestionWebhookRoutes: FastifyPluginAsync = async (fastify) => { + const controller = new EmailIngestionController(); + + // POST /api/webhooks/resend/inbound - PUBLIC endpoint (no JWT auth) + // Resend authenticates via webhook signature verification (Svix) + // rawBody MUST be enabled for signature verification to work + fastify.post( + '/webhooks/resend/inbound', + { + config: { + rawBody: true, + }, + }, + controller.handleInboundWebhook.bind(controller) + ); +}; + +/** Authenticated user-facing routes for pending vehicle associations */ +export const emailIngestionRoutes: FastifyPluginAsync = async (fastify) => { + const controller = new EmailIngestionController(); + + // GET /api/email-ingestion/pending - List pending associations for authenticated user + fastify.get('/email-ingestion/pending', { + preHandler: [fastify.authenticate], + handler: controller.getPendingAssociations.bind(controller), + }); + + // GET /api/email-ingestion/pending/count - Get count of pending associations + fastify.get('/email-ingestion/pending/count', { + preHandler: [fastify.authenticate], + handler: controller.getPendingAssociationCount.bind(controller), + }); + + // POST /api/email-ingestion/pending/:id/resolve - Resolve by selecting vehicle + fastify.post<{ Params: { id: string }; Body: { vehicleId: string } }>( + '/email-ingestion/pending/:id/resolve', + { + preHandler: [fastify.authenticate], + handler: controller.resolveAssociation.bind(controller), + } + ); + + // DELETE /api/email-ingestion/pending/:id - Dismiss/discard a pending association + fastify.delete<{ Params: { id: string } }>( + '/email-ingestion/pending/:id', + { + preHandler: [fastify.authenticate], + handler: controller.dismissAssociation.bind(controller), + } + ); +}; diff --git a/backend/src/features/email-ingestion/data/email-ingestion.repository.ts b/backend/src/features/email-ingestion/data/email-ingestion.repository.ts new file mode 100644 index 0000000..b91b58c --- /dev/null +++ b/backend/src/features/email-ingestion/data/email-ingestion.repository.ts @@ -0,0 +1,257 @@ +/** + * @ai-summary Data access layer for email ingestion queue and pending vehicle associations + * @ai-context Provides CRUD operations with standard mapRow() snake_case -> camelCase conversion + */ + +import { Pool } from 'pg'; +import pool from '../../../core/config/database'; +import { logger } from '../../../core/logging/logger'; +import type { + EmailIngestionQueueRecord, + EmailIngestionStatus, + EmailProcessingResult, + PendingVehicleAssociation, + PendingAssociationStatus, + EmailRecordType, + ExtractedReceiptData, +} from '../domain/email-ingestion.types'; + +export class EmailIngestionRepository { + constructor(private readonly db: Pool = pool) {} + + // ======================== + // Row Mappers + // ======================== + + private mapQueueRow(row: any): EmailIngestionQueueRecord { + return { + id: row.id, + emailId: row.email_id, + senderEmail: row.sender_email, + userId: row.user_id, + receivedAt: row.received_at, + subject: row.subject, + status: row.status, + processingResult: row.processing_result, + errorMessage: row.error_message, + retryCount: row.retry_count, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + private mapPendingAssociationRow(row: any): PendingVehicleAssociation { + return { + id: row.id, + userId: row.user_id, + recordType: row.record_type, + extractedData: row.extracted_data, + documentId: row.document_id, + status: row.status, + createdAt: row.created_at, + resolvedAt: row.resolved_at, + }; + } + + // ======================== + // Queue Operations + // ======================== + + async insertQueueEntry(entry: { + emailId: string; + senderEmail: string; + userId: string; + receivedAt: string; + subject: string | null; + }): Promise { + try { + const res = await this.db.query( + `INSERT INTO email_ingestion_queue + (email_id, sender_email, user_id, received_at, subject, status) + VALUES ($1, $2, $3, $4, $5, 'pending') + RETURNING *`, + [ + entry.emailId, + entry.senderEmail, + entry.userId, + entry.receivedAt, + entry.subject, + ] + ); + return this.mapQueueRow(res.rows[0]); + } catch (error) { + logger.error('Error inserting queue entry', { error, emailId: entry.emailId }); + throw error; + } + } + + async updateQueueStatus( + emailId: string, + status: EmailIngestionStatus, + updates?: { + processingResult?: EmailProcessingResult; + errorMessage?: string; + retryCount?: number; + userId?: string; + } + ): Promise { + try { + const fields: string[] = ['status = $2']; + const params: any[] = [emailId, status]; + let paramIndex = 3; + + if (updates?.processingResult !== undefined) { + fields.push(`processing_result = $${paramIndex++}`); + params.push(JSON.stringify(updates.processingResult)); + } + if (updates?.errorMessage !== undefined) { + fields.push(`error_message = $${paramIndex++}`); + params.push(updates.errorMessage); + } + if (updates?.retryCount !== undefined) { + fields.push(`retry_count = $${paramIndex++}`); + params.push(updates.retryCount); + } + if (updates?.userId !== undefined) { + fields.push(`user_id = $${paramIndex++}`); + params.push(updates.userId); + } + + const res = await this.db.query( + `UPDATE email_ingestion_queue + SET ${fields.join(', ')} + WHERE email_id = $1 + RETURNING *`, + params + ); + return res.rows[0] ? this.mapQueueRow(res.rows[0]) : null; + } catch (error) { + logger.error('Error updating queue status', { error, emailId, status }); + throw error; + } + } + + async getQueueEntry(emailId: string): Promise { + try { + const res = await this.db.query( + `SELECT * FROM email_ingestion_queue WHERE email_id = $1`, + [emailId] + ); + return res.rows[0] ? this.mapQueueRow(res.rows[0]) : null; + } catch (error) { + logger.error('Error fetching queue entry', { error, emailId }); + throw error; + } + } + + async findByEmailId(emailId: string): Promise { + return this.getQueueEntry(emailId); + } + + async getRetryableEntries(maxRetries: number = 3): Promise { + try { + const res = await this.db.query( + `SELECT * FROM email_ingestion_queue + WHERE status = 'failed' + AND retry_count < $1 + ORDER BY created_at ASC`, + [maxRetries] + ); + return res.rows.map(row => this.mapQueueRow(row)); + } catch (error) { + logger.error('Error fetching retryable entries', { error }); + throw error; + } + } + + // ======================== + // Pending Association Operations + // ======================== + + async insertPendingAssociation(association: { + userId: string; + recordType: EmailRecordType; + extractedData: ExtractedReceiptData; + documentId: string | null; + }): Promise { + try { + const res = await this.db.query( + `INSERT INTO pending_vehicle_associations + (user_id, record_type, extracted_data, document_id, status) + VALUES ($1, $2, $3, $4, 'pending') + RETURNING *`, + [ + association.userId, + association.recordType, + JSON.stringify(association.extractedData), + association.documentId, + ] + ); + return this.mapPendingAssociationRow(res.rows[0]); + } catch (error) { + logger.error('Error inserting pending association', { error, userId: association.userId }); + throw error; + } + } + + async getPendingAssociationById(associationId: string): Promise { + try { + const res = await this.db.query( + `SELECT * FROM pending_vehicle_associations WHERE id = $1`, + [associationId] + ); + return res.rows[0] ? this.mapPendingAssociationRow(res.rows[0]) : null; + } catch (error) { + logger.error('Error fetching pending association by id', { error, associationId }); + throw error; + } + } + + async getPendingAssociationCount(userId: string): Promise { + try { + const res = await this.db.query( + `SELECT COUNT(*)::int AS count FROM pending_vehicle_associations + WHERE user_id = $1 AND status = 'pending'`, + [userId] + ); + return res.rows[0]?.count ?? 0; + } catch (error) { + logger.error('Error counting pending associations', { error, userId }); + throw error; + } + } + + async getPendingAssociations(userId: string): Promise { + try { + const res = await this.db.query( + `SELECT * FROM pending_vehicle_associations + WHERE user_id = $1 AND status = 'pending' + ORDER BY created_at DESC`, + [userId] + ); + return res.rows.map(row => this.mapPendingAssociationRow(row)); + } catch (error) { + logger.error('Error fetching pending associations', { error, userId }); + throw error; + } + } + + async resolvePendingAssociation( + associationId: string, + status: PendingAssociationStatus = 'resolved' + ): Promise { + try { + const res = await this.db.query( + `UPDATE pending_vehicle_associations + SET status = $2, resolved_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING *`, + [associationId, status] + ); + return res.rows[0] ? this.mapPendingAssociationRow(res.rows[0]) : null; + } catch (error) { + logger.error('Error resolving pending association', { error, associationId }); + throw error; + } + } +} diff --git a/backend/src/features/email-ingestion/domain/email-ingestion.service.ts b/backend/src/features/email-ingestion/domain/email-ingestion.service.ts new file mode 100644 index 0000000..4cae7ba --- /dev/null +++ b/backend/src/features/email-ingestion/domain/email-ingestion.service.ts @@ -0,0 +1,844 @@ +/** + * @ai-summary Core processing service for the email-to-record pipeline + * @ai-context Orchestrates sender validation, OCR extraction, record classification, + * vehicle association, status tracking, and retry logic. Delegates all notifications + * (emails, in-app, logging) to EmailIngestionNotificationHandler. + */ + +import { Pool } from 'pg'; +import pool from '../../../core/config/database'; +import { logger } from '../../../core/logging/logger'; +import { EmailIngestionRepository } from '../data/email-ingestion.repository'; +import { ResendInboundClient, type ParsedEmailAttachment } from '../external/resend-inbound.client'; +import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; +import { VehiclesRepository } from '../../vehicles/data/vehicles.repository'; +import { NotificationsRepository } from '../../notifications/data/notifications.repository'; +import { TemplateService } from '../../notifications/domain/template.service'; +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 { EmailIngestionNotificationHandler } from './notification-handler'; +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, + ExtractedReceiptData, + EmailRecordType, +} from './email-ingestion.types'; + +/** Supported attachment MIME types */ +const SUPPORTED_ATTACHMENT_TYPES = new Set([ + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/heic', + 'image/heif', +]); + +/** Image types that work with receipt-specific OCR */ +const OCR_RECEIPT_IMAGE_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/heic', + 'image/heif', +]); + +const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB +const MAX_RETRY_COUNT = 3; + +export class EmailIngestionService { + private repository: EmailIngestionRepository; + private resendClient: ResendInboundClient; + private userProfileRepository: UserProfileRepository; + private vehiclesRepository: VehiclesRepository; + private notificationHandler: EmailIngestionNotificationHandler; + private classifier: ReceiptClassifier; + private fuelLogsService: FuelLogsService; + private maintenanceService: MaintenanceService; + + constructor(dbPool?: Pool) { + const p = dbPool || pool; + this.repository = new EmailIngestionRepository(p); + this.resendClient = new ResendInboundClient(); + this.userProfileRepository = new UserProfileRepository(p); + this.vehiclesRepository = new VehiclesRepository(p); + const notificationsRepository = new NotificationsRepository(p); + this.notificationHandler = new EmailIngestionNotificationHandler( + notificationsRepository, + new TemplateService(), + new EmailService(), + ); + this.classifier = new ReceiptClassifier(); + this.fuelLogsService = new FuelLogsService(new FuelLogsRepository(p)); + this.maintenanceService = new MaintenanceService(); + } + + // ======================== + // Main Processing Pipeline + // ======================== + + /** + * Process an inbound email through the full pipeline. + * Called asynchronously after webhook receipt is acknowledged. + */ + async processEmail(emailId: string, event: ResendWebhookEvent): Promise { + const senderEmail = event.data.from; + const subject = event.data.subject; + + try { + // 1. Mark as processing + await this.repository.updateQueueStatus(emailId, 'processing'); + + // 2. Validate sender + const userProfile = await this.validateSender(senderEmail); + if (!userProfile) { + await this.handleUnregisteredSender(emailId, senderEmail); + return; + } + + const userId = userProfile.auth0Sub; + const userName = userProfile.displayName || userProfile.email; + + // Update queue with resolved user_id + await this.repository.updateQueueStatus(emailId, 'processing', { userId }); + + // 3. Get attachments (from webhook data or by fetching raw email) + const attachments = await this.getAttachments(emailId, event); + + // 4. Filter valid attachments + const validAttachments = this.filterAttachments(attachments); + if (validAttachments.length === 0) { + await this.handleNoValidAttachments(emailId, userId, userName, senderEmail); + return; + } + + // 5. Classify receipt from email text first + const emailClassification = this.classifier.classifyFromText(subject, event.data.text); + logger.info('Email text classification result', { + emailId, + type: emailClassification.type, + confidence: emailClassification.confidence, + }); + + // 6. Process attachments through OCR using classification + const ocrResult = await this.processAttachmentsWithClassification( + userId, validAttachments, emailClassification, emailId + ); + if (!ocrResult) { + await this.handleOcrFailure(emailId, userId, userName, senderEmail, 'No receipt data could be extracted from attachments'); + return; + } + + // 7. Build extracted data from OCR result + const extractedData = this.mapOcrToExtractedData(ocrResult.response); + const recordType = ocrResult.recordType; + + // 8. Handle vehicle association + const processingResult = await this.handleVehicleAssociation( + userId, userName, senderEmail, recordType, extractedData + ); + + // 9. Mark as completed + await this.repository.updateQueueStatus(emailId, 'completed', { + processingResult, + }); + + logger.info('Email processing completed successfully', { + emailId, + userId, + recordType, + vehicleId: processingResult.vehicleId, + pendingAssociationId: processingResult.pendingAssociationId, + }); + } catch (error) { + await this.handleProcessingError(emailId, senderEmail, subject, error); + } + } + + // ======================== + // Sender Validation + // ======================== + + private async validateSender(senderEmail: string): Promise<{ + auth0Sub: string; + email: string; + displayName: string | null; + } | null> { + // Case-insensitive lookup by lowercasing the sender email + const profile = await this.userProfileRepository.getByEmail(senderEmail.toLowerCase()); + if (profile) { + return { + auth0Sub: profile.auth0Sub, + email: profile.email, + displayName: profile.displayName ?? null, + }; + } + + // Try original case as fallback + if (senderEmail !== senderEmail.toLowerCase()) { + const fallback = await this.userProfileRepository.getByEmail(senderEmail); + if (fallback) { + return { + auth0Sub: fallback.auth0Sub, + email: fallback.email, + displayName: fallback.displayName ?? null, + }; + } + } + + return null; + } + + // ======================== + // Attachment Handling + // ======================== + + /** + * Get attachments from webhook data or by fetching the raw email + */ + private async getAttachments( + emailId: string, + event: ResendWebhookEvent + ): Promise { + // If webhook includes attachments with content, use those + if (event.data.attachments && event.data.attachments.length > 0) { + return event.data.attachments.map(att => ({ + filename: att.filename, + contentType: att.content_type, + content: Buffer.from(att.content, 'base64'), + size: Buffer.from(att.content, 'base64').length, + })); + } + + // Otherwise fetch and parse the raw email + try { + const { downloadUrl } = await this.resendClient.getEmail(emailId); + const rawEmail = await this.resendClient.downloadRawEmail(downloadUrl); + const parsed = await this.resendClient.parseEmail(rawEmail); + return parsed.attachments; + } catch (error) { + logger.warn('Failed to fetch raw email for attachments', { + emailId, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + } + + /** + * Filter attachments by supported type and size + */ + private filterAttachments(attachments: ParsedEmailAttachment[]): ParsedEmailAttachment[] { + return attachments.filter(att => { + if (!SUPPORTED_ATTACHMENT_TYPES.has(att.contentType)) { + logger.info('Skipping unsupported attachment type', { + filename: att.filename, + contentType: att.contentType, + }); + return false; + } + + if (att.size > MAX_ATTACHMENT_SIZE) { + logger.info('Skipping oversized attachment', { + filename: att.filename, + size: att.size, + maxSize: MAX_ATTACHMENT_SIZE, + }); + return false; + } + + return true; + }); + } + + // ======================== + // OCR Processing + // ======================== + + /** + * Process attachments using classifier-driven OCR extraction. + * If email text classification is confident, calls the specific OCR endpoint. + * If not, performs general OCR and classifies from rawText. + * Returns null if no usable result or receipt is unclassified. + */ + private async processAttachmentsWithClassification( + userId: string, + attachments: ParsedEmailAttachment[], + emailClassification: { type: string; confidence: number }, + emailId: string + ): Promise<{ response: ReceiptExtractionResponse; recordType: EmailRecordType } | null> { + const imageAttachments = attachments.filter(att => OCR_RECEIPT_IMAGE_TYPES.has(att.contentType)); + + for (const attachment of imageAttachments) { + // If email text gave a confident classification, call the specific OCR endpoint first + if (emailClassification.type === 'fuel') { + const result = await this.extractFuelReceipt(userId, attachment); + if (result?.success) return { response: result, recordType: 'fuel_log' }; + // Fuel OCR failed, try maintenance as fallback + const fallbackResult = await this.extractMaintenanceReceipt(userId, attachment); + if (fallbackResult?.success) return { response: fallbackResult, recordType: 'maintenance_record' }; + continue; + } + + if (emailClassification.type === 'maintenance') { + const result = await this.extractMaintenanceReceipt(userId, attachment); + if (result?.success) return { response: result, recordType: 'maintenance_record' }; + // Maintenance OCR failed, try fuel as fallback + const fallbackResult = await this.extractFuelReceipt(userId, attachment); + if (fallbackResult?.success) return { response: fallbackResult, recordType: 'fuel_log' }; + continue; + } + + // Email text was not confident - try both OCR endpoints and classify from rawText + const fuelResult = await this.extractFuelReceipt(userId, attachment); + const maintenanceResult = await this.extractMaintenanceReceipt(userId, attachment); + + // Use rawText from whichever succeeded for secondary classification + const rawText = fuelResult?.rawText || maintenanceResult?.rawText || ''; + if (rawText) { + const ocrClassification = this.classifier.classifyFromOcrRawText(rawText); + logger.info('OCR rawText classification result', { + emailId, + type: ocrClassification.type, + confidence: ocrClassification.confidence, + }); + + if (ocrClassification.type === 'fuel' && fuelResult?.success) { + return { response: fuelResult, recordType: 'fuel_log' }; + } + if (ocrClassification.type === 'maintenance' && maintenanceResult?.success) { + return { response: maintenanceResult, recordType: 'maintenance_record' }; + } + } + + // Both classifiers failed - fall back to field-count heuristic + const fallback = this.selectBestResultByFields(fuelResult, maintenanceResult); + if (fallback) return fallback; + } + + return null; + } + + /** + * Extract fuel receipt via OCR. Returns null on failure. + */ + private async extractFuelReceipt( + userId: string, + attachment: ParsedEmailAttachment + ): Promise { + try { + return await ocrService.extractReceipt(userId, { + fileBuffer: attachment.content, + contentType: attachment.contentType, + receiptType: 'fuel', + }); + } catch (error) { + logger.info('Fuel receipt extraction failed', { + filename: attachment.filename, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + + /** + * Extract maintenance receipt via OCR. Returns null on failure. + */ + private async extractMaintenanceReceipt( + userId: string, + attachment: ParsedEmailAttachment + ): Promise { + try { + return await ocrService.extractMaintenanceReceipt(userId, { + fileBuffer: attachment.content, + contentType: attachment.contentType, + }); + } catch (error) { + logger.info('Maintenance receipt extraction failed', { + filename: attachment.filename, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + + /** + * Last-resort fallback: select the better OCR result based on domain-specific + * fields and field count when keyword classifiers could not decide. + */ + private selectBestResultByFields( + fuelResult: ReceiptExtractionResponse | null, + maintenanceResult: ReceiptExtractionResponse | null + ): { response: ReceiptExtractionResponse; recordType: EmailRecordType } | null { + const fuelFieldCount = fuelResult?.success + ? Object.keys(fuelResult.extractedFields).length + : 0; + const maintenanceFieldCount = maintenanceResult?.success + ? Object.keys(maintenanceResult.extractedFields).length + : 0; + + if (fuelFieldCount === 0 && maintenanceFieldCount === 0) { + return null; + } + + const hasFuelFields = fuelResult?.extractedFields['gallons'] || + fuelResult?.extractedFields['price_per_gallon'] || + fuelResult?.extractedFields['fuel_type']; + + const hasMaintenanceFields = maintenanceResult?.extractedFields['category'] || + maintenanceResult?.extractedFields['shop_name'] || + maintenanceResult?.extractedFields['description']; + + if (hasFuelFields && !hasMaintenanceFields) { + return { response: fuelResult!, recordType: 'fuel_log' }; + } + if (hasMaintenanceFields && !hasFuelFields) { + return { response: maintenanceResult!, recordType: 'maintenance_record' }; + } + + if (fuelFieldCount >= maintenanceFieldCount && fuelResult?.success) { + return { response: fuelResult, recordType: 'fuel_log' }; + } + if (maintenanceResult?.success) { + return { response: maintenanceResult, recordType: 'maintenance_record' }; + } + + return null; + } + + /** + * Map OCR extracted fields to our ExtractedReceiptData format + */ + private mapOcrToExtractedData(response: ReceiptExtractionResponse): ExtractedReceiptData { + const fields = response.extractedFields; + const getFieldValue = (key: string): string | null => + fields[key]?.value || null; + const getFieldNumber = (key: string): number | null => { + const val = fields[key]?.value; + if (!val) return null; + const num = parseFloat(val); + return isNaN(num) ? null : num; + }; + + return { + vendor: getFieldValue('vendor') || getFieldValue('shop_name'), + date: getFieldValue('date'), + total: getFieldNumber('total'), + odometerReading: getFieldNumber('odometer') || getFieldNumber('odometer_reading'), + gallons: getFieldNumber('gallons'), + pricePerGallon: getFieldNumber('price_per_gallon'), + fuelType: getFieldValue('fuel_type'), + category: getFieldValue('category'), + subtypes: fields['subtypes']?.value ? fields['subtypes'].value.split(',').map(s => s.trim()) : null, + shopName: getFieldValue('shop_name'), + description: getFieldValue('description'), + }; + } + + // ======================== + // Vehicle Association + // ======================== + + /** + * Handle vehicle association based on user's vehicle count. + * 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, + userName: string, + userEmail: string, + recordType: EmailRecordType, + extractedData: ExtractedReceiptData + ): Promise { + const vehicles = await this.vehiclesRepository.findByUserId(userId); + + // No vehicles: user must add a vehicle first + if (vehicles.length === 0) { + await this.notificationHandler.notifyNoVehicles(userId, userName, userEmail); + return { + recordType, + vehicleId: null, + recordId: null, + documentId: null, + pendingAssociationId: null, + extractedData, + }; + } + + // Single vehicle: auto-associate and create record + if (vehicles.length === 1) { + const vehicle = vehicles[0]; + let recordId: string | null = null; + + try { + recordId = await this.createRecord(userId, vehicle.id, recordType, extractedData); + } catch (error) { + logger.error('Failed to create record from email receipt', { + userId, + vehicleId: vehicle.id, + recordType, + error: error instanceof Error ? error.message : String(error), + }); + } + + const vehicleName = vehicle.nickname + || [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ') + || 'your vehicle'; + + await this.notificationHandler.notifyReceiptProcessed({ + userId, + userName, + userEmail, + vehicleName, + recordType, + recordId, + vehicleId: vehicle.id, + extractedData, + }); + + return { + recordType, + vehicleId: vehicle.id, + recordId, + documentId: null, + pendingAssociationId: null, + extractedData, + }; + } + + // Multiple vehicles: create pending association for user selection + const pendingAssociation = await this.repository.insertPendingAssociation({ + userId, + recordType, + extractedData, + documentId: null, + }); + + await this.notificationHandler.notifyPendingVehicleSelection({ + userId, + userName, + userEmail, + recordType, + pendingAssociationId: pendingAssociation.id, + extractedData, + }); + + return { + recordType, + vehicleId: null, + recordId: null, + documentId: null, + pendingAssociationId: pendingAssociation.id, + extractedData, + }; + } + + // ======================== + // Public Resolution API + // ======================== + + /** + * Resolve a pending vehicle association by creating the record with the selected vehicle. + * Called from the user-facing API when a multi-vehicle user picks a vehicle. + */ + async resolveAssociation( + associationId: string, + vehicleId: string, + userId: string + ): Promise<{ recordId: string; recordType: EmailRecordType }> { + const association = await this.repository.getPendingAssociationById(associationId); + if (!association) { + throw new Error('Pending association not found'); + } + if (association.userId !== userId) { + throw new Error('Unauthorized'); + } + if (association.status !== 'pending') { + throw new Error('Association already resolved'); + } + + // Verify vehicle belongs to user + const vehicles = await this.vehiclesRepository.findByUserId(userId); + const vehicle = vehicles.find(v => v.id === vehicleId); + if (!vehicle) { + throw new Error('Vehicle not found'); + } + + // Create the record + const recordId = await this.createRecord(userId, vehicleId, association.recordType, association.extractedData); + + // Mark as resolved + await this.repository.resolvePendingAssociation(associationId, 'resolved'); + + logger.info('Pending association resolved', { associationId, vehicleId, userId, recordType: association.recordType, recordId }); + + return { recordId, recordType: association.recordType }; + } + + /** + * Dismiss a pending vehicle association without creating a record. + */ + async dismissAssociation(associationId: string, userId: string): Promise { + const association = await this.repository.getPendingAssociationById(associationId); + if (!association) { + throw new Error('Pending association not found'); + } + if (association.userId !== userId) { + throw new Error('Unauthorized'); + } + if (association.status !== 'pending') { + throw new Error('Association already resolved'); + } + + await this.repository.resolvePendingAssociation(associationId, 'expired'); + logger.info('Pending association dismissed', { associationId, userId }); + } + + // ======================== + // 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 + // ======================== + + private async handleProcessingError( + emailId: string, + senderEmail: string, + _subject: string | null, + error: unknown + ): Promise { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('Email processing pipeline error', { emailId, error: errorMessage }); + + // Get current queue entry for retry count and userId + const queueEntry = await this.repository.getQueueEntry(emailId); + const currentRetryCount = queueEntry?.retryCount || 0; + const newRetryCount = currentRetryCount + 1; + + if (newRetryCount < MAX_RETRY_COUNT) { + // Mark for retry + await this.repository.updateQueueStatus(emailId, 'failed', { + errorMessage, + retryCount: newRetryCount, + }); + + logger.info('Email queued for retry', { + emailId, + retryCount: newRetryCount, + maxRetries: MAX_RETRY_COUNT, + }); + } else { + // Max retries exceeded - permanently failed + await this.repository.updateQueueStatus(emailId, 'failed', { + errorMessage: `Max retries (${MAX_RETRY_COUNT}) exceeded. Last error: ${errorMessage}`, + retryCount: newRetryCount, + }); + + // Send failure notification (email + in-app if userId available) + await this.notificationHandler.notifyProcessingFailure({ + userId: queueEntry?.userId, + userEmail: senderEmail, + errorReason: errorMessage, + }).catch(notifyErr => { + logger.error('Failed to send failure notification', { + emailId, + error: notifyErr instanceof Error ? notifyErr.message : String(notifyErr), + }); + }); + } + } + + private async handleUnregisteredSender( + emailId: string, + senderEmail: string + ): Promise { + logger.info('Unregistered sender rejected', { emailId, senderEmail }); + + await this.repository.updateQueueStatus(emailId, 'failed', { + errorMessage: 'Sender email is not registered with MotoVaultPro', + }); + + await this.notificationHandler.notifyUnregisteredSender(senderEmail).catch(error => { + logger.error('Failed to send unregistered sender notification', { + emailId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + private async handleNoValidAttachments( + emailId: string, + userId: string, + userName: string, + userEmail: string + ): Promise { + logger.info('No valid attachments found', { emailId }); + + await this.repository.updateQueueStatus(emailId, 'failed', { + errorMessage: 'No valid attachments found. Supported types: PDF, PNG, JPG, JPEG, HEIC (max 10MB each)', + }); + + await this.notificationHandler.notifyNoValidAttachments(userId, userName, userEmail).catch(error => { + logger.error('Failed to send no-attachments notification', { + emailId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + private async handleOcrFailure( + emailId: string, + userId: string, + userName: string, + userEmail: string, + reason: string + ): Promise { + logger.info('OCR extraction failed for all attachments', { emailId, reason }); + + await this.repository.updateQueueStatus(emailId, 'failed', { + errorMessage: reason, + }); + + await this.notificationHandler.notifyOcrFailure(userId, userName, userEmail, reason).catch(error => { + logger.error('Failed to send OCR failure notification', { + emailId, + error: error instanceof Error ? error.message : String(error), + }); + }); + } +} 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..dc267ca --- /dev/null +++ b/backend/src/features/email-ingestion/domain/email-ingestion.types.ts @@ -0,0 +1,114 @@ +/** + * @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'; + +// ======================== +// Receipt Classification +// ======================== + +export type ReceiptClassificationType = 'fuel' | 'maintenance' | 'unclassified'; + +export interface ClassificationResult { + type: ReceiptClassificationType; + confidence: number; +} + +// ======================== +// 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/domain/notification-handler.ts b/backend/src/features/email-ingestion/domain/notification-handler.ts new file mode 100644 index 0000000..7f6ed6b --- /dev/null +++ b/backend/src/features/email-ingestion/domain/notification-handler.ts @@ -0,0 +1,333 @@ +/** + * @ai-summary Notification handler for the email ingestion pipeline + * @ai-context Encapsulates all email replies, in-app notifications, and notification logging + * for the email-to-record flow. Every email sent is logged to notification_logs. + */ + +import { logger } from '../../../core/logging/logger'; +import { NotificationsRepository } from '../../notifications/data/notifications.repository'; +import { TemplateService } from '../../notifications/domain/template.service'; +import { EmailService } from '../../notifications/domain/email.service'; +import type { TemplateKey } from '../../notifications/domain/notifications.types'; +import type { EmailRecordType, ExtractedReceiptData } from './email-ingestion.types'; + +export class EmailIngestionNotificationHandler { + constructor( + private notificationsRepository: NotificationsRepository, + private templateService: TemplateService, + private emailService: EmailService, + ) {} + + // ======================== + // Success Notifications + // ======================== + + /** + * Notify user that their emailed receipt was successfully processed. + * Sends confirmation email + creates in-app notification + logs to notification_logs. + */ + async notifyReceiptProcessed(params: { + userId: string; + userName: string; + userEmail: string; + vehicleName: string; + recordType: EmailRecordType; + recordId: string | null; + vehicleId: string; + extractedData: ExtractedReceiptData; + }): Promise { + const { userId, userName, userEmail, vehicleName, recordType, recordId, vehicleId, extractedData } = params; + const recordLabel = recordType === 'fuel_log' ? 'Fuel Log' : 'Maintenance Record'; + const merchantName = extractedData.vendor || extractedData.shopName || 'Unknown'; + + // In-app notification + const message = recordId + ? `Your emailed ${recordLabel.toLowerCase()} receipt from ${merchantName} has been processed and recorded for ${vehicleName}.` + : `Your emailed ${recordLabel.toLowerCase()} receipt from ${merchantName} 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, + }); + + // Confirmation email + await this.sendTemplateEmail({ + templateKey: 'receipt_processed', + userId, + userEmail, + variables: { + userName, + vehicleName, + recordType: recordLabel, + merchantName, + totalAmount: extractedData.total?.toFixed(2) || 'N/A', + date: extractedData.date || 'N/A', + }, + referenceType: 'email_ingestion', + referenceId: recordId ?? undefined, + }); + } + + // ======================== + // Pending Vehicle Notification + // ======================== + + /** + * Notify multi-vehicle user that their receipt needs vehicle selection. + * Sends pending-vehicle email + creates in-app notification + logs to notification_logs. + */ + async notifyPendingVehicleSelection(params: { + userId: string; + userName: string; + userEmail: string; + recordType: EmailRecordType; + pendingAssociationId: string; + extractedData: ExtractedReceiptData; + }): Promise { + const { userId, userName, userEmail, recordType, pendingAssociationId, extractedData } = params; + const recordLabel = recordType === 'fuel_log' ? 'Fuel Log' : 'Maintenance Record'; + const merchantName = extractedData.vendor || extractedData.shopName || 'Unknown'; + + // In-app notification + await this.notificationsRepository.insertUserNotification({ + userId, + notificationType: 'email_ingestion', + title: 'Vehicle Selection Required', + message: `Your emailed receipt from ${merchantName} has been processed. Please select which vehicle this ${recordLabel.toLowerCase()} belongs to.`, + referenceType: 'pending_vehicle_association', + referenceId: pendingAssociationId, + }); + + // Pending vehicle email + await this.sendTemplateEmail({ + templateKey: 'receipt_pending_vehicle', + userId, + userEmail, + variables: { + userName, + recordType: recordLabel, + merchantName, + totalAmount: extractedData.total?.toFixed(2) || 'N/A', + date: extractedData.date || 'N/A', + }, + referenceType: 'email_ingestion', + referenceId: pendingAssociationId, + }); + } + + // ======================== + // Error Notifications + // ======================== + + /** + * Notify unregistered sender that their email was rejected. + * Email reply only (no in-app notification since no user account). + */ + async notifyUnregisteredSender(userEmail: string): Promise { + await this.sendTemplateEmail({ + templateKey: 'receipt_failed', + userEmail, + variables: { + userName: 'MotoVaultPro User', + errorReason: 'This email address is not registered with MotoVaultPro.', + guidance: 'Please send receipts from the email address associated with your account. You can check your registered email in your MotoVaultPro profile settings.', + }, + referenceType: 'email_ingestion', + }); + } + + /** + * Notify user that they must add a vehicle before emailing receipts. + * Sends error email + creates in-app notification + logs to notification_logs. + */ + async notifyNoVehicles(userId: string, userName: string, userEmail: string): Promise { + // In-app notification + await this.notificationsRepository.insertUserNotification({ + userId, + notificationType: 'email_ingestion', + title: 'Receipt Processing Failed', + message: 'Your emailed receipt could not be processed because you have no vehicles registered. Please add a vehicle first, then re-send your receipt.', + }); + + // Error email + await this.sendTemplateEmail({ + templateKey: 'receipt_failed', + userId, + userEmail, + variables: { + userName, + errorReason: 'You do not have any vehicles registered in MotoVaultPro.', + guidance: 'Please add a vehicle first in the MotoVaultPro app, then re-send your receipt.', + }, + referenceType: 'email_ingestion', + }); + } + + /** + * Notify user that no valid attachments were found in their email. + * Sends error email + creates in-app notification + logs to notification_logs. + */ + async notifyNoValidAttachments(userId: string, userName: string, userEmail: string): Promise { + // In-app notification + await this.notificationsRepository.insertUserNotification({ + userId, + notificationType: 'email_ingestion', + title: 'Receipt Processing Failed', + message: 'No valid attachments were found in your email. Please attach receipt images (PNG, JPG, JPEG, HEIC) or PDFs under 10MB.', + }); + + // Error email + await this.sendTemplateEmail({ + templateKey: 'receipt_failed', + userId, + userEmail, + variables: { + userName, + errorReason: 'No valid attachments were found in your email.', + guidance: 'Please attach receipt images (PNG, JPG, JPEG, HEIC) or PDFs under 10MB. Make sure your receipt is clearly visible in the image.', + }, + referenceType: 'email_ingestion', + }); + } + + /** + * Notify user that OCR extraction failed after all attempts. + * Sends error email + creates in-app notification + logs to notification_logs. + */ + async notifyOcrFailure(userId: string, userName: string, userEmail: string, reason: string): Promise { + // In-app notification + await this.notificationsRepository.insertUserNotification({ + userId, + notificationType: 'email_ingestion', + title: 'Receipt Processing Failed', + message: `We could not extract data from your receipt: ${reason}`, + }); + + // Error email + await this.sendTemplateEmail({ + templateKey: 'receipt_failed', + userId, + userEmail, + variables: { + userName, + errorReason: reason, + guidance: 'Please try again with a clearer image or PDF. Tips: use a well-lit, high-contrast photo; ensure text is legible and not cut off; PDF receipts work best.', + }, + referenceType: 'email_ingestion', + }); + } + + /** + * Notify user of a general processing failure (after max retries exceeded). + * Sends error email + creates in-app notification (if userId available) + logs. + */ + async notifyProcessingFailure(params: { + userId?: string; + userName?: string; + userEmail: string; + errorReason: string; + }): Promise { + const { userId, userName, userEmail, errorReason } = params; + + // In-app notification (only if we have a userId) + if (userId) { + await this.notificationsRepository.insertUserNotification({ + userId, + notificationType: 'email_ingestion', + title: 'Receipt Processing Failed', + message: `Your emailed receipt could not be processed: ${errorReason}`, + }); + } + + // Error email + await this.sendTemplateEmail({ + templateKey: 'receipt_failed', + userId, + userEmail, + variables: { + userName: userName || 'MotoVaultPro User', + errorReason, + guidance: 'Please try again or upload the receipt directly through the MotoVaultPro app.', + }, + referenceType: 'email_ingestion', + }); + } + + // ======================== + // Internal Helpers + // ======================== + + /** + * Send a templated email and log to notification_logs. + * Swallows errors to prevent notification failures from breaking the pipeline. + */ + private async sendTemplateEmail(params: { + templateKey: TemplateKey; + userId?: string; + userEmail: string; + variables: Record; + referenceType?: string; + referenceId?: string; + }): Promise { + const { templateKey, userId, userEmail, variables, referenceType, referenceId } = params; + + try { + const template = await this.notificationsRepository.getEmailTemplateByKey(templateKey); + if (!template || !template.isActive) { + logger.warn('Email template not found or inactive', { templateKey }); + return; + } + + const renderedSubject = this.templateService.render(template.subject, variables); + const renderedHtml = this.templateService.renderEmailHtml(template.body, variables); + + await this.emailService.send(userEmail, renderedSubject, renderedHtml); + + // Log successful send + if (userId) { + await this.notificationsRepository.insertNotificationLog({ + user_id: userId, + notification_type: 'email', + template_key: templateKey, + recipient_email: userEmail, + subject: renderedSubject, + reference_type: referenceType, + reference_id: referenceId, + status: 'sent', + }); + } + + logger.info('Email ingestion notification sent', { templateKey, userEmail }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('Failed to send email ingestion notification', { + templateKey, + userEmail, + error: errorMessage, + }); + + // Log failed send + if (userId) { + await this.notificationsRepository.insertNotificationLog({ + user_id: userId, + notification_type: 'email', + template_key: templateKey, + recipient_email: userEmail, + reference_type: referenceType, + reference_id: referenceId, + status: 'failed', + error_message: errorMessage, + }).catch(logErr => { + logger.error('Failed to log notification failure', { + error: logErr instanceof Error ? logErr.message : String(logErr), + }); + }); + } + } + } +} diff --git a/backend/src/features/email-ingestion/domain/receipt-classifier.ts b/backend/src/features/email-ingestion/domain/receipt-classifier.ts new file mode 100644 index 0000000..417f495 --- /dev/null +++ b/backend/src/features/email-ingestion/domain/receipt-classifier.ts @@ -0,0 +1,130 @@ +/** + * @ai-summary Classifies receipt type from email text or OCR raw text + * @ai-context Uses keyword matching to determine fuel vs maintenance receipts + * before falling back to OCR-based classification. Returns confidence score. + */ + +import { logger } from '../../../core/logging/logger'; +import type { ClassificationResult, ReceiptClassificationType } from './email-ingestion.types'; + +/** Fuel-related keywords (case-insensitive matching) */ +const FUEL_KEYWORDS: string[] = [ + 'gas', + 'fuel', + 'gallons', + 'octane', + 'pump', + 'diesel', + 'unleaded', + 'shell', + 'chevron', + 'exxon', + 'bp', +]; + +/** Maintenance-related keywords (case-insensitive matching). Multi-word entries matched as phrases. */ +const MAINTENANCE_KEYWORDS: string[] = [ + 'oil change', + 'brake', + 'alignment', + 'tire', + 'rotation', + 'inspection', + 'labor', + 'parts', + 'service', + 'repair', + 'transmission', + 'coolant', +]; + +/** Minimum keyword matches required for a confident classification */ +const CONFIDENCE_THRESHOLD = 2; + +export class ReceiptClassifier { + /** + * Classify receipt type from email subject and body text. + * Returns a confident result if >= 2 keyword matches for one type. + */ + classifyFromText(subject: string | null, body: string | null): ClassificationResult { + const text = [subject || '', body || ''].join(' '); + return this.classifyText(text, 'email'); + } + + /** + * Classify receipt type from OCR raw text output. + * Uses same keyword matching as email text classification. + */ + classifyFromOcrRawText(rawText: string): ClassificationResult { + return this.classifyText(rawText, 'ocr'); + } + + /** + * Core keyword matching logic shared by email and OCR classification. + */ + private classifyText(text: string, source: 'email' | 'ocr'): ClassificationResult { + const normalizedText = text.toLowerCase(); + + const fuelMatches = this.countKeywordMatches(normalizedText, FUEL_KEYWORDS); + const maintenanceMatches = this.countKeywordMatches(normalizedText, MAINTENANCE_KEYWORDS); + + logger.info('Receipt classification keyword analysis', { + source, + fuelMatches, + maintenanceMatches, + textLength: text.length, + }); + + // Both below threshold - unclassified + if (fuelMatches < CONFIDENCE_THRESHOLD && maintenanceMatches < CONFIDENCE_THRESHOLD) { + return { type: 'unclassified', confidence: 0 }; + } + + // Clear winner with threshold met + if (fuelMatches >= CONFIDENCE_THRESHOLD && fuelMatches > maintenanceMatches) { + return { + type: 'fuel', + confidence: Math.min(fuelMatches / (fuelMatches + maintenanceMatches), 1), + }; + } + + if (maintenanceMatches >= CONFIDENCE_THRESHOLD && maintenanceMatches > fuelMatches) { + return { + type: 'maintenance', + confidence: Math.min(maintenanceMatches / (fuelMatches + maintenanceMatches), 1), + }; + } + + // Tie with both meeting threshold - unclassified (ambiguous) + if (fuelMatches >= CONFIDENCE_THRESHOLD && maintenanceMatches >= CONFIDENCE_THRESHOLD) { + return { type: 'unclassified', confidence: 0 }; + } + + return { type: 'unclassified', confidence: 0 }; + } + + /** + * Count how many keywords from the list appear in the text. + * Multi-word keywords are matched as phrases. + */ + private countKeywordMatches(normalizedText: string, keywords: string[]): number { + let matches = 0; + for (const keyword of keywords) { + if (normalizedText.includes(keyword)) { + matches++; + } + } + return matches; + } + + /** + * Map classifier type to the EmailRecordType used in the processing pipeline. + */ + static toRecordType(classificationType: ReceiptClassificationType): 'fuel_log' | 'maintenance_record' | null { + switch (classificationType) { + case 'fuel': return 'fuel_log'; + case 'maintenance': return 'maintenance_record'; + case 'unclassified': return null; + } + } +} diff --git a/backend/src/features/email-ingestion/external/resend-inbound.client.ts b/backend/src/features/email-ingestion/external/resend-inbound.client.ts new file mode 100644 index 0000000..8753d3a --- /dev/null +++ b/backend/src/features/email-ingestion/external/resend-inbound.client.ts @@ -0,0 +1,110 @@ +/** + * @ai-summary Resend inbound email client for webhook verification and email parsing + * @ai-context Verifies Resend webhook signatures via Svix, fetches raw emails, parses with mailparser + */ + +import { Webhook } from 'svix'; +import { simpleParser } from 'mailparser'; +import { logger } from '../../../core/logging/logger'; +import type { ResendWebhookEvent } from '../domain/email-ingestion.types'; + +export interface ParsedEmailResult { + text: string | null; + html: string | null; + attachments: ParsedEmailAttachment[]; +} + +export interface ParsedEmailAttachment { + filename: string; + contentType: string; + content: Buffer; + size: number; +} + +export class ResendInboundClient { + private webhookSecret: string | undefined; + private apiKey: string; + + constructor() { + this.apiKey = process.env['RESEND_API_KEY'] || ''; + this.webhookSecret = process.env['RESEND_WEBHOOK_SECRET']; + } + + /** + * Verify Resend webhook signature using Svix + * @throws Error if signature is invalid or secret is not configured + */ + verifyWebhookSignature(rawBody: string | Buffer, headers: Record): ResendWebhookEvent { + if (!this.webhookSecret) { + throw new Error('RESEND_WEBHOOK_SECRET is not configured'); + } + + const wh = new Webhook(this.webhookSecret); + const verified = wh.verify( + typeof rawBody === 'string' ? rawBody : rawBody.toString(), + { + 'svix-id': headers['svix-id'] || '', + 'svix-timestamp': headers['svix-timestamp'] || '', + 'svix-signature': headers['svix-signature'] || '', + } + ); + + return verified as unknown as ResendWebhookEvent; + } + + /** + * Fetch email metadata from Resend API including raw download URL + */ + async getEmail(emailId: string): Promise<{ downloadUrl: string }> { + const response = await fetch(`https://api.resend.com/emails/${emailId}`, { + headers: { 'Authorization': `Bearer ${this.apiKey}` }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch email ${emailId}: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as { raw?: { download_url?: string } }; + const downloadUrl = data.raw?.download_url; + + if (!downloadUrl) { + throw new Error(`No download URL for email ${emailId}`); + } + + logger.info('Fetched email metadata from Resend', { emailId }); + return { downloadUrl }; + } + + /** + * Download raw RFC 5322 email content from Resend download URL + */ + async downloadRawEmail(downloadUrl: string): Promise { + const response = await fetch(downloadUrl); + + if (!response.ok) { + throw new Error(`Failed to download raw email: ${response.status} ${response.statusText}`); + } + + const rawEmail = await response.text(); + logger.info('Downloaded raw email', { size: rawEmail.length }); + return rawEmail; + } + + /** + * Parse raw RFC 5322 email into structured text/html body and attachments + */ + async parseEmail(rawEmail: string): Promise { + const parsed = await simpleParser(rawEmail); + + return { + text: parsed.text || null, + html: typeof parsed.html === 'string' ? parsed.html : null, + attachments: (parsed.attachments || []).map((att) => ({ + filename: att.filename || 'unnamed', + contentType: att.contentType || 'application/octet-stream', + content: att.content, + size: att.size, + })), + }; + } +} diff --git a/backend/src/features/email-ingestion/index.ts b/backend/src/features/email-ingestion/index.ts new file mode 100644 index 0000000..305ce0b --- /dev/null +++ b/backend/src/features/email-ingestion/index.ts @@ -0,0 +1,22 @@ +/** + * @ai-summary Email ingestion feature barrel export + * @ai-context Exports webhook routes, services, and types for Resend inbound email processing + */ + +export { emailIngestionWebhookRoutes, emailIngestionRoutes } from './api/email-ingestion.routes'; +export { EmailIngestionService } from './domain/email-ingestion.service'; +export { EmailIngestionRepository } from './data/email-ingestion.repository'; +export { ReceiptClassifier } from './domain/receipt-classifier'; +export { ResendInboundClient } from './external/resend-inbound.client'; +export type { ParsedEmailResult, ParsedEmailAttachment } from './external/resend-inbound.client'; +export type { + ClassificationResult, + EmailIngestionQueueRecord, + EmailIngestionStatus, + EmailProcessingResult, + ExtractedReceiptData, + PendingVehicleAssociation, + ReceiptClassificationType, + ResendWebhookEvent, + ResendWebhookEventData, +} from './domain/email-ingestion.types'; 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..40d6fcf --- /dev/null +++ b/backend/src/features/email-ingestion/migrations/002_create_email_templates.sql @@ -0,0 +1,233 @@ +/** + * 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}} +Merchant: {{merchantName}} +Date: {{date}} +Amount: ${{totalAmount}} + +The record has been added to your vehicle history. + +Best regards, +MotoVaultPro Team', + '["userName", "vehicleName", "recordType", "merchantName", "totalAmount", "date"]', + ' + + + + + Receipt Processed + + + + + + +
+ + + + + + + + + + +
+

Receipt Processed

+
+

Hi {{userName}},

+

Your emailed receipt has been successfully processed.

+ + + + +
+

Vehicle: {{vehicleName}}

+

Record Type: {{recordType}}

+

Merchant: {{merchantName}}

+

Date: {{date}}

+

Amount: ${{totalAmount}}

+
+

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. + +Error: {{errorReason}} + +{{guidance}} + +Best regards, +MotoVaultPro Team', + '["userName", "errorReason", "guidance"]', + ' + + + + + Receipt Processing Failed + + + + + + +
+ + + + + + + + + + +
+

Processing Failed

+
+

Hi {{userName}},

+

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

+ + + + +
+

Error: {{errorReason}}

+
+
+

What to do next:

+

{{guidance}}

+
+
+

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}} +Merchant: {{merchantName}} +Date: {{date}} +Amount: ${{totalAmount}} + +You can find the pending receipt in your notifications. + +Best regards, +MotoVaultPro Team', + '["userName", "recordType", "merchantName", "totalAmount", "date"]', + ' + + + + + 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}}

+

Merchant: {{merchantName}}

+

Date: {{date}}

+

Amount: ${{totalAmount}}

+
+

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(); diff --git a/backend/src/features/maintenance/data/maintenance.repository.ts b/backend/src/features/maintenance/data/maintenance.repository.ts index 24758e6..52c948e 100644 --- a/backend/src/features/maintenance/data/maintenance.repository.ts +++ b/backend/src/features/maintenance/data/maintenance.repository.ts @@ -21,6 +21,7 @@ export class MaintenanceRepository { cost: row.cost, shopName: row.shop_name, notes: row.notes, + receiptDocumentId: row.receipt_document_id, createdAt: row.created_at, updatedAt: row.updated_at }; @@ -66,11 +67,12 @@ export class MaintenanceRepository { cost?: number | null; shopName?: string | null; notes?: string | null; + receiptDocumentId?: string | null; }): Promise { const res = await this.db.query( `INSERT INTO maintenance_records ( - id, user_id, vehicle_id, category, subtypes, date, odometer_reading, cost, shop_name, notes - ) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10) + id, user_id, vehicle_id, category, subtypes, date, odometer_reading, cost, shop_name, notes, receipt_document_id + ) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11) RETURNING *`, [ record.id, @@ -83,6 +85,7 @@ export class MaintenanceRepository { record.cost ?? null, record.shopName ?? null, record.notes ?? null, + record.receiptDocumentId ?? null, ] ); return this.mapMaintenanceRecord(res.rows[0]); @@ -96,6 +99,26 @@ export class MaintenanceRepository { return res.rows[0] ? this.mapMaintenanceRecord(res.rows[0]) : null; } + async findRecordByIdWithDocument(id: string, userId: string): Promise<{ record: MaintenanceRecord; receiptDocument: { documentId: string; fileName: string; contentType: string; storageKey: string } | null } | null> { + const res = await this.db.query( + `SELECT mr.*, d.id AS doc_id, d.file_name AS doc_file_name, d.content_type AS doc_content_type, d.storage_key AS doc_storage_key + FROM maintenance_records mr + LEFT JOIN documents d ON mr.receipt_document_id = d.id + WHERE mr.id = $1 AND mr.user_id = $2`, + [id, userId] + ); + if (!res.rows[0]) return null; + const row = res.rows[0]; + const record = this.mapMaintenanceRecord(row); + const receiptDocument = row.doc_id ? { + documentId: row.doc_id, + fileName: row.doc_file_name, + contentType: row.doc_content_type, + storageKey: row.doc_storage_key, + } : null; + return { record, receiptDocument }; + } + async findRecordsByUserId( userId: string, filters?: { vehicleId?: string; category?: MaintenanceCategory } diff --git a/backend/src/features/maintenance/domain/maintenance.service.ts b/backend/src/features/maintenance/domain/maintenance.service.ts index da04b7e..0c21751 100644 --- a/backend/src/features/maintenance/domain/maintenance.service.ts +++ b/backend/src/features/maintenance/domain/maintenance.service.ts @@ -10,7 +10,8 @@ import type { MaintenanceScheduleResponse, MaintenanceCategory, ScheduleType, - MaintenanceCostStats + MaintenanceCostStats, + ReceiptDocumentMeta } from './maintenance.types'; import { validateSubtypes } from './maintenance.types'; import { MaintenanceRepository } from '../data/maintenance.repository'; @@ -40,6 +41,7 @@ export class MaintenanceService { cost: body.cost, shopName: body.shopName, notes: body.notes, + receiptDocumentId: body.receiptDocumentId, }); // Auto-link: Find and update matching 'time_since_last' schedules @@ -49,9 +51,9 @@ export class MaintenanceService { } async getRecord(userId: string, id: string): Promise { - const record = await this.repo.findRecordById(id, userId); - if (!record) return null; - return this.toRecordResponse(record); + const result = await this.repo.findRecordByIdWithDocument(id, userId); + if (!result) return null; + return this.toRecordResponse(result.record, result.receiptDocument); } async getRecords(userId: string, filters?: { vehicleId?: string; category?: MaintenanceCategory }): Promise { @@ -272,10 +274,11 @@ export class MaintenanceService { return { nextDueDate, nextDueMileage }; } - private toRecordResponse(record: MaintenanceRecord): MaintenanceRecordResponse { + private toRecordResponse(record: MaintenanceRecord, receiptDocument?: ReceiptDocumentMeta | null): MaintenanceRecordResponse { return { ...record, subtypeCount: record.subtypes.length, + receiptDocument: receiptDocument ?? null, }; } diff --git a/backend/src/features/maintenance/domain/maintenance.types.ts b/backend/src/features/maintenance/domain/maintenance.types.ts index ce85ac1..6b3d3be 100644 --- a/backend/src/features/maintenance/domain/maintenance.types.ts +++ b/backend/src/features/maintenance/domain/maintenance.types.ts @@ -68,6 +68,7 @@ export interface MaintenanceRecord { cost?: number; shopName?: string; notes?: string; + receiptDocumentId?: string | null; createdAt: string; updatedAt: string; } @@ -113,6 +114,7 @@ export const CreateMaintenanceRecordSchema = z.object({ cost: z.number().positive().optional(), shopName: z.string().max(200).optional(), notes: z.string().max(10000).optional(), + receiptDocumentId: z.string().uuid().optional(), }); export type CreateMaintenanceRecordRequest = z.infer; @@ -157,9 +159,18 @@ export const UpdateScheduleSchema = z.object({ }); export type UpdateScheduleRequest = z.infer; +// Receipt document metadata returned on GET +export interface ReceiptDocumentMeta { + documentId: string; + fileName: string; + contentType: string; + storageKey: string; +} + // Response types export interface MaintenanceRecordResponse extends MaintenanceRecord { subtypeCount: number; + receiptDocument?: ReceiptDocumentMeta | null; } // TCO aggregation stats diff --git a/backend/src/features/maintenance/migrations/004_add_receipt_document_id.sql b/backend/src/features/maintenance/migrations/004_add_receipt_document_id.sql new file mode 100644 index 0000000..36476bc --- /dev/null +++ b/backend/src/features/maintenance/migrations/004_add_receipt_document_id.sql @@ -0,0 +1,7 @@ +-- Add receipt_document_id FK to link maintenance records to scanned receipt documents +ALTER TABLE maintenance_records + ADD COLUMN receipt_document_id UUID REFERENCES documents(id) ON DELETE SET NULL; + +-- Index for querying records by receipt document +CREATE INDEX idx_maintenance_records_receipt_document_id ON maintenance_records(receipt_document_id) + WHERE receipt_document_id IS NOT NULL; diff --git a/backend/src/features/notifications/domain/notifications.service.ts b/backend/src/features/notifications/domain/notifications.service.ts index 7c1684c..38e4ac2 100644 --- a/backend/src/features/notifications/domain/notifications.service.ts +++ b/backend/src/features/notifications/domain/notifications.service.ts @@ -445,6 +445,29 @@ export class NotificationsService { reason: 'Subscription upgrade', additionalInfo: 'You now have access to all the features included in the Pro tier. Enjoy your enhanced MotoVaultPro experience!', }; + case 'receipt_processed': + return { + ...baseVariables, + vehicleName: '2024 Toyota Camry', + recordType: 'Fuel Log', + merchantName: 'Shell Gas Station', + totalAmount: '45.50', + date: new Date().toLocaleDateString(), + }; + case 'receipt_failed': + return { + ...baseVariables, + errorReason: 'Unable to extract receipt data from the attached image.', + guidance: 'Please try again with a clearer image or PDF. Tips: use a well-lit, high-contrast photo; ensure text is legible and not cut off; PDF receipts work best.', + }; + case 'receipt_pending_vehicle': + return { + ...baseVariables, + recordType: 'Maintenance Record', + merchantName: 'AutoZone', + totalAmount: '89.99', + date: new Date().toLocaleDateString(), + }; default: return baseVariables; } diff --git a/backend/src/features/notifications/domain/notifications.types.ts b/backend/src/features/notifications/domain/notifications.types.ts index 3f419c8..55eef3f 100644 --- a/backend/src/features/notifications/domain/notifications.types.ts +++ b/backend/src/features/notifications/domain/notifications.types.ts @@ -11,7 +11,10 @@ export type TemplateKey = | 'maintenance_overdue' | 'document_expiring' | 'document_expired' - | 'subscription_tier_change'; + | 'subscription_tier_change' + | 'receipt_processed' + | 'receipt_failed' + | 'receipt_pending_vehicle'; // Email template API response type (camelCase for frontend) export interface EmailTemplate { @@ -86,7 +89,10 @@ export const TemplateKeySchema = z.enum([ 'maintenance_overdue', 'document_expiring', 'document_expired', - 'subscription_tier_change' + 'subscription_tier_change', + 'receipt_processed', + 'receipt_failed', + 'receipt_pending_vehicle' ]); export const UpdateEmailTemplateSchema = z.object({ diff --git a/backend/src/features/ocr/api/ocr.controller.ts b/backend/src/features/ocr/api/ocr.controller.ts index c511bab..4da998c 100644 --- a/backend/src/features/ocr/api/ocr.controller.ts +++ b/backend/src/features/ocr/api/ocr.controller.ts @@ -342,6 +342,114 @@ export class OcrController { } } + /** + * POST /api/ocr/extract/maintenance-receipt + * Extract data from a maintenance receipt image using maintenance-specific OCR. + * Requires Pro tier (maintenance.receiptScan). + */ + async extractMaintenanceReceipt( + request: FastifyRequest, + reply: FastifyReply + ) { + const userId = (request as any).user?.sub as string; + + logger.info('Maintenance receipt extract requested', { + operation: 'ocr.controller.extractMaintenanceReceipt', + userId, + }); + + const file = await (request as any).file({ limits: { files: 1 } }); + if (!file) { + logger.warn('No file provided for maintenance receipt extraction', { + operation: 'ocr.controller.extractMaintenanceReceipt.no_file', + userId, + }); + return reply.code(400).send({ + error: 'Bad Request', + message: 'No file provided', + }); + } + + const contentType = file.mimetype as string; + if (!SUPPORTED_IMAGE_TYPES.has(contentType)) { + logger.warn('Unsupported file type for maintenance receipt extraction', { + operation: 'ocr.controller.extractMaintenanceReceipt.unsupported_type', + userId, + contentType, + fileName: file.filename, + }); + return reply.code(415).send({ + error: 'Unsupported Media Type', + message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC`, + }); + } + + const chunks: Buffer[] = []; + for await (const chunk of file.file) { + chunks.push(chunk); + } + const fileBuffer = Buffer.concat(chunks); + + if (fileBuffer.length === 0) { + logger.warn('Empty file provided for maintenance receipt extraction', { + operation: 'ocr.controller.extractMaintenanceReceipt.empty_file', + userId, + fileName: file.filename, + }); + return reply.code(400).send({ + error: 'Bad Request', + message: 'Empty file provided', + }); + } + + try { + const result = await ocrService.extractMaintenanceReceipt(userId, { + fileBuffer, + contentType, + }); + + logger.info('Maintenance receipt extract completed', { + operation: 'ocr.controller.extractMaintenanceReceipt.success', + userId, + success: result.success, + receiptType: result.receiptType, + processingTimeMs: result.processingTimeMs, + }); + + return reply.code(200).send(result); + } catch (error: any) { + if (error.statusCode === 413) { + return reply.code(413).send({ + error: 'Payload Too Large', + message: error.message, + }); + } + if (error.statusCode === 415) { + return reply.code(415).send({ + error: 'Unsupported Media Type', + message: error.message, + }); + } + if (error.statusCode === 422) { + return reply.code(422).send({ + error: 'Unprocessable Entity', + message: error.message, + }); + } + + logger.error('Maintenance receipt extract failed', { + operation: 'ocr.controller.extractMaintenanceReceipt.error', + userId, + error: error.message, + }); + + return reply.code(500).send({ + error: 'Internal Server Error', + message: 'Maintenance receipt extraction failed', + }); + } + } + /** * POST /api/ocr/extract/manual * Submit an async manual extraction job for PDF owner's manuals. diff --git a/backend/src/features/ocr/api/ocr.routes.ts b/backend/src/features/ocr/api/ocr.routes.ts index 7144671..addd215 100644 --- a/backend/src/features/ocr/api/ocr.routes.ts +++ b/backend/src/features/ocr/api/ocr.routes.ts @@ -30,6 +30,12 @@ export const ocrRoutes: FastifyPluginAsync = async ( handler: ctrl.extractReceipt.bind(ctrl), }); + // POST /api/ocr/extract/maintenance-receipt - Maintenance receipt OCR extraction (Pro tier required) + fastify.post('/ocr/extract/maintenance-receipt', { + preHandler: [requireAuth, requireTier('maintenance.receiptScan')], + handler: ctrl.extractMaintenanceReceipt.bind(ctrl), + }); + // POST /api/ocr/extract/manual - Manual extraction (Pro tier required) fastify.post('/ocr/extract/manual', { preHandler: [requireAuth, fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' })], diff --git a/backend/src/features/ocr/domain/ocr.service.ts b/backend/src/features/ocr/domain/ocr.service.ts index 567361b..30ef7e6 100644 --- a/backend/src/features/ocr/domain/ocr.service.ts +++ b/backend/src/features/ocr/domain/ocr.service.ts @@ -5,6 +5,7 @@ import { logger } from '../../../core/logging/logger'; import { ocrClient, JobNotFoundError } from '../external/ocr-client'; import type { JobResponse, + MaintenanceReceiptExtractRequest, ManualJobResponse, ManualJobSubmitRequest, OcrExtractRequest, @@ -221,6 +222,63 @@ export class OcrService { } } + /** + * Extract data from a maintenance receipt image using maintenance-specific OCR. + * + * @param userId - User ID for logging + * @param request - Maintenance receipt extraction request + * @returns Receipt extraction result + */ + async extractMaintenanceReceipt(userId: string, request: MaintenanceReceiptExtractRequest): Promise { + if (request.fileBuffer.length > MAX_SYNC_SIZE) { + const err: any = new Error( + `File too large. Max: ${MAX_SYNC_SIZE / (1024 * 1024)}MB.` + ); + err.statusCode = 413; + throw err; + } + + if (!SUPPORTED_IMAGE_TYPES.has(request.contentType)) { + const err: any = new Error( + `Unsupported file type: ${request.contentType}. Supported: ${[...SUPPORTED_IMAGE_TYPES].join(', ')}` + ); + err.statusCode = 415; + throw err; + } + + logger.info('Maintenance receipt extract requested', { + operation: 'ocr.service.extractMaintenanceReceipt', + userId, + contentType: request.contentType, + fileSize: request.fileBuffer.length, + }); + + try { + const result = await ocrClient.extractMaintenanceReceipt( + request.fileBuffer, + request.contentType + ); + + logger.info('Maintenance receipt extract completed', { + operation: 'ocr.service.extractMaintenanceReceipt.success', + userId, + success: result.success, + receiptType: result.receiptType, + fieldCount: Object.keys(result.extractedFields).length, + processingTimeMs: result.processingTimeMs, + }); + + return result; + } catch (error) { + logger.error('Maintenance receipt extract failed', { + operation: 'ocr.service.extractMaintenanceReceipt.error', + userId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + throw error; + } + } + /** * Submit an async OCR job for large files. * diff --git a/backend/src/features/ocr/domain/ocr.types.ts b/backend/src/features/ocr/domain/ocr.types.ts index 9209962..2f00b4c 100644 --- a/backend/src/features/ocr/domain/ocr.types.ts +++ b/backend/src/features/ocr/domain/ocr.types.ts @@ -62,6 +62,12 @@ export interface ReceiptExtractRequest { receiptType?: string; } +/** Request for maintenance receipt extraction */ +export interface MaintenanceReceiptExtractRequest { + fileBuffer: Buffer; + contentType: string; +} + /** Response from VIN-specific extraction */ export interface VinExtractionResponse { success: boolean; diff --git a/backend/src/features/ocr/external/ocr-client.ts b/backend/src/features/ocr/external/ocr-client.ts index d8fa5e2..627abf7 100644 --- a/backend/src/features/ocr/external/ocr-client.ts +++ b/backend/src/features/ocr/external/ocr-client.ts @@ -177,6 +177,57 @@ export class OcrClient { return result; } + /** + * Extract data from a maintenance receipt image using maintenance-specific OCR. + * + * @param fileBuffer - Image file buffer + * @param contentType - MIME type of the file + * @returns Receipt extraction result (receiptType: "maintenance") + */ + async extractMaintenanceReceipt( + fileBuffer: Buffer, + contentType: string + ): Promise { + const formData = this.buildFormData(fileBuffer, contentType); + const url = `${this.baseUrl}/extract/maintenance-receipt`; + + logger.info('OCR maintenance receipt extract request', { + operation: 'ocr.client.extractMaintenanceReceipt', + url, + contentType, + fileSize: fileBuffer.length, + }); + + const response = await this.fetchWithTimeout(url, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('OCR maintenance receipt extract failed', { + operation: 'ocr.client.extractMaintenanceReceipt.error', + status: response.status, + error: errorText, + }); + const err: any = new Error(`OCR service error: ${response.status} - ${errorText}`); + err.statusCode = response.status; + throw err; + } + + const result = (await response.json()) as ReceiptExtractionResponse; + + logger.info('OCR maintenance receipt extract completed', { + operation: 'ocr.client.extractMaintenanceReceipt.success', + success: result.success, + receiptType: result.receiptType, + fieldCount: Object.keys(result.extractedFields).length, + processingTimeMs: result.processingTimeMs, + }); + + return result; + } + /** * Submit an async OCR job for large files. * diff --git a/frontend/src/features/dashboard/components/DashboardScreen.tsx b/frontend/src/features/dashboard/components/DashboardScreen.tsx index ae7d827..e9444c4 100644 --- a/frontend/src/features/dashboard/components/DashboardScreen.tsx +++ b/frontend/src/features/dashboard/components/DashboardScreen.tsx @@ -2,16 +2,19 @@ * @ai-summary Main dashboard screen component showing fleet overview */ -import React from 'react'; -import { Box } from '@mui/material'; +import React, { useState } from 'react'; +import { Box, Dialog, DialogTitle, DialogContent, IconButton, useMediaQuery, useTheme } from '@mui/material'; import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; +import CloseIcon from '@mui/icons-material/Close'; import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards'; import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention'; import { QuickActions, QuickActionsSkeleton } from './QuickActions'; import { useDashboardSummary, useVehiclesNeedingAttention } from '../hooks/useDashboardData'; import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; import { Button } from '../../../shared-minimal/components/Button'; +import { PendingAssociationBanner } from '../../email-ingestion/components/PendingAssociationBanner'; +import { PendingAssociationList } from '../../email-ingestion/components/PendingAssociationList'; import { MobileScreen } from '../../../core/store'; import { Vehicle } from '../../vehicles/types/vehicles.types'; @@ -29,6 +32,9 @@ export const DashboardScreen: React.FC = ({ onViewMaintenance, onAddVehicle }) => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down('sm')); + const [showPendingReceipts, setShowPendingReceipts] = useState(false); const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary(); const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention(); @@ -102,6 +108,9 @@ export const DashboardScreen: React.FC = ({ // Main dashboard view return (
+ {/* Pending Receipts Banner */} + setShowPendingReceipts(true)} /> + {/* Summary Cards */} @@ -132,6 +141,35 @@ export const DashboardScreen: React.FC = ({ Dashboard updates every 2 minutes

+ + {/* Pending Receipts Dialog */} + setShowPendingReceipts(false)} + fullScreen={isSmall} + maxWidth="sm" + fullWidth + PaperProps={{ + sx: { + maxHeight: isSmall ? '100%' : '90vh', + m: isSmall ? 0 : 2, + }, + }} + > + + Pending Receipts + setShowPendingReceipts(false)} + sx={{ minWidth: 44, minHeight: 44 }} + > + + + + + + + ); }; diff --git a/frontend/src/features/email-ingestion/api/email-ingestion.api.ts b/frontend/src/features/email-ingestion/api/email-ingestion.api.ts new file mode 100644 index 0000000..fac197f --- /dev/null +++ b/frontend/src/features/email-ingestion/api/email-ingestion.api.ts @@ -0,0 +1,33 @@ +/** + * @ai-summary API calls for email ingestion pending associations + */ + +import { apiClient } from '../../../core/api/client'; +import type { + PendingVehicleAssociation, + PendingAssociationCount, + ResolveAssociationResult, +} from '../types/email-ingestion.types'; + +export const emailIngestionApi = { + getPending: async (): Promise => { + const response = await apiClient.get('/email-ingestion/pending'); + return response.data; + }, + + getPendingCount: async (): Promise => { + const response = await apiClient.get('/email-ingestion/pending/count'); + return response.data; + }, + + resolve: async (associationId: string, vehicleId: string): Promise => { + const response = await apiClient.post(`/email-ingestion/pending/${associationId}/resolve`, { + vehicleId, + }); + return response.data; + }, + + dismiss: async (associationId: string): Promise => { + await apiClient.delete(`/email-ingestion/pending/${associationId}`); + }, +}; diff --git a/frontend/src/features/email-ingestion/components/PendingAssociationBanner.tsx b/frontend/src/features/email-ingestion/components/PendingAssociationBanner.tsx new file mode 100644 index 0000000..38aaa34 --- /dev/null +++ b/frontend/src/features/email-ingestion/components/PendingAssociationBanner.tsx @@ -0,0 +1,76 @@ +/** + * @ai-summary Banner shown on dashboard when user has pending vehicle associations from emailed receipts + */ + +import React from 'react'; +import { Box } from '@mui/material'; +import EmailRoundedIcon from '@mui/icons-material/EmailRounded'; +import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; +import { Button } from '../../../shared-minimal/components/Button'; +import { usePendingAssociationCount } from '../hooks/usePendingAssociations'; + +interface PendingAssociationBannerProps { + onViewPending: () => void; +} + +export const PendingAssociationBanner: React.FC = ({ onViewPending }) => { + const { data, isLoading } = usePendingAssociationCount(); + + if (isLoading || !data || data.count === 0) { + return null; + } + + const count = data.count; + const label = count === 1 ? '1 emailed receipt' : `${count} emailed receipts`; + + return ( + + + + + + +
+ + Pending Receipts + +

+ {label} need a vehicle assigned +

+
+ + + + +
+
+ ); +}; diff --git a/frontend/src/features/email-ingestion/components/PendingAssociationList.tsx b/frontend/src/features/email-ingestion/components/PendingAssociationList.tsx new file mode 100644 index 0000000..68c9659 --- /dev/null +++ b/frontend/src/features/email-ingestion/components/PendingAssociationList.tsx @@ -0,0 +1,210 @@ +/** + * @ai-summary List of pending vehicle associations with receipt details and action buttons + */ + +import React, { useState } from 'react'; +import { Box } from '@mui/material'; +import EmailRoundedIcon from '@mui/icons-material/EmailRounded'; +import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; +import BuildRoundedIcon from '@mui/icons-material/BuildRounded'; +import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded'; +import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard'; +import { Button } from '../../../shared-minimal/components/Button'; +import { usePendingAssociations, useDismissAssociation } from '../hooks/usePendingAssociations'; +import { ResolveAssociationDialog } from './ResolveAssociationDialog'; +import type { PendingVehicleAssociation } from '../types/email-ingestion.types'; + +export const PendingAssociationList: React.FC = () => { + const { data: associations, isLoading, error } = usePendingAssociations(); + const dismissMutation = useDismissAssociation(); + const [resolving, setResolving] = useState(null); + + if (isLoading) { + return ; + } + + if (error) { + return ( + +
+

Failed to load pending receipts

+
+
+ ); + } + + if (!associations || associations.length === 0) { + return ( + +
+ + + +

+ No Pending Receipts +

+

+ All emailed receipts have been assigned to vehicles +

+
+
+ ); + } + + const formatDate = (dateStr: string | null): string => { + if (!dateStr) return 'Unknown date'; + try { + return new Date(dateStr).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } catch { + return dateStr; + } + }; + + const formatCurrency = (amount: number | null): string => { + if (amount === null || amount === undefined) return ''; + return `$${amount.toFixed(2)}`; + }; + + return ( + <> + +
+

+ Pending Receipts +

+

+ Assign a vehicle to each emailed receipt +

+
+ +
+ {associations.map((association) => { + const isFuel = association.recordType === 'fuel_log'; + const IconComponent = isFuel ? LocalGasStationRoundedIcon : BuildRoundedIcon; + const typeLabel = isFuel ? 'Fuel Receipt' : 'Maintenance Receipt'; + const { extractedData } = association; + const merchant = extractedData.vendor || extractedData.shopName || 'Unknown merchant'; + + return ( + +
+ + + + +
+ + {merchant} + + +
+ {typeLabel} + {formatDate(extractedData.date)} + {extractedData.total != null && ( + {formatCurrency(extractedData.total)} + )} +
+ + {isFuel && extractedData.gallons != null && ( +

+ {extractedData.gallons} gal + {extractedData.pricePerGallon != null && ` @ $${extractedData.pricePerGallon.toFixed(3)}/gal`} +

+ )} + {!isFuel && extractedData.category && ( +

+ {extractedData.category} + {extractedData.description && ` - ${extractedData.description}`} +

+ )} + +

+ Received {formatDate(association.createdAt)} +

+ +
+ + +
+
+
+
+ ); + })} +
+
+ + {resolving && ( + setResolving(null)} + /> + )} + + ); +}; + +const PendingAssociationListSkeleton: React.FC = () => ( + +
+
+
+
+
+ {[1, 2].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ +); diff --git a/frontend/src/features/email-ingestion/components/ResolveAssociationDialog.tsx b/frontend/src/features/email-ingestion/components/ResolveAssociationDialog.tsx new file mode 100644 index 0000000..0c6425a --- /dev/null +++ b/frontend/src/features/email-ingestion/components/ResolveAssociationDialog.tsx @@ -0,0 +1,260 @@ +/** + * @ai-summary Dialog to select a vehicle and resolve a pending association from an emailed receipt + */ + +import React, { useState } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box, + IconButton, + useMediaQuery, + useTheme, + CircularProgress, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; +import BuildRoundedIcon from '@mui/icons-material/BuildRounded'; +import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded'; +import { useVehicles } from '../../vehicles/hooks/useVehicles'; +import { useResolveAssociation } from '../hooks/usePendingAssociations'; +import type { PendingVehicleAssociation } from '../types/email-ingestion.types'; + +interface ResolveAssociationDialogProps { + open: boolean; + association: PendingVehicleAssociation; + onClose: () => void; +} + +export const ResolveAssociationDialog: React.FC = ({ + open, + association, + onClose, +}) => { + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down('sm')); + const { data: vehicles, isLoading: vehiclesLoading } = useVehicles(); + const resolveMutation = useResolveAssociation(); + const [selectedVehicleId, setSelectedVehicleId] = useState(null); + + const isFuel = association.recordType === 'fuel_log'; + const { extractedData } = association; + const merchant = extractedData.vendor || extractedData.shopName || 'Unknown merchant'; + + const handleResolve = () => { + if (!selectedVehicleId) return; + resolveMutation.mutate( + { associationId: association.id, vehicleId: selectedVehicleId }, + { onSuccess: () => onClose() } + ); + }; + + const formatDate = (dateStr: string | null): string => { + if (!dateStr) return ''; + try { + return new Date(dateStr).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } catch { + return dateStr; + } + }; + + return ( + + {isSmall && ( + + + + )} + + + {isFuel ? ( + + ) : ( + + )} + Assign Vehicle + + + + + {/* Receipt summary */} + + + {isFuel ? 'Fuel Receipt' : 'Maintenance Receipt'} + + + {merchant} + + + {extractedData.date && ( + + {formatDate(extractedData.date)} + + )} + {extractedData.total != null && ( + + ${extractedData.total.toFixed(2)} + + )} + {isFuel && extractedData.gallons != null && ( + + {extractedData.gallons} gal + + )} + {!isFuel && extractedData.category && ( + + {extractedData.category} + + )} + + + + {/* Vehicle selection */} + + + Select a vehicle + + + {vehiclesLoading ? ( + + + + ) : !vehicles || vehicles.length === 0 ? ( + + No vehicles found. Add a vehicle first. + + ) : ( + + {vehicles.map((vehicle) => { + const isSelected = selectedVehicleId === vehicle.id; + const vehicleName = vehicle.nickname + || [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ') + || 'Unnamed Vehicle'; + + return ( + setSelectedVehicleId(vehicle.id)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelectedVehicleId(vehicle.id); + } + }} + sx={{ + p: 2, + borderRadius: 2, + border: '2px solid', + borderColor: isSelected ? 'primary.main' : 'divider', + bgcolor: isSelected ? 'primary.50' : 'transparent', + cursor: 'pointer', + transition: 'all 0.15s', + display: 'flex', + alignItems: 'center', + gap: 1.5, + minHeight: 44, + '&:hover': { + borderColor: isSelected ? 'primary.main' : 'primary.light', + bgcolor: isSelected ? 'primary.50' : 'action.hover', + }, + }} + > + {isSelected && ( + + )} + + + {vehicleName} + + {vehicle.licensePlate && ( + + {vehicle.licensePlate} + + )} + + + ); + })} + + )} + + + + + + + + + + ); +}; diff --git a/frontend/src/features/email-ingestion/hooks/usePendingAssociations.ts b/frontend/src/features/email-ingestion/hooks/usePendingAssociations.ts new file mode 100644 index 0000000..4451e18 --- /dev/null +++ b/frontend/src/features/email-ingestion/hooks/usePendingAssociations.ts @@ -0,0 +1,65 @@ +/** + * @ai-summary React Query hooks for pending vehicle association management + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAuth0 } from '@auth0/auth0-react'; +import { emailIngestionApi } from '../api/email-ingestion.api'; +import toast from 'react-hot-toast'; + +export const usePendingAssociationCount = () => { + const { isAuthenticated, isLoading } = useAuth0(); + + return useQuery({ + queryKey: ['pendingAssociations', 'count'], + queryFn: emailIngestionApi.getPendingCount, + enabled: isAuthenticated && !isLoading, + staleTime: 60 * 1000, + refetchInterval: 2 * 60 * 1000, + }); +}; + +export const usePendingAssociations = () => { + const { isAuthenticated, isLoading } = useAuth0(); + + return useQuery({ + queryKey: ['pendingAssociations'], + queryFn: emailIngestionApi.getPending, + enabled: isAuthenticated && !isLoading, + staleTime: 30 * 1000, + }); +}; + +export const useResolveAssociation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ associationId, vehicleId }: { associationId: string; vehicleId: string }) => + emailIngestionApi.resolve(associationId, vehicleId), + onSuccess: (_data) => { + queryClient.invalidateQueries({ queryKey: ['pendingAssociations'] }); + queryClient.invalidateQueries({ queryKey: ['fuelLogs'] }); + queryClient.invalidateQueries({ queryKey: ['maintenance'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + toast.success('Receipt assigned to vehicle'); + }, + onError: () => { + toast.error('Failed to assign receipt'); + }, + }); +}; + +export const useDismissAssociation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (associationId: string) => emailIngestionApi.dismiss(associationId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pendingAssociations'] }); + toast.success('Receipt dismissed'); + }, + onError: () => { + toast.error('Failed to dismiss receipt'); + }, + }); +}; diff --git a/frontend/src/features/email-ingestion/index.ts b/frontend/src/features/email-ingestion/index.ts new file mode 100644 index 0000000..0d0f2f5 --- /dev/null +++ b/frontend/src/features/email-ingestion/index.ts @@ -0,0 +1,10 @@ +/** + * @ai-summary Email ingestion feature barrel export + */ + +export { PendingAssociationBanner } from './components/PendingAssociationBanner'; +export { PendingAssociationList } from './components/PendingAssociationList'; +export { ResolveAssociationDialog } from './components/ResolveAssociationDialog'; +export { usePendingAssociationCount, usePendingAssociations, useResolveAssociation, useDismissAssociation } from './hooks/usePendingAssociations'; +export { emailIngestionApi } from './api/email-ingestion.api'; +export type { PendingVehicleAssociation, ExtractedReceiptData, ResolveAssociationResult } from './types/email-ingestion.types'; diff --git a/frontend/src/features/email-ingestion/types/email-ingestion.types.ts b/frontend/src/features/email-ingestion/types/email-ingestion.types.ts new file mode 100644 index 0000000..bfb59e3 --- /dev/null +++ b/frontend/src/features/email-ingestion/types/email-ingestion.types.ts @@ -0,0 +1,41 @@ +/** + * @ai-summary TypeScript types for email ingestion frontend feature + */ + +export type EmailRecordType = 'fuel_log' | 'maintenance_record'; + +export type PendingAssociationStatus = 'pending' | 'resolved' | 'expired'; + +export interface ExtractedReceiptData { + vendor: string | null; + date: string | null; + total: number | null; + odometerReading: number | null; + gallons: number | null; + pricePerGallon: number | null; + fuelType: string | null; + category: string | null; + subtypes: string[] | null; + shopName: string | null; + description: string | null; +} + +export interface PendingVehicleAssociation { + id: string; + userId: string; + recordType: EmailRecordType; + extractedData: ExtractedReceiptData; + documentId: string | null; + status: PendingAssociationStatus; + createdAt: string; + resolvedAt: string | null; +} + +export interface PendingAssociationCount { + count: number; +} + +export interface ResolveAssociationResult { + recordId: string; + recordType: EmailRecordType; +} diff --git a/frontend/src/features/maintenance/components/MaintenanceReceiptReviewModal.tsx b/frontend/src/features/maintenance/components/MaintenanceReceiptReviewModal.tsx new file mode 100644 index 0000000..b504074 --- /dev/null +++ b/frontend/src/features/maintenance/components/MaintenanceReceiptReviewModal.tsx @@ -0,0 +1,427 @@ +/** + * @ai-summary Modal for reviewing and editing OCR-extracted maintenance receipt fields + * @ai-context Mirrors ReceiptOcrReviewModal: confidence indicators, inline editing, category suggestion display + */ + +import React, { useState } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, + TextField, + Grid, + useTheme, + useMediaQuery, + IconButton, + Collapse, + Alert, + Chip, +} from '@mui/material'; +import EditIcon from '@mui/icons-material/Edit'; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; +import CameraAltIcon from '@mui/icons-material/CameraAlt'; +import BuildIcon from '@mui/icons-material/Build'; +import { + ExtractedMaintenanceReceiptFields, + ExtractedMaintenanceField, + CategorySuggestion, +} from '../types/maintenance-receipt.types'; +import { LOW_CONFIDENCE_THRESHOLD } from '../hooks/useMaintenanceReceiptOcr'; +import { getCategoryDisplayName } from '../types/maintenance.types'; +import { ReceiptPreview } from '../../fuel-logs/components/ReceiptPreview'; + +export interface MaintenanceReceiptReviewModalProps { + open: boolean; + extractedFields: ExtractedMaintenanceReceiptFields; + receiptImageUrl: string | null; + categorySuggestion: CategorySuggestion | null; + onAccept: () => void; + onRetake: () => void; + onCancel: () => void; + onFieldEdit: (fieldName: keyof ExtractedMaintenanceReceiptFields, value: string | number | null) => void; +} + +/** Confidence indicator component (4-dot system) */ +const ConfidenceIndicator: React.FC<{ confidence: number }> = ({ confidence }) => { + const filledDots = Math.round(confidence * 4); + const isLow = confidence < LOW_CONFIDENCE_THRESHOLD; + + return ( + + {[0, 1, 2, 3].map((i) => ( + + ))} + + ); +}; + +/** Field row component with inline editing */ +const FieldRow: React.FC<{ + label: string; + field: ExtractedMaintenanceField; + onEdit: (value: string | number | null) => void; + type?: 'text' | 'number'; + formatDisplay?: (value: string | number | null) => string; +}> = ({ label, field, onEdit, type = 'text', formatDisplay }) => { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState( + field.value !== null ? String(field.value) : '' + ); + const isLowConfidence = field.confidence < LOW_CONFIDENCE_THRESHOLD && field.value !== null; + + const displayValue = formatDisplay + ? formatDisplay(field.value) + : field.value !== null + ? String(field.value) + : '-'; + + const handleSave = () => { + let parsedValue: string | number | null = editValue || null; + if (type === 'number' && editValue) { + const num = parseFloat(editValue); + parsedValue = isNaN(num) ? null : num; + } + onEdit(parsedValue); + setIsEditing(false); + }; + + const handleCancel = () => { + setEditValue(field.value !== null ? String(field.value) : ''); + setIsEditing(false); + }; + + return ( + + + {label} + + + {isEditing ? ( + + setEditValue(e.target.value)} + type={type === 'number' ? 'number' : 'text'} + inputProps={{ + step: type === 'number' ? 0.01 : undefined, + }} + autoFocus + sx={{ flex: 1 }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') handleCancel(); + }} + /> + + + + + + + + ) : ( + setIsEditing(true)} + role="button" + tabIndex={0} + aria-label={`Edit ${label}`} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsEditing(true); + } + }} + > + + {displayValue} + + {field.value !== null && } + + + + + )} + + ); +}; + +export const MaintenanceReceiptReviewModal: React.FC = ({ + open, + extractedFields, + receiptImageUrl, + categorySuggestion, + onAccept, + onRetake, + onCancel, + onFieldEdit, +}) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const [showAllFields, setShowAllFields] = useState(false); + + const hasLowConfidenceFields = Object.values(extractedFields).some( + (field) => field.value !== null && field.confidence < LOW_CONFIDENCE_THRESHOLD + ); + + const formatCurrency = (value: string | number | null): string => { + if (value === null) return '-'; + const num = typeof value === 'string' ? parseFloat(value) : value; + return isNaN(num) ? String(value) : `$${num.toFixed(2)}`; + }; + + const formatDate = (value: string | number | null): string => { + if (value === null) return '-'; + const dateStr = String(value); + try { + const date = new Date(dateStr + 'T00:00:00'); + if (!isNaN(date.getTime())) { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + } catch { + // Return as-is if parsing fails + } + return dateStr; + }; + + return ( + + + + Maintenance Receipt Extracted + + + + + + + + {hasLowConfidenceFields && ( + + Some fields have low confidence. Please review and edit if needed. + + )} + + + {/* Receipt thumbnail */} + {receiptImageUrl && ( + + + + + Tap to zoom + + + + )} + + {/* Extracted fields */} + + + {/* Primary fields */} + onFieldEdit('serviceName', value)} + /> + onFieldEdit('serviceDate', value)} + formatDisplay={formatDate} + /> + onFieldEdit('totalCost', value)} + type="number" + formatDisplay={formatCurrency} + /> + onFieldEdit('shopName', value)} + /> + + {/* Category suggestion */} + {categorySuggestion && ( + + + + + {getCategoryDisplayName(categorySuggestion.category)} + + + {categorySuggestion.subtypes.map((subtype) => ( + + ))} + + + + )} + + {/* Secondary fields (collapsible on mobile) */} + + onFieldEdit('odometerReading', value)} + type="number" + /> + onFieldEdit('laborCost', value)} + type="number" + formatDisplay={formatCurrency} + /> + onFieldEdit('partsCost', value)} + type="number" + formatDisplay={formatCurrency} + /> + onFieldEdit('vehicleInfo', value)} + /> + + + {isMobile && ( + + )} + + + + + + Tap any field to edit before saving. + + + + + + + + + + + ); +}; + +export default MaintenanceReceiptReviewModal; diff --git a/frontend/src/features/maintenance/components/MaintenanceRecordEditDialog.tsx b/frontend/src/features/maintenance/components/MaintenanceRecordEditDialog.tsx index ff95c0d..5482c47 100644 --- a/frontend/src/features/maintenance/components/MaintenanceRecordEditDialog.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceRecordEditDialog.tsx @@ -1,6 +1,6 @@ /** - * @ai-summary Edit dialog for maintenance records - * @ai-context Mobile-friendly dialog with proper form handling + * @ai-summary Edit dialog for maintenance records with linked receipt display + * @ai-context Mobile-friendly dialog with proper form handling and receipt thumbnail/view */ import React, { useState, useEffect } from 'react'; @@ -19,7 +19,9 @@ import { MenuItem, Typography, useMediaQuery, + useTheme, } from '@mui/material'; +import ReceiptIcon from '@mui/icons-material/Receipt'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { DatePicker } from '@mui/x-date-pickers/DatePicker'; @@ -32,6 +34,7 @@ import { } from '../types/maintenance.types'; import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup'; import { useVehicles } from '../../vehicles/hooks/useVehicles'; +import { documentsApi } from '../../documents/api/documents.api'; import type { Vehicle } from '../../vehicles/types/vehicles.types'; interface MaintenanceRecordEditDialogProps { @@ -53,7 +56,10 @@ export const MaintenanceRecordEditDialog: React.FC(null); // Reset form when record changes useEffect(() => { @@ -76,6 +82,45 @@ export const MaintenanceRecordEditDialog: React.FC { + if (!record?.receiptDocument?.documentId) { + setReceiptThumbnailUrl(null); + return; + } + + let revoked = false; + documentsApi.download(record.receiptDocument.documentId).then((blob) => { + if (!revoked) { + const url = URL.createObjectURL(blob); + setReceiptThumbnailUrl(url); + } + }).catch((err) => { + console.error('[MaintenanceRecordEditDialog] Failed to load receipt thumbnail:', err); + }); + + return () => { + revoked = true; + if (receiptThumbnailUrl) { + URL.revokeObjectURL(receiptThumbnailUrl); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [record?.receiptDocument?.documentId]); + + const handleViewReceipt = async () => { + if (!record?.receiptDocument?.documentId) return; + try { + const blob = await documentsApi.download(record.receiptDocument.documentId); + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + // Revoke after a delay to allow the new tab to load + setTimeout(() => URL.revokeObjectURL(url), 10000); + } catch (err) { + console.error('[MaintenanceRecordEditDialog] Failed to open receipt:', err); + } + }; + const handleInputChange = (field: keyof UpdateMaintenanceRecordRequest, value: any) => { setFormData((prev) => ({ ...prev, @@ -182,6 +227,76 @@ export const MaintenanceRecordEditDialog: React.FC + {/* Linked Receipt Display */} + {record.receiptDocument && ( + + + {receiptThumbnailUrl ? ( + + ) : ( + + + + )} + + + Linked Receipt + + {record.receiptDocument.fileName && ( + + {record.receiptDocument.fileName} + + )} + + + + + )} + {/* Category */} diff --git a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx index e3b4343..755373d 100644 --- a/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceRecordForm.tsx @@ -1,6 +1,6 @@ /** - * @ai-summary Form component for creating maintenance records - * @ai-context Mobile-first responsive design with proper validation + * @ai-summary Form component for creating maintenance records with receipt OCR integration + * @ai-context Mobile-first responsive design with tier-gated receipt scanning, mirrors FuelLogForm OCR pattern */ import React, { useState, useEffect } from 'react'; @@ -23,6 +23,8 @@ import { CircularProgress, Typography, InputAdornment, + Dialog, + Backdrop, } from '@mui/material'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; @@ -36,6 +38,13 @@ import { CreateMaintenanceRecordRequest, getCategoryDisplayName, } from '../types/maintenance.types'; +import { useMaintenanceReceiptOcr } from '../hooks/useMaintenanceReceiptOcr'; +import { MaintenanceReceiptReviewModal } from './MaintenanceReceiptReviewModal'; +import { ReceiptCameraButton } from '../../fuel-logs/components/ReceiptCameraButton'; +import { CameraCapture } from '../../../shared/components/CameraCapture'; +import { useTierAccess } from '../../../core/hooks/useTierAccess'; +import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog'; +import { documentsApi } from '../../documents/api/documents.api'; import toast from 'react-hot-toast'; const schema = z.object({ @@ -58,6 +67,29 @@ export const MaintenanceRecordForm: React.FC = () => { const { createRecord, isRecordMutating } = useMaintenanceRecords(); const [selectedCategory, setSelectedCategory] = useState(null); + // Tier access check for receipt scan feature + const { hasAccess } = useTierAccess(); + const hasReceiptScanAccess = hasAccess('maintenance.receiptScan'); + const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); + + // Receipt OCR integration + const { + isCapturing, + isProcessing, + result: ocrResult, + receiptImageUrl, + error: ocrError, + startCapture, + cancelCapture, + processImage, + acceptResult, + reset: resetOcr, + updateField, + } = useMaintenanceReceiptOcr(); + + // Store captured file for document upload on submit + const [capturedReceiptFile, setCapturedReceiptFile] = useState(null); + const { control, handleSubmit, @@ -89,8 +121,69 @@ export const MaintenanceRecordForm: React.FC = () => { } }, [watchedCategory, setValue]); + // Wrap processImage to also save file reference + const handleCaptureImage = async (file: File, croppedFile?: File) => { + setCapturedReceiptFile(croppedFile || file); + await processImage(file, croppedFile); + }; + + // Handle accepting OCR results and populating the form + const handleAcceptOcrResult = () => { + const mappedFields = acceptResult(); + if (!mappedFields) return; + + // Populate form fields from OCR result + if (mappedFields.category) { + setValue('category', mappedFields.category); + setSelectedCategory(mappedFields.category); + } + if (mappedFields.subtypes && mappedFields.subtypes.length > 0) { + setValue('subtypes', mappedFields.subtypes); + } + if (mappedFields.date) { + setValue('date', mappedFields.date); + } + if (mappedFields.cost !== undefined) { + setValue('cost', mappedFields.cost as any); + } + if (mappedFields.shopName) { + setValue('shop_name', mappedFields.shopName); + } + if (mappedFields.odometerReading !== undefined) { + setValue('odometer_reading', mappedFields.odometerReading as any); + } + if (mappedFields.notes) { + setValue('notes', mappedFields.notes); + } + }; + + // Handle retaking photo + const handleRetakePhoto = () => { + resetOcr(); + setCapturedReceiptFile(null); + startCapture(); + }; + const onSubmit = async (data: FormData) => { try { + let receiptDocumentId: string | undefined; + + // Upload receipt as document if we have a captured file + if (capturedReceiptFile) { + try { + const doc = await documentsApi.create({ + vehicleId: data.vehicle_id, + documentType: 'manual', + title: `Maintenance Receipt - ${new Date(data.date).toLocaleDateString()}`, + }); + await documentsApi.upload(doc.id, capturedReceiptFile); + receiptDocumentId = doc.id; + } catch (uploadError) { + console.error('Failed to upload receipt document:', uploadError); + toast.error('Receipt upload failed, but the record will be saved without the receipt.'); + } + } + const payload: CreateMaintenanceRecordRequest = { vehicleId: data.vehicle_id, category: data.category as MaintenanceCategory, @@ -100,6 +193,7 @@ export const MaintenanceRecordForm: React.FC = () => { cost: data.cost ? Number(data.cost) : undefined, shopName: data.shop_name || undefined, notes: data.notes || undefined, + receiptDocumentId, }; await createRecord(payload); @@ -117,6 +211,7 @@ export const MaintenanceRecordForm: React.FC = () => { notes: '', }); setSelectedCategory(null); + setCapturedReceiptFile(null); } catch (error) { console.error('Failed to create maintenance record:', error); toast.error('Failed to add maintenance record'); @@ -140,6 +235,31 @@ export const MaintenanceRecordForm: React.FC = () => { + {/* Receipt Scan Button */} + + { + if (!hasReceiptScanAccess) { + setShowUpgradeDialog(true); + return; + } + startCapture(); + }} + disabled={isProcessing || isRecordMutating} + variant="button" + locked={!hasReceiptScanAccess} + /> + +
{/* Vehicle Selection */} @@ -374,6 +494,89 @@ export const MaintenanceRecordForm: React.FC = () => {
+ + {/* Camera Capture Modal */} + + + + + {/* OCR Processing Overlay */} + theme.zIndex.drawer + 1, + flexDirection: 'column', + gap: 2, + }} + > + + Extracting receipt data... + + + {/* OCR Review Modal */} + {ocrResult && ( + { + resetOcr(); + setCapturedReceiptFile(null); + }} + onFieldEdit={updateField} + /> + )} + + {/* Upgrade Required Dialog for Receipt Scan */} + setShowUpgradeDialog(false)} + /> + + {/* OCR Error Display */} + {ocrError && ( + + + + OCR Error + + + {ocrError} + + + + + + + + )} ); }; diff --git a/frontend/src/features/maintenance/components/MaintenanceRecordsList.tsx b/frontend/src/features/maintenance/components/MaintenanceRecordsList.tsx index 015029c..0f38234 100644 --- a/frontend/src/features/maintenance/components/MaintenanceRecordsList.tsx +++ b/frontend/src/features/maintenance/components/MaintenanceRecordsList.tsx @@ -20,7 +20,7 @@ import { useTheme, useMediaQuery, } from '@mui/material'; -import { Edit, Delete } from '@mui/icons-material'; +import { Edit, Delete, Receipt } from '@mui/icons-material'; import { MaintenanceRecordResponse, getCategoryDisplayName, @@ -136,6 +136,15 @@ export const MaintenanceRecordsList: React.FC = ({ variant="outlined" /> )} + {record.receiptDocument && ( + } + label="Receipt" + size="small" + color="info" + variant="outlined" + /> + )} {record.notes && ( diff --git a/frontend/src/features/maintenance/hooks/useMaintenanceReceiptOcr.ts b/frontend/src/features/maintenance/hooks/useMaintenanceReceiptOcr.ts new file mode 100644 index 0000000..725ff1a --- /dev/null +++ b/frontend/src/features/maintenance/hooks/useMaintenanceReceiptOcr.ts @@ -0,0 +1,333 @@ +/** + * @ai-summary Hook to orchestrate maintenance receipt OCR extraction + * @ai-context Mirrors useReceiptOcr pattern: capture -> OCR -> category suggestion -> review -> accept + */ + +import { useState, useCallback } from 'react'; +import { apiClient } from '../../../core/api/client'; +import { + ExtractedMaintenanceReceiptFields, + ExtractedMaintenanceField, + MappedMaintenanceFields, + MaintenanceReceiptOcrResult, + CategorySuggestion, + UseMaintenanceReceiptOcrReturn, +} from '../types/maintenance-receipt.types'; +import { MaintenanceCategory } from '../types/maintenance.types'; + +/** Confidence threshold for highlighting low-confidence fields */ +export const LOW_CONFIDENCE_THRESHOLD = 0.7; + +/** Keyword-to-category/subtype mapping for service name suggestion */ +const SERVICE_KEYWORD_MAP: Array<{ + keywords: string[]; + category: MaintenanceCategory; + subtypes: string[]; +}> = [ + // Routine maintenance mappings + { keywords: ['oil change', 'oil filter', 'engine oil', 'synthetic oil', 'conventional oil', 'oil & filter'], + category: 'routine_maintenance', subtypes: ['Engine Oil'] }, + { keywords: ['tire rotation', 'tire balance', 'wheel balance', 'tire alignment', 'alignment'], + category: 'routine_maintenance', subtypes: ['Tires'] }, + { keywords: ['brake pad', 'brake rotor', 'brake fluid', 'brake inspection', 'brake service', 'brakes'], + category: 'routine_maintenance', subtypes: ['Brakes and Traction Control'] }, + { keywords: ['air filter', 'engine air filter'], + category: 'routine_maintenance', subtypes: ['Air Filter Element'] }, + { keywords: ['cabin filter', 'cabin air', 'a/c filter'], + category: 'routine_maintenance', subtypes: ['Cabin Air Filter / Purifier'] }, + { keywords: ['spark plug', 'ignition'], + category: 'routine_maintenance', subtypes: ['Spark Plug'] }, + { keywords: ['coolant', 'antifreeze', 'radiator flush', 'cooling system'], + category: 'routine_maintenance', subtypes: ['Coolant'] }, + { keywords: ['transmission fluid', 'trans fluid', 'atf'], + category: 'routine_maintenance', subtypes: ['Fluid - A/T'] }, + { keywords: ['differential fluid', 'diff fluid'], + category: 'routine_maintenance', subtypes: ['Fluid - Differential'] }, + { keywords: ['wiper blade', 'wiper', 'windshield wiper'], + category: 'routine_maintenance', subtypes: ['Wiper Blade'] }, + { keywords: ['washer fluid', 'windshield washer'], + category: 'routine_maintenance', subtypes: ['Washer Fluid'] }, + { keywords: ['drive belt', 'serpentine belt', 'timing belt', 'belt replacement'], + category: 'routine_maintenance', subtypes: ['Drive Belt'] }, + { keywords: ['exhaust', 'muffler', 'catalytic'], + category: 'routine_maintenance', subtypes: ['Exhaust System'] }, + { keywords: ['suspension', 'shock', 'strut', 'ball joint', 'tie rod'], + category: 'routine_maintenance', subtypes: ['Steering and Suspension'] }, + { keywords: ['fuel filter', 'fuel injection', 'fuel system', 'fuel delivery'], + category: 'routine_maintenance', subtypes: ['Fuel Delivery and Air Induction'] }, + { keywords: ['parking brake', 'e-brake', 'emergency brake'], + category: 'routine_maintenance', subtypes: ['Parking Brake System'] }, + // Repair mappings + { keywords: ['engine repair', 'engine rebuild', 'head gasket', 'valve cover'], + category: 'repair', subtypes: ['Engine'] }, + { keywords: ['transmission repair', 'trans rebuild'], + category: 'repair', subtypes: ['Transmission'] }, + { keywords: ['axle', 'cv joint', 'driveshaft', 'drivetrain'], + category: 'repair', subtypes: ['Drivetrain'] }, + { keywords: ['body work', 'dent', 'paint', 'bumper', 'fender'], + category: 'repair', subtypes: ['Exterior'] }, + { keywords: ['upholstery', 'dashboard', 'seat repair', 'interior repair'], + category: 'repair', subtypes: ['Interior'] }, +]; + +/** Suggest category and subtypes from service name using keyword matching */ +function suggestCategory(serviceName: string | number | null): CategorySuggestion | null { + if (!serviceName) return null; + const normalized = String(serviceName).toLowerCase().trim(); + if (!normalized) return null; + + for (const mapping of SERVICE_KEYWORD_MAP) { + for (const keyword of mapping.keywords) { + if (normalized.includes(keyword)) { + return { + category: mapping.category, + subtypes: mapping.subtypes, + confidence: 0.8, + }; + } + } + } + + // No match found - default to routine_maintenance with no subtypes + return null; +} + +/** Parse date string to YYYY-MM-DD format */ +function parseServiceDate(value: string | number | null): string | undefined { + if (!value) return undefined; + + const dateStr = String(value); + + // Already in YYYY-MM-DD format + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + const date = new Date(dateStr + 'T00:00:00'); + if (!isNaN(date.getTime())) return dateStr; + } + + // Try standard parsing + const date = new Date(dateStr); + if (!isNaN(date.getTime())) { + return date.toISOString().split('T')[0]; + } + + // Try MM/DD/YYYY format + const mdyMatch = dateStr.match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/); + if (mdyMatch) { + const [, month, day, year] = mdyMatch; + const parsed = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); + if (!isNaN(parsed.getTime())) { + return parsed.toISOString().split('T')[0]; + } + } + + return undefined; +} + +/** Parse numeric value */ +function parseNumber(value: string | number | null): number | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value === 'number') return value; + + const cleaned = value.replace(/[$,\s]/g, ''); + const num = parseFloat(cleaned); + return isNaN(num) ? undefined : num; +} + +/** Extract maintenance receipt data from image via OCR service */ +async function extractMaintenanceReceiptFromImage(file: File): Promise<{ + extractedFields: ExtractedMaintenanceReceiptFields; + rawText: string; + confidence: number; +}> { + const formData = new FormData(); + formData.append('file', file); + + const response = await apiClient.post('/ocr/extract/maintenance-receipt', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 30000, + }); + + const data = response.data; + + if (!data.success) { + throw new Error('Maintenance receipt OCR extraction failed'); + } + + const fields = data.extractedFields || {}; + + const makeField = (key: string): ExtractedMaintenanceField => ({ + value: fields[key]?.value ?? null, + confidence: fields[key]?.confidence ?? 0, + }); + + const extractedFields: ExtractedMaintenanceReceiptFields = { + serviceName: makeField('serviceName'), + serviceDate: makeField('serviceDate'), + totalCost: makeField('totalCost'), + shopName: makeField('shopName'), + laborCost: makeField('laborCost'), + partsCost: makeField('partsCost'), + odometerReading: makeField('odometerReading'), + vehicleInfo: makeField('vehicleInfo'), + }; + + return { + extractedFields, + rawText: data.rawText || '', + confidence: data.confidence || 0, + }; +} + +/** Map extracted fields to maintenance record form fields */ +function mapFieldsToMaintenanceRecord( + fields: ExtractedMaintenanceReceiptFields, + categorySuggestion: CategorySuggestion | null +): MappedMaintenanceFields { + // Build notes from supplementary fields + const noteParts: string[] = []; + if (fields.laborCost.value !== null) { + noteParts.push(`Labor: $${parseNumber(fields.laborCost.value)?.toFixed(2) ?? fields.laborCost.value}`); + } + if (fields.partsCost.value !== null) { + noteParts.push(`Parts: $${parseNumber(fields.partsCost.value)?.toFixed(2) ?? fields.partsCost.value}`); + } + if (fields.vehicleInfo.value !== null) { + noteParts.push(`Vehicle: ${fields.vehicleInfo.value}`); + } + + return { + date: parseServiceDate(fields.serviceDate.value), + cost: parseNumber(fields.totalCost.value), + shopName: fields.shopName.value ? String(fields.shopName.value) : undefined, + odometerReading: parseNumber(fields.odometerReading.value), + category: categorySuggestion?.category, + subtypes: categorySuggestion?.subtypes, + notes: noteParts.length > 0 ? noteParts.join(' | ') : undefined, + }; +} + +/** + * Hook to orchestrate maintenance receipt photo capture and OCR extraction. + * Mirrors useReceiptOcr pattern: startCapture -> processImage -> review -> acceptResult + */ +export function useMaintenanceReceiptOcr(): UseMaintenanceReceiptOcrReturn { + const [isCapturing, setIsCapturing] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [result, setResult] = useState(null); + const [receiptImageUrl, setReceiptImageUrl] = useState(null); + const [error, setError] = useState(null); + + const startCapture = useCallback(() => { + setIsCapturing(true); + setError(null); + setResult(null); + }, []); + + const cancelCapture = useCallback(() => { + setIsCapturing(false); + setError(null); + }, []); + + const processImage = useCallback(async (file: File, croppedFile?: File) => { + setIsCapturing(false); + setIsProcessing(true); + setError(null); + setResult(null); + + const imageToProcess = croppedFile || file; + const imageUrl = URL.createObjectURL(imageToProcess); + setReceiptImageUrl(imageUrl); + + try { + const { extractedFields, rawText, confidence } = await extractMaintenanceReceiptFromImage(imageToProcess); + + const categorySuggestion = suggestCategory(extractedFields.serviceName.value); + const mappedFields = mapFieldsToMaintenanceRecord(extractedFields, categorySuggestion); + + setResult({ + extractedFields, + mappedFields, + categorySuggestion, + rawText, + overallConfidence: confidence, + }); + } catch (err: any) { + console.error('Maintenance receipt OCR processing failed:', err); + const message = err.response?.data?.message || err.message || 'Failed to process maintenance receipt image'; + setError(message); + URL.revokeObjectURL(imageUrl); + setReceiptImageUrl(null); + } finally { + setIsProcessing(false); + } + }, []); + + const updateField = useCallback(( + fieldName: keyof ExtractedMaintenanceReceiptFields, + value: string | number | null + ) => { + setResult((prev) => { + if (!prev) return null; + + const updatedFields = { + ...prev.extractedFields, + [fieldName]: { + ...prev.extractedFields[fieldName], + value, + confidence: 1.0, // User-edited field has full confidence + }, + }; + + // Re-run category suggestion if service name was edited + const categorySuggestion = fieldName === 'serviceName' + ? suggestCategory(value) + : prev.categorySuggestion; + + return { + ...prev, + extractedFields: updatedFields, + categorySuggestion, + mappedFields: mapFieldsToMaintenanceRecord(updatedFields, categorySuggestion), + }; + }); + }, []); + + const acceptResult = useCallback(() => { + if (!result) return null; + + const mappedFields = result.mappedFields; + + if (receiptImageUrl) { + URL.revokeObjectURL(receiptImageUrl); + } + setResult(null); + setReceiptImageUrl(null); + + return mappedFields; + }, [result, receiptImageUrl]); + + const reset = useCallback(() => { + setIsCapturing(false); + setIsProcessing(false); + if (receiptImageUrl) { + URL.revokeObjectURL(receiptImageUrl); + } + setResult(null); + setReceiptImageUrl(null); + setError(null); + }, [receiptImageUrl]); + + return { + isCapturing, + isProcessing, + result, + receiptImageUrl, + error, + startCapture, + cancelCapture, + processImage, + acceptResult, + reset, + updateField, + }; +} diff --git a/frontend/src/features/maintenance/types/maintenance-receipt.types.ts b/frontend/src/features/maintenance/types/maintenance-receipt.types.ts new file mode 100644 index 0000000..35c7081 --- /dev/null +++ b/frontend/src/features/maintenance/types/maintenance-receipt.types.ts @@ -0,0 +1,70 @@ +/** + * @ai-summary Type definitions for maintenance receipt OCR extraction + * @ai-context Mirrors fuel-logs ExtractedReceiptField pattern; maps OCR fields to maintenance record form values + */ + +import { MaintenanceCategory } from './maintenance.types'; + +/** OCR-extracted field with confidence score */ +export interface ExtractedMaintenanceField { + value: string | number | null; + confidence: number; +} + +/** Fields extracted from a maintenance receipt via OCR */ +export interface ExtractedMaintenanceReceiptFields { + serviceName: ExtractedMaintenanceField; + serviceDate: ExtractedMaintenanceField; + totalCost: ExtractedMaintenanceField; + shopName: ExtractedMaintenanceField; + laborCost: ExtractedMaintenanceField; + partsCost: ExtractedMaintenanceField; + odometerReading: ExtractedMaintenanceField; + vehicleInfo: ExtractedMaintenanceField; +} + +/** Suggested category and subtypes from service name keyword matching */ +export interface CategorySuggestion { + category: MaintenanceCategory; + subtypes: string[]; + confidence: number; +} + +/** Mapped fields ready for maintenance record form population */ +export interface MappedMaintenanceFields { + date?: string; + cost?: number; + shopName?: string; + odometerReading?: number; + category?: MaintenanceCategory; + subtypes?: string[]; + notes?: string; +} + +/** Maintenance receipt OCR result */ +export interface MaintenanceReceiptOcrResult { + extractedFields: ExtractedMaintenanceReceiptFields; + mappedFields: MappedMaintenanceFields; + categorySuggestion: CategorySuggestion | null; + rawText: string; + overallConfidence: number; +} + +/** Hook state */ +export interface UseMaintenanceReceiptOcrState { + isCapturing: boolean; + isProcessing: boolean; + result: MaintenanceReceiptOcrResult | null; + receiptImageUrl: string | null; + error: string | null; +} + +/** Hook return type */ +export interface UseMaintenanceReceiptOcrReturn extends UseMaintenanceReceiptOcrState { + startCapture: () => void; + cancelCapture: () => void; + processImage: (file: File, croppedFile?: File) => Promise; + acceptResult: () => MappedMaintenanceFields | null; + reset: () => void; + updateField: (fieldName: keyof ExtractedMaintenanceReceiptFields, value: string | number | null) => void; +} diff --git a/frontend/src/features/maintenance/types/maintenance.types.ts b/frontend/src/features/maintenance/types/maintenance.types.ts index 26e7d3d..3eebef4 100644 --- a/frontend/src/features/maintenance/types/maintenance.types.ts +++ b/frontend/src/features/maintenance/types/maintenance.types.ts @@ -68,6 +68,7 @@ export interface MaintenanceRecord { cost?: number; shopName?: string; notes?: string; + receiptDocumentId?: string | null; createdAt: string; updatedAt: string; } @@ -105,6 +106,15 @@ export interface CreateMaintenanceRecordRequest { cost?: number; shopName?: string; notes?: string; + receiptDocumentId?: string; +} + +// Receipt document metadata returned on GET +export interface ReceiptDocumentMeta { + documentId: string; + fileName: string; + contentType: string; + storageKey: string; } export interface UpdateMaintenanceRecordRequest { @@ -148,6 +158,7 @@ export interface UpdateScheduleRequest { // Response types (camelCase) export interface MaintenanceRecordResponse extends MaintenanceRecord { subtypeCount: number; + receiptDocument?: ReceiptDocumentMeta | null; } export interface MaintenanceScheduleResponse extends MaintenanceSchedule { diff --git a/ocr/app/extractors/__init__.py b/ocr/app/extractors/__init__.py index c0026a7..97d7480 100644 --- a/ocr/app/extractors/__init__.py +++ b/ocr/app/extractors/__init__.py @@ -8,6 +8,10 @@ from app.extractors.receipt_extractor import ( ExtractedField, ) from app.extractors.fuel_receipt import FuelReceiptExtractor, fuel_receipt_extractor +from app.extractors.maintenance_receipt_extractor import ( + MaintenanceReceiptExtractor, + maintenance_receipt_extractor, +) from app.extractors.manual_extractor import ( ManualExtractor, manual_extractor, @@ -27,6 +31,8 @@ __all__ = [ "ExtractedField", "FuelReceiptExtractor", "fuel_receipt_extractor", + "MaintenanceReceiptExtractor", + "maintenance_receipt_extractor", "ManualExtractor", "manual_extractor", "ManualExtractionResult", diff --git a/ocr/app/extractors/maintenance_receipt_extractor.py b/ocr/app/extractors/maintenance_receipt_extractor.py new file mode 100644 index 0000000..93285ba --- /dev/null +++ b/ocr/app/extractors/maintenance_receipt_extractor.py @@ -0,0 +1,312 @@ +"""Maintenance receipt extraction with Gemini-primary and regex cross-validation. + +Flow: +1. Preprocess image and OCR via receipt_extractor (PaddleOCR) +2. Send OCR text to Gemini text API for semantic field extraction +3. Cross-validate structured fields (date, cost, odometer) with regex +4. Return ReceiptExtractionResult with per-field confidence scores +""" + +import json +import logging +import os +import time +from typing import Any, Optional + +from app.config import settings +from app.extractors.receipt_extractor import ( + ExtractedField, + ReceiptExtractionResult, + receipt_extractor, +) +from app.patterns.maintenance_receipt_validation import ( + MaintenanceReceiptValidation, + maintenance_receipt_validator, +) + +logger = logging.getLogger(__name__) + +# Default confidence for Gemini-extracted fields before cross-validation +DEFAULT_GEMINI_CONFIDENCE = 0.85 + +# Gemini prompt for maintenance receipt field extraction +_RECEIPT_EXTRACTION_PROMPT = """\ +Extract maintenance service receipt fields from the following OCR text. + +For each field, extract the value if present. Return null for fields not found. + +Fields to extract: +- serviceName: The maintenance service performed (e.g., "Oil Change", "Brake Pad Replacement", "Tire Rotation") +- serviceDate: Date of service in YYYY-MM-DD format +- totalCost: Total cost as a number (e.g., 89.95) +- shopName: Name of the shop or business +- laborCost: Labor cost as a number, or null if not broken out +- partsCost: Parts cost as a number, or null if not broken out +- odometerReading: Odometer/mileage reading as a number, or null if not present +- vehicleInfo: Vehicle description if present (e.g., "2022 Toyota Camry"), or null + +Return a JSON object with these field names and their extracted values. + +OCR Text: +--- +{ocr_text} +---\ +""" + +_RECEIPT_RESPONSE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "serviceName": {"type": "string", "nullable": True}, + "serviceDate": {"type": "string", "nullable": True}, + "totalCost": {"type": "number", "nullable": True}, + "shopName": {"type": "string", "nullable": True}, + "laborCost": {"type": "number", "nullable": True}, + "partsCost": {"type": "number", "nullable": True}, + "odometerReading": {"type": "number", "nullable": True}, + "vehicleInfo": {"type": "string", "nullable": True}, + }, + "required": [ + "serviceName", + "serviceDate", + "totalCost", + "shopName", + "laborCost", + "partsCost", + "odometerReading", + "vehicleInfo", + ], +} + + +class MaintenanceReceiptExtractor: + """Maintenance receipt extractor using Gemini for semantic extraction. + + Wraps receipt_extractor for OCR preprocessing, then sends raw text to + Gemini for field extraction. Structured fields (dates, amounts, odometer) + are cross-validated against regex patterns for confidence adjustment. + """ + + def __init__(self) -> None: + self._model: Any | None = None + self._generation_config: Any | None = None + + def extract( + self, + image_bytes: bytes, + content_type: Optional[str] = None, + ) -> ReceiptExtractionResult: + """Extract maintenance receipt fields from an image. + + Args: + image_bytes: Raw image bytes (HEIC, JPEG, PNG). + content_type: MIME type (auto-detected if not provided). + + Returns: + ReceiptExtractionResult with maintenance-specific fields. + """ + start_time = time.time() + + # Step 1: OCR the image via receipt_extractor + ocr_result = receipt_extractor.extract( + image_bytes=image_bytes, + content_type=content_type, + ) + + if not ocr_result.success: + return ocr_result + + raw_text = ocr_result.raw_text + + if not raw_text.strip(): + return ReceiptExtractionResult( + success=False, + error="No text found in image", + processing_time_ms=int((time.time() - start_time) * 1000), + ) + + # Step 2: Extract fields with Gemini + try: + gemini_fields = self._extract_with_gemini(raw_text) + except Exception as e: + logger.warning(f"Gemini extraction failed, falling back to OCR-only: {e}") + gemini_fields = {} + + # Step 3: Build extracted fields with base confidence + extracted_fields = self._build_fields(gemini_fields) + + if not extracted_fields: + return ReceiptExtractionResult( + success=False, + receipt_type="maintenance", + error="No maintenance receipt fields could be extracted", + raw_text=raw_text, + processing_time_ms=int((time.time() - start_time) * 1000), + ) + + # Step 4: Cross-validate structured fields with regex + validation = maintenance_receipt_validator.validate(gemini_fields, raw_text) + + if validation.issues: + logger.info(f"Maintenance receipt validation issues: {validation.issues}") + + # Step 5: Adjust confidences based on cross-validation + adjusted_fields = self._adjust_confidences(extracted_fields, validation) + + processing_time_ms = int((time.time() - start_time) * 1000) + + logger.info( + f"Maintenance receipt extraction: " + f"fields={len(adjusted_fields)}, " + f"validated={validation.is_valid}, " + f"time={processing_time_ms}ms" + ) + + return ReceiptExtractionResult( + success=True, + receipt_type="maintenance", + extracted_fields=adjusted_fields, + raw_text=raw_text, + processing_time_ms=processing_time_ms, + ) + + def _get_model(self) -> Any: + """Lazy-initialize Vertex AI Gemini model. + + Uses the same authentication pattern as GeminiEngine. + """ + if self._model is not None: + return self._model + + key_path = settings.google_vision_key_path + if not os.path.isfile(key_path): + raise RuntimeError( + f"Google credential config not found at {key_path}. " + "Set GOOGLE_VISION_KEY_PATH or mount the secret." + ) + + from google.cloud import aiplatform # type: ignore[import-untyped] + from vertexai.generative_models import ( # type: ignore[import-untyped] + GenerationConfig, + GenerativeModel, + ) + + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = key_path + os.environ["GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"] = "1" + + aiplatform.init( + project=settings.vertex_ai_project, + location=settings.vertex_ai_location, + ) + + model_name = settings.gemini_model + self._model = GenerativeModel(model_name) + self._generation_config = GenerationConfig( + response_mime_type="application/json", + response_schema=_RECEIPT_RESPONSE_SCHEMA, + ) + + logger.info( + "Maintenance receipt Gemini model initialized (model=%s)", + model_name, + ) + return self._model + + def _extract_with_gemini(self, ocr_text: str) -> dict: + """Send OCR text to Gemini for semantic field extraction. + + Args: + ocr_text: Raw OCR text from receipt image. + + Returns: + Dictionary of field_name -> extracted_value from Gemini. + """ + model = self._get_model() + + prompt = _RECEIPT_EXTRACTION_PROMPT.format(ocr_text=ocr_text) + + response = model.generate_content( + [prompt], + generation_config=self._generation_config, + ) + + raw = json.loads(response.text) + + logger.info( + "Gemini extracted maintenance fields: %s", + [k for k, v in raw.items() if v is not None], + ) + + return raw + + def _build_fields(self, gemini_fields: dict) -> dict[str, ExtractedField]: + """Convert Gemini response to ExtractedField dict with base confidence. + + Args: + gemini_fields: Raw Gemini response dict. + + Returns: + Dictionary of field_name -> ExtractedField. + """ + fields: dict[str, ExtractedField] = {} + + for field_name, value in gemini_fields.items(): + if value is None: + continue + + # Convert numeric values to appropriate types + if field_name in ("totalCost", "laborCost", "partsCost"): + try: + value = round(float(value), 2) + except (ValueError, TypeError): + continue + elif field_name == "odometerReading": + try: + value = int(float(value)) + except (ValueError, TypeError): + continue + elif isinstance(value, str) and not value.strip(): + continue + + fields[field_name] = ExtractedField( + value=value, + confidence=DEFAULT_GEMINI_CONFIDENCE, + ) + + return fields + + def _adjust_confidences( + self, + fields: dict[str, ExtractedField], + validation: MaintenanceReceiptValidation, + ) -> dict[str, ExtractedField]: + """Adjust field confidences based on cross-validation results. + + Args: + fields: Extracted fields with base confidence. + validation: Cross-validation results. + + Returns: + Fields with adjusted confidences. + """ + adjusted: dict[str, ExtractedField] = {} + + for name, extracted_field in fields.items(): + if name in validation.field_validations: + fv = validation.field_validations[name] + new_confidence = min( + 1.0, extracted_field.confidence * fv.confidence_adjustment + ) + else: + # Semantic fields (no regex validation) keep base confidence + new_confidence = extracted_field.confidence + + adjusted[name] = ExtractedField( + value=extracted_field.value, + confidence=round(new_confidence, 3), + ) + + return adjusted + + +# Singleton instance +maintenance_receipt_extractor = MaintenanceReceiptExtractor() diff --git a/ocr/app/patterns/__init__.py b/ocr/app/patterns/__init__.py index f9a8bc4..969c36a 100644 --- a/ocr/app/patterns/__init__.py +++ b/ocr/app/patterns/__init__.py @@ -4,6 +4,10 @@ from app.patterns.currency_patterns import CurrencyPatternMatcher, currency_matc from app.patterns.fuel_patterns import FuelPatternMatcher, fuel_matcher from app.patterns.maintenance_patterns import MaintenancePatternMatcher, maintenance_matcher from app.patterns.service_mapping import ServiceMapper, service_mapper +from app.patterns.maintenance_receipt_validation import ( + MaintenanceReceiptValidator, + maintenance_receipt_validator, +) __all__ = [ "DatePatternMatcher", @@ -16,4 +20,6 @@ __all__ = [ "maintenance_matcher", "ServiceMapper", "service_mapper", + "MaintenanceReceiptValidator", + "maintenance_receipt_validator", ] diff --git a/ocr/app/patterns/maintenance_receipt_validation.py b/ocr/app/patterns/maintenance_receipt_validation.py new file mode 100644 index 0000000..ef2abcc --- /dev/null +++ b/ocr/app/patterns/maintenance_receipt_validation.py @@ -0,0 +1,299 @@ +"""Cross-validation patterns for maintenance receipt field extraction. + +Validates structured fields (dates, amounts, odometer) extracted by Gemini +against regex patterns found in the OCR raw text. Boosts or reduces confidence +based on regex agreement. +""" + +import re +from dataclasses import dataclass, field +from typing import Optional + +from app.patterns.currency_patterns import currency_matcher +from app.patterns.date_patterns import date_matcher + + +@dataclass +class FieldValidation: + """Validation result for a single extracted field.""" + + field_name: str + regex_confirmed: bool + confidence_adjustment: float # Multiplier: >1.0 boosts, <1.0 reduces + regex_value: Optional[str] = None # Value found by regex, if any + + +@dataclass +class MaintenanceReceiptValidation: + """Aggregated validation result for a maintenance receipt.""" + + is_valid: bool + issues: list[str] + field_validations: dict[str, FieldValidation] = field(default_factory=dict) + overall_confidence: float = 1.0 + + +# Odometer patterns: 5-7 digit numbers near odometer keywords +ODOMETER_PATTERNS = [ + # "Odometer: 45,231" or "Mileage: 45231" + ( + r"(?:ODOMETER|MILEAGE|MILES|ODO|MI)\s*[:\s]\s*(\d{1,3}[,.]?\d{3,4})", + "labeled_odometer", + 0.95, + ), + # "45,231 mi" or "45231 miles" + ( + r"(\d{1,3}[,.]?\d{3,4})\s*(?:MI|MILES|KM)", + "unit_odometer", + 0.90, + ), + # Standalone 5-6 digit number (lower confidence) + ( + r"(? MaintenanceReceiptValidation: + """Validate Gemini-extracted fields against regex patterns in raw OCR text. + + Args: + gemini_fields: Fields extracted by Gemini (field_name -> value). + raw_text: Raw OCR text for regex cross-validation. + + Returns: + MaintenanceReceiptValidation with per-field results. + """ + issues: list[str] = [] + field_validations: dict[str, FieldValidation] = {} + overall_confidence = 1.0 + + # Validate date field + if "serviceDate" in gemini_fields: + date_validation = self._validate_date( + gemini_fields["serviceDate"], raw_text + ) + field_validations["serviceDate"] = date_validation + if not date_validation.regex_confirmed: + issues.append( + f"Service date '{gemini_fields['serviceDate']}' not confirmed by regex" + ) + overall_confidence *= 0.85 + + # Validate total cost + if "totalCost" in gemini_fields: + cost_validation = self._validate_amount( + "totalCost", gemini_fields["totalCost"], raw_text + ) + field_validations["totalCost"] = cost_validation + if not cost_validation.regex_confirmed: + issues.append( + f"Total cost '{gemini_fields['totalCost']}' not confirmed by regex" + ) + overall_confidence *= 0.85 + + # Validate labor cost + if "laborCost" in gemini_fields: + labor_validation = self._validate_amount( + "laborCost", gemini_fields["laborCost"], raw_text + ) + field_validations["laborCost"] = labor_validation + if not labor_validation.regex_confirmed: + issues.append( + f"Labor cost '{gemini_fields['laborCost']}' not confirmed by regex" + ) + overall_confidence *= 0.90 + + # Validate odometer + if "odometerReading" in gemini_fields: + odo_validation = self._validate_odometer( + gemini_fields["odometerReading"], raw_text + ) + field_validations["odometerReading"] = odo_validation + if not odo_validation.regex_confirmed: + issues.append( + f"Odometer '{gemini_fields['odometerReading']}' not confirmed by regex" + ) + overall_confidence *= 0.90 + + is_valid = len(issues) == 0 + return MaintenanceReceiptValidation( + is_valid=is_valid, + issues=issues, + field_validations=field_validations, + overall_confidence=overall_confidence, + ) + + def extract_odometer(self, text: str) -> Optional[OdometerMatch]: + """Extract odometer reading from text using regex patterns. + + Args: + text: OCR text to search. + + Returns: + Best OdometerMatch or None. + """ + text_upper = text.upper() + best_match: Optional[OdometerMatch] = None + + for pattern, name, confidence in ODOMETER_PATTERNS: + for match in re.finditer(pattern, text_upper): + raw_value = match.group(1) + parsed = self._parse_odometer(raw_value) + if parsed is not None and self._is_reasonable_odometer(parsed): + candidate = OdometerMatch( + value=parsed, + raw_match=match.group(0).strip(), + confidence=confidence, + pattern_name=name, + ) + if best_match is None or candidate.confidence > best_match.confidence: + best_match = candidate + + return best_match + + def _validate_date(self, gemini_date: str, raw_text: str) -> FieldValidation: + """Check if Gemini-extracted date matches a regex-found date.""" + regex_dates = date_matcher.extract_dates(raw_text) + + if not regex_dates: + # No dates found by regex -- cannot confirm or deny + return FieldValidation( + field_name="serviceDate", + regex_confirmed=False, + confidence_adjustment=0.95, + ) + + # Normalize Gemini date for comparison + gemini_normalized = gemini_date.strip().replace("/", "-") + + for regex_date in regex_dates: + if regex_date.value == gemini_normalized: + return FieldValidation( + field_name="serviceDate", + regex_confirmed=True, + confidence_adjustment=1.10, + regex_value=regex_date.value, + ) + + # Gemini found a date but it doesn't match regex dates + return FieldValidation( + field_name="serviceDate", + regex_confirmed=False, + confidence_adjustment=0.80, + regex_value=regex_dates[0].value if regex_dates else None, + ) + + def _validate_amount( + self, field_name: str, gemini_amount: str | float, raw_text: str + ) -> FieldValidation: + """Check if Gemini-extracted amount matches a regex-found amount.""" + try: + gemini_value = float(str(gemini_amount).replace("$", "").replace(",", "")) + except (ValueError, TypeError): + return FieldValidation( + field_name=field_name, + regex_confirmed=False, + confidence_adjustment=0.70, + ) + + regex_amounts = currency_matcher.extract_all_amounts(raw_text) + + if not regex_amounts: + return FieldValidation( + field_name=field_name, + regex_confirmed=False, + confidence_adjustment=0.95, + ) + + # Check if any regex amount matches within 5% tolerance + for regex_amount in regex_amounts: + if gemini_value > 0 and abs(regex_amount.value - gemini_value) / gemini_value < 0.05: + return FieldValidation( + field_name=field_name, + regex_confirmed=True, + confidence_adjustment=1.10, + regex_value=str(regex_amount.value), + ) + + return FieldValidation( + field_name=field_name, + regex_confirmed=False, + confidence_adjustment=0.80, + regex_value=str(regex_amounts[0].value) if regex_amounts else None, + ) + + def _validate_odometer( + self, gemini_odometer: str | int, raw_text: str + ) -> FieldValidation: + """Check if Gemini-extracted odometer matches a regex-found reading.""" + try: + gemini_value = int( + str(gemini_odometer).replace(",", "").replace(".", "").strip() + ) + except (ValueError, TypeError): + return FieldValidation( + field_name="odometerReading", + regex_confirmed=False, + confidence_adjustment=0.70, + ) + + regex_match = self.extract_odometer(raw_text) + + if not regex_match: + return FieldValidation( + field_name="odometerReading", + regex_confirmed=False, + confidence_adjustment=0.95, + ) + + # Check if values match within 1% tolerance (OCR might misread a digit) + if gemini_value > 0 and abs(regex_match.value - gemini_value) / gemini_value < 0.01: + return FieldValidation( + field_name="odometerReading", + regex_confirmed=True, + confidence_adjustment=1.10, + regex_value=str(regex_match.value), + ) + + return FieldValidation( + field_name="odometerReading", + regex_confirmed=False, + confidence_adjustment=0.80, + regex_value=str(regex_match.value), + ) + + def _parse_odometer(self, raw: str) -> Optional[int]: + """Parse odometer string to integer.""" + cleaned = raw.replace(",", "").replace(".", "").strip() + try: + return int(cleaned) + except ValueError: + return None + + def _is_reasonable_odometer(self, value: int) -> bool: + """Check if odometer reading is in a reasonable range.""" + return 100 <= value <= 999_999 + + +# Singleton instance +maintenance_receipt_validator = MaintenanceReceiptValidator() diff --git a/ocr/app/routers/extract.py b/ocr/app/routers/extract.py index e9aa6e3..3c1d02f 100644 --- a/ocr/app/routers/extract.py +++ b/ocr/app/routers/extract.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Query from app.extractors.vin_extractor import vin_extractor from app.extractors.receipt_extractor import receipt_extractor +from app.extractors.maintenance_receipt_extractor import maintenance_receipt_extractor from app.extractors.manual_extractor import manual_extractor from app.models import ( BoundingBox, @@ -267,6 +268,95 @@ async def extract_receipt( ) +@router.post("/maintenance-receipt", response_model=ReceiptExtractionResponse) +async def extract_maintenance_receipt( + file: UploadFile = File(..., description="Maintenance receipt image file"), +) -> ReceiptExtractionResponse: + """ + Extract data from a maintenance receipt image using OCR + Gemini. + + Gemini-primary extraction with regex cross-validation: + - OCR preprocessing (HEIC conversion, contrast, thresholding) + - PaddleOCR text extraction + - Gemini semantic field extraction from OCR text + - Regex cross-validation for dates, amounts, odometer + + Supports HEIC, JPEG, PNG formats. + + - **file**: Maintenance receipt image file (max 10MB) + + Returns: + - **receiptType**: "maintenance" + - **extractedFields**: Dictionary of extracted fields with confidence scores + - serviceName: Service performed (e.g., "Oil Change") + - serviceDate: Date in YYYY-MM-DD format + - totalCost: Total cost + - shopName: Shop or business name + - laborCost: Labor cost (if broken out) + - partsCost: Parts cost (if broken out) + - odometerReading: Odometer reading (if present) + - vehicleInfo: Vehicle description (if present) + - **rawText**: Full OCR text + - **processingTimeMs**: Processing time in milliseconds + """ + # Validate file presence + if not file.filename: + raise HTTPException(status_code=400, detail="No file provided") + + # Read file content + content = await file.read() + file_size = len(content) + + # Validate file size + if file_size > MAX_SYNC_SIZE: + raise HTTPException( + status_code=413, + detail=f"File too large. Max: {MAX_SYNC_SIZE // (1024*1024)}MB", + ) + + if file_size == 0: + raise HTTPException(status_code=400, detail="Empty file provided") + + logger.info( + f"Maintenance receipt extraction: {file.filename}, " + f"size: {file_size} bytes, " + f"content_type: {file.content_type}" + ) + + # Perform maintenance receipt extraction + result = maintenance_receipt_extractor.extract( + image_bytes=content, + content_type=file.content_type, + ) + + if not result.success: + logger.warning( + f"Maintenance receipt extraction failed for {file.filename}: {result.error}" + ) + raise HTTPException( + status_code=422, + detail=result.error or "Failed to extract data from maintenance receipt", + ) + + # Convert internal fields to API response format + extracted_fields = { + name: ReceiptExtractedField( + value=field.value, + confidence=field.confidence, + ) + for name, field in result.extracted_fields.items() + } + + return ReceiptExtractionResponse( + success=result.success, + receiptType=result.receipt_type, + extractedFields=extracted_fields, + rawText=result.raw_text, + processingTimeMs=result.processing_time_ms, + error=result.error, + ) + + @router.post("/manual", response_model=ManualJobResponse) async def extract_manual( background_tasks: BackgroundTasks,