feat: Maintenance Receipt Upload with OCR Auto-populate (#16) #161
226
backend/package-lock.json
generated
226
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<FastifyInstance> {
|
||||
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<FastifyInstance> {
|
||||
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<FastifyInstance> {
|
||||
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' });
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -36,6 +36,11 @@ export const FEATURE_TIERS: Record<string, FeatureConfig> = {
|
||||
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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string> = {
|
||||
'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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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<EmailIngestionQueueRecord> {
|
||||
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<EmailIngestionQueueRecord | null> {
|
||||
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<EmailIngestionQueueRecord | null> {
|
||||
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<EmailIngestionQueueRecord | null> {
|
||||
return this.getQueueEntry(emailId);
|
||||
}
|
||||
|
||||
async getRetryableEntries(maxRetries: number = 3): Promise<EmailIngestionQueueRecord[]> {
|
||||
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<PendingVehicleAssociation> {
|
||||
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<PendingVehicleAssociation | null> {
|
||||
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<number> {
|
||||
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<PendingVehicleAssociation[]> {
|
||||
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<PendingVehicleAssociation | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<ParsedEmailAttachment[]> {
|
||||
// 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<ReceiptExtractionResponse | null> {
|
||||
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<ReceiptExtractionResponse | null> {
|
||||
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<EmailProcessingResult> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
if (recordType === 'fuel_log') {
|
||||
return this.createFuelLogRecord(userId, vehicleId, extractedData);
|
||||
}
|
||||
return this.createMaintenanceRecord(userId, vehicleId, extractedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map extracted receipt data to EnhancedCreateFuelLogRequest and create fuel log.
|
||||
*/
|
||||
private async createFuelLogRecord(
|
||||
userId: string,
|
||||
vehicleId: string,
|
||||
data: ExtractedReceiptData
|
||||
): Promise<string> {
|
||||
const fuelUnits = data.gallons ?? 0;
|
||||
const costPerUnit = data.pricePerGallon ?? (data.total && fuelUnits > 0 ? data.total / fuelUnits : 0);
|
||||
|
||||
const request: EnhancedCreateFuelLogRequest = {
|
||||
vehicleId,
|
||||
dateTime: data.date || new Date().toISOString(),
|
||||
fuelType: this.mapFuelType(data.fuelType),
|
||||
fuelUnits,
|
||||
costPerUnit,
|
||||
odometerReading: data.odometerReading ?? undefined,
|
||||
locationData: data.vendor ? { stationName: data.vendor } : undefined,
|
||||
notes: 'Created from emailed receipt',
|
||||
};
|
||||
|
||||
logger.info('Creating fuel log from email receipt', { userId, vehicleId, fuelUnits, costPerUnit });
|
||||
const result = await this.fuelLogsService.createFuelLog(request, userId);
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map extracted receipt data to CreateMaintenanceRecordRequest and create maintenance record.
|
||||
*/
|
||||
private async createMaintenanceRecord(
|
||||
userId: string,
|
||||
vehicleId: string,
|
||||
data: ExtractedReceiptData
|
||||
): Promise<string> {
|
||||
const category = this.mapMaintenanceCategory(data.category);
|
||||
const subtypes = this.resolveMaintenanceSubtypes(category, data.subtypes);
|
||||
|
||||
const record = await this.maintenanceService.createRecord(userId, {
|
||||
vehicleId,
|
||||
category,
|
||||
subtypes,
|
||||
date: data.date || new Date().toISOString().split('T')[0],
|
||||
odometerReading: data.odometerReading ?? undefined,
|
||||
cost: data.total ?? undefined,
|
||||
shopName: data.shopName || data.vendor || undefined,
|
||||
notes: data.description
|
||||
? `${data.description}\n\nCreated from emailed receipt`
|
||||
: 'Created from emailed receipt',
|
||||
});
|
||||
|
||||
logger.info('Created maintenance record from email receipt', { userId, vehicleId, recordId: record.id, category });
|
||||
return record.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map OCR fuel type string to FuelType enum. Defaults to gasoline.
|
||||
*/
|
||||
private mapFuelType(fuelTypeStr: string | null): FuelType {
|
||||
if (!fuelTypeStr) return FuelType.GASOLINE;
|
||||
|
||||
const normalized = fuelTypeStr.toLowerCase().trim();
|
||||
if (normalized.includes('diesel') || normalized === '#1' || normalized === '#2') {
|
||||
return FuelType.DIESEL;
|
||||
}
|
||||
if (normalized.includes('electric') || normalized.includes('ev')) {
|
||||
return FuelType.ELECTRIC;
|
||||
}
|
||||
return FuelType.GASOLINE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map OCR category string to MaintenanceCategory. Defaults to routine_maintenance.
|
||||
*/
|
||||
private mapMaintenanceCategory(categoryStr: string | null): MaintenanceCategory {
|
||||
if (!categoryStr) return 'routine_maintenance';
|
||||
|
||||
const normalized = categoryStr.toLowerCase().trim();
|
||||
if (normalized.includes('repair')) return 'repair';
|
||||
if (normalized.includes('performance') || normalized.includes('upgrade')) return 'performance_upgrade';
|
||||
return 'routine_maintenance';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and resolve maintenance subtypes. Falls back to first valid
|
||||
* subtype for the category if OCR subtypes are invalid or missing.
|
||||
*/
|
||||
private resolveMaintenanceSubtypes(
|
||||
category: MaintenanceCategory,
|
||||
ocrSubtypes: string[] | null
|
||||
): string[] {
|
||||
if (ocrSubtypes && ocrSubtypes.length > 0 && validateSubtypes(category, ocrSubtypes)) {
|
||||
return ocrSubtypes;
|
||||
}
|
||||
|
||||
// Attempt to match OCR subtypes against valid options (case-insensitive)
|
||||
if (ocrSubtypes && ocrSubtypes.length > 0) {
|
||||
const validOptions = getSubtypesForCategory(category);
|
||||
const matched = ocrSubtypes
|
||||
.map(s => validOptions.find(v => v.toLowerCase() === s.toLowerCase().trim()))
|
||||
.filter((v): v is string => v !== undefined);
|
||||
if (matched.length > 0) return matched;
|
||||
}
|
||||
|
||||
// Default to first subtype of category
|
||||
const defaults = getSubtypesForCategory(category);
|
||||
return [defaults[0] as string];
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Error Handling & Retries
|
||||
// ========================
|
||||
|
||||
private async handleProcessingError(
|
||||
emailId: string,
|
||||
senderEmail: string,
|
||||
_subject: string | null,
|
||||
error: unknown
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
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<string, string | number | boolean | null | undefined>;
|
||||
referenceType?: string;
|
||||
referenceId?: string;
|
||||
}): Promise<void> {
|
||||
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),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
110
backend/src/features/email-ingestion/external/resend-inbound.client.ts
vendored
Normal file
110
backend/src/features/email-ingestion/external/resend-inbound.client.ts
vendored
Normal file
@@ -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<string, string>): 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<string> {
|
||||
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<ParsedEmailResult> {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
22
backend/src/features/email-ingestion/index.ts
Normal file
22
backend/src/features/email-ingestion/index.ts
Normal file
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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"]',
|
||||
'<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Receipt Processed</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="background-color: #2e7d32; padding: 30px; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Receipt Processed</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Your emailed receipt has been successfully processed.</p>
|
||||
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
|
||||
<tr>
|
||||
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #2e7d32;">
|
||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Vehicle:</strong> {{vehicleName}}</p>
|
||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Record Type:</strong> {{recordType}}</p>
|
||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Merchant:</strong> {{merchantName}}</p>
|
||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Date:</strong> {{date}}</p>
|
||||
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{totalAmount}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;">The record has been added to your vehicle history.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
|
||||
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>'
|
||||
),
|
||||
(
|
||||
'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"]',
|
||||
'<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Receipt Processing Failed</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="background-color: #d32f2f; padding: 30px; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Processing Failed</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">We were unable to process the receipt you emailed to us.</p>
|
||||
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
|
||||
<tr>
|
||||
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #d32f2f;">
|
||||
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Error:</strong> {{errorReason}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 4px; margin: 20px 0;">
|
||||
<p style="color: #333333; font-size: 14px; font-weight: bold; margin: 0 0 10px 0;">What to do next:</p>
|
||||
<p style="color: #666666; font-size: 14px; line-height: 1.8; margin: 0;">{{guidance}}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
|
||||
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>'
|
||||
),
|
||||
(
|
||||
'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"]',
|
||||
'<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Select Vehicle for Receipt</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="background-color: #f57c00; padding: 30px; text-align: center;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Vehicle Selection Needed</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Your emailed receipt has been processed, but we need your help to complete the record.</p>
|
||||
<div style="background-color: #fff3e0; border-left: 4px solid #f57c00; padding: 20px; margin: 20px 0;">
|
||||
<p style="color: #e65100; font-size: 16px; font-weight: bold; margin: 0 0 10px 0;">Action Required</p>
|
||||
<p style="color: #333333; font-size: 14px; margin: 0;">Since you have multiple vehicles, please select which vehicle this receipt belongs to.</p>
|
||||
</div>
|
||||
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
|
||||
<tr>
|
||||
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #f57c00;">
|
||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Record Type:</strong> {{recordType}}</p>
|
||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Merchant:</strong> {{merchantName}}</p>
|
||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Date:</strong> {{date}}</p>
|
||||
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{totalAmount}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;">You can find the pending receipt in your notifications.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="https://motovaultpro.com/notifications" style="display: inline-block; padding: 14px 28px; background-color: #f57c00; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: bold;">Select Vehicle</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
|
||||
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>'
|
||||
)
|
||||
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();
|
||||
@@ -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<MaintenanceRecord> {
|
||||
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 }
|
||||
|
||||
@@ -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<MaintenanceRecordResponse | null> {
|
||||
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<MaintenanceRecordResponse[]> {
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof CreateMaintenanceRecordSchema>;
|
||||
|
||||
@@ -157,9 +159,18 @@ export const UpdateScheduleSchema = z.object({
|
||||
});
|
||||
export type UpdateScheduleRequest = z.infer<typeof UpdateScheduleSchema>;
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' })],
|
||||
|
||||
@@ -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<ReceiptExtractionResponse> {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
51
backend/src/features/ocr/external/ocr-client.ts
vendored
51
backend/src/features/ocr/external/ocr-client.ts
vendored
@@ -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<ReceiptExtractionResponse> {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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<DashboardScreenProps> = ({
|
||||
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<DashboardScreenProps> = ({
|
||||
// Main dashboard view
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Pending Receipts Banner */}
|
||||
<PendingAssociationBanner onViewPending={() => setShowPendingReceipts(true)} />
|
||||
|
||||
{/* Summary Cards */}
|
||||
<SummaryCards summary={summary} />
|
||||
|
||||
@@ -132,6 +141,35 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
|
||||
Dashboard updates every 2 minutes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pending Receipts Dialog */}
|
||||
<Dialog
|
||||
open={showPendingReceipts}
|
||||
onClose={() => setShowPendingReceipts(false)}
|
||||
fullScreen={isSmall}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
maxHeight: isSmall ? '100%' : '90vh',
|
||||
m: isSmall ? 0 : 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
Pending Receipts
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={() => setShowPendingReceipts(false)}
|
||||
sx={{ minWidth: 44, minHeight: 44 }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ p: { xs: 1, sm: 2 } }}>
|
||||
<PendingAssociationList />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<PendingVehicleAssociation[]> => {
|
||||
const response = await apiClient.get('/email-ingestion/pending');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPendingCount: async (): Promise<PendingAssociationCount> => {
|
||||
const response = await apiClient.get('/email-ingestion/pending/count');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
resolve: async (associationId: string, vehicleId: string): Promise<ResolveAssociationResult> => {
|
||||
const response = await apiClient.post(`/email-ingestion/pending/${associationId}/resolve`, {
|
||||
vehicleId,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
dismiss: async (associationId: string): Promise<void> => {
|
||||
await apiClient.delete(`/email-ingestion/pending/${associationId}`);
|
||||
},
|
||||
};
|
||||
@@ -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<PendingAssociationBannerProps> = ({ 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 (
|
||||
<GlassCard padding="md">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
flexWrap: { xs: 'wrap', sm: 'nowrap' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 3,
|
||||
bgcolor: 'warning.light',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<EmailRoundedIcon sx={{ color: 'warning.dark', fontSize: 24 }} />
|
||||
</Box>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<Box
|
||||
component="h4"
|
||||
sx={{ fontWeight: 600, color: 'text.primary', fontSize: '0.95rem', mb: 0.25 }}
|
||||
>
|
||||
Pending Receipts
|
||||
</Box>
|
||||
<p className="text-sm text-slate-500 dark:text-titanio">
|
||||
{label} need a vehicle assigned
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Box sx={{ flexShrink: 0, width: { xs: '100%', sm: 'auto' } }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onViewPending}
|
||||
style={{ minHeight: 44, width: '100%' }}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
@@ -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<PendingVehicleAssociation | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return <PendingAssociationListSkeleton />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<GlassCard padding="md">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-slate-500 dark:text-titanio">Failed to load pending receipts</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!associations || associations.length === 0) {
|
||||
return (
|
||||
<GlassCard padding="md">
|
||||
<div className="text-center py-12">
|
||||
<Box sx={{ color: 'success.main', mb: 1.5 }}>
|
||||
<EmailRoundedIcon sx={{ fontSize: 48 }} />
|
||||
</Box>
|
||||
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">
|
||||
No Pending Receipts
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-titanio">
|
||||
All emailed receipts have been assigned to vehicles
|
||||
</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<GlassCard padding="md">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus">
|
||||
Pending Receipts
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-titanio">
|
||||
Assign a vehicle to each emailed receipt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<Box
|
||||
key={association.id}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 3,
|
||||
bgcolor: 'action.hover',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Box
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
color: isFuel ? 'info.main' : 'warning.main',
|
||||
}}
|
||||
>
|
||||
<IconComponent sx={{ fontSize: 24 }} />
|
||||
</Box>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<Box
|
||||
component="h4"
|
||||
sx={{ fontWeight: 600, color: 'text.primary', fontSize: '0.95rem', mb: 0.5 }}
|
||||
>
|
||||
{merchant}
|
||||
</Box>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-slate-600 dark:text-titanio">
|
||||
<span>{typeLabel}</span>
|
||||
<span>{formatDate(extractedData.date)}</span>
|
||||
{extractedData.total != null && (
|
||||
<span className="font-medium">{formatCurrency(extractedData.total)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isFuel && extractedData.gallons != null && (
|
||||
<p className="text-xs text-slate-400 dark:text-canna mt-1">
|
||||
{extractedData.gallons} gal
|
||||
{extractedData.pricePerGallon != null && ` @ $${extractedData.pricePerGallon.toFixed(3)}/gal`}
|
||||
</p>
|
||||
)}
|
||||
{!isFuel && extractedData.category && (
|
||||
<p className="text-xs text-slate-400 dark:text-canna mt-1">
|
||||
{extractedData.category}
|
||||
{extractedData.description && ` - ${extractedData.description}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-slate-400 dark:text-canna mt-1">
|
||||
Received {formatDate(association.createdAt)}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setResolving(association)}
|
||||
style={{ minHeight: 44 }}
|
||||
>
|
||||
Assign Vehicle
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => dismissMutation.mutate(association.id)}
|
||||
disabled={dismissMutation.isPending}
|
||||
style={{ minHeight: 44 }}
|
||||
>
|
||||
<DeleteOutlineRoundedIcon sx={{ fontSize: 18, mr: 0.5 }} />
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{resolving && (
|
||||
<ResolveAssociationDialog
|
||||
open={!!resolving}
|
||||
association={resolving}
|
||||
onClose={() => setResolving(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const PendingAssociationListSkeleton: React.FC = () => (
|
||||
<GlassCard padding="md">
|
||||
<div className="mb-4">
|
||||
<div className="h-6 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-40 mb-2" />
|
||||
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-56" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-slate-100 dark:bg-slate-700 rounded animate-pulse" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-3/4" />
|
||||
<div className="h-4 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-1/2" />
|
||||
<div className="h-4 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-1/3" />
|
||||
<div className="flex gap-2 mt-3">
|
||||
<div className="h-10 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-28" />
|
||||
<div className="h-10 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
@@ -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<ResolveAssociationDialogProps> = ({
|
||||
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<string | null>(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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
fullScreen={isSmall}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
maxHeight: isSmall ? '100%' : '90vh',
|
||||
m: isSmall ? 0 : 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isSmall && (
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: 8,
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
color: theme.palette.grey[500],
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{isFuel ? (
|
||||
<LocalGasStationRoundedIcon color="info" />
|
||||
) : (
|
||||
<BuildRoundedIcon color="warning" />
|
||||
)}
|
||||
Assign Vehicle
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Receipt summary */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{isFuel ? 'Fuel Receipt' : 'Maintenance Receipt'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.primary" fontWeight={600}>
|
||||
{merchant}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
{extractedData.date && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatDate(extractedData.date)}
|
||||
</Typography>
|
||||
)}
|
||||
{extractedData.total != null && (
|
||||
<Typography variant="body2" color="text.secondary" fontWeight={500}>
|
||||
${extractedData.total.toFixed(2)}
|
||||
</Typography>
|
||||
)}
|
||||
{isFuel && extractedData.gallons != null && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{extractedData.gallons} gal
|
||||
</Typography>
|
||||
)}
|
||||
{!isFuel && extractedData.category && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{extractedData.category}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Vehicle selection */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Select a vehicle
|
||||
</Typography>
|
||||
|
||||
{vehiclesLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : !vehicles || vehicles.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No vehicles found. Add a vehicle first.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{vehicles.map((vehicle) => {
|
||||
const isSelected = selectedVehicleId === vehicle.id;
|
||||
const vehicleName = vehicle.nickname
|
||||
|| [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ')
|
||||
|| 'Unnamed Vehicle';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={vehicle.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => 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 && (
|
||||
<CheckCircleRoundedIcon color="primary" sx={{ fontSize: 20 }} />
|
||||
)}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{vehicleName}
|
||||
</Typography>
|
||||
{vehicle.licensePlate && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{vehicle.licensePlate}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions
|
||||
sx={{
|
||||
px: 3,
|
||||
pb: 3,
|
||||
pt: 1,
|
||||
flexDirection: isSmall ? 'column' : 'row',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outlined"
|
||||
fullWidth={isSmall}
|
||||
sx={{ order: isSmall ? 2 : 1, minHeight: 44 }}
|
||||
disabled={resolveMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleResolve}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
fullWidth={isSmall}
|
||||
sx={{ order: isSmall ? 1 : 2, minHeight: 44 }}
|
||||
disabled={!selectedVehicleId || resolveMutation.isPending}
|
||||
>
|
||||
{resolveMutation.isPending ? (
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
) : (
|
||||
'Assign & Create Record'
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
};
|
||||
10
frontend/src/features/email-ingestion/index.ts
Normal file
10
frontend/src/features/email-ingestion/index.ts
Normal file
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<Box
|
||||
sx={{ display: 'flex', gap: 0.25, ml: 1 }}
|
||||
aria-label={`Confidence: ${Math.round(confidence * 100)}%`}
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: i < filledDots
|
||||
? (isLow ? 'warning.main' : 'success.main')
|
||||
: 'grey.300',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/** 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<string>(
|
||||
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 (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
py: 1.5,
|
||||
px: 1,
|
||||
borderRadius: 1,
|
||||
backgroundColor: isLowConfidence ? 'warning.light' : 'transparent',
|
||||
'&:hover': {
|
||||
backgroundColor: isLowConfidence ? 'warning.light' : 'action.hover',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ width: 100, flexShrink: 0, color: 'text.secondary', fontWeight: 500 }}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
|
||||
{isEditing ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1, gap: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
value={editValue}
|
||||
onChange={(e) => 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();
|
||||
}}
|
||||
/>
|
||||
<IconButton size="small" onClick={handleSave} color="primary">
|
||||
<CheckIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={handleCancel}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', flex: 1, cursor: 'pointer' }}
|
||||
onClick={() => setIsEditing(true)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Edit ${label}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsEditing(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
flex: 1,
|
||||
fontWeight: field.value !== null ? 500 : 400,
|
||||
color: field.value !== null ? 'text.primary' : 'text.disabled',
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
</Typography>
|
||||
{field.value !== null && <ConfidenceIndicator confidence={field.confidence} />}
|
||||
<IconButton size="small" sx={{ ml: 1 }}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const MaintenanceReceiptReviewModal: React.FC<MaintenanceReceiptReviewModalProps> = ({
|
||||
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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
fullScreen={isMobile}
|
||||
PaperProps={{
|
||||
sx: { maxHeight: isMobile ? '100vh' : '90vh' },
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
||||
>
|
||||
<Typography variant="h6" component="span">
|
||||
Maintenance Receipt Extracted
|
||||
</Typography>
|
||||
<IconButton onClick={onCancel} size="small" aria-label="Close">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers>
|
||||
{hasLowConfidenceFields && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Some fields have low confidence. Please review and edit if needed.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{/* Receipt thumbnail */}
|
||||
{receiptImageUrl && (
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Box
|
||||
sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}
|
||||
>
|
||||
<ReceiptPreview
|
||||
imageUrl={receiptImageUrl}
|
||||
maxWidth={isMobile ? 100 : 120}
|
||||
maxHeight={isMobile ? 150 : 180}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Tap to zoom
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Extracted fields */}
|
||||
<Grid item xs={12} sm={receiptImageUrl ? 8 : 12}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Primary fields */}
|
||||
<FieldRow
|
||||
label="Service"
|
||||
field={extractedFields.serviceName}
|
||||
onEdit={(value) => onFieldEdit('serviceName', value)}
|
||||
/>
|
||||
<FieldRow
|
||||
label="Date"
|
||||
field={extractedFields.serviceDate}
|
||||
onEdit={(value) => onFieldEdit('serviceDate', value)}
|
||||
formatDisplay={formatDate}
|
||||
/>
|
||||
<FieldRow
|
||||
label="Total Cost"
|
||||
field={extractedFields.totalCost}
|
||||
onEdit={(value) => onFieldEdit('totalCost', value)}
|
||||
type="number"
|
||||
formatDisplay={formatCurrency}
|
||||
/>
|
||||
<FieldRow
|
||||
label="Shop"
|
||||
field={extractedFields.shopName}
|
||||
onEdit={(value) => onFieldEdit('shopName', value)}
|
||||
/>
|
||||
|
||||
{/* Category suggestion */}
|
||||
{categorySuggestion && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
py: 1,
|
||||
px: 1,
|
||||
gap: 1,
|
||||
backgroundColor: 'success.light',
|
||||
borderRadius: 1,
|
||||
my: 0.5,
|
||||
}}
|
||||
>
|
||||
<BuildIcon fontSize="small" color="success" />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
{getCategoryDisplayName(categorySuggestion.category)}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
|
||||
{categorySuggestion.subtypes.map((subtype) => (
|
||||
<Chip
|
||||
key={subtype}
|
||||
label={subtype}
|
||||
size="small"
|
||||
color="success"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Secondary fields (collapsible on mobile) */}
|
||||
<Collapse in={!isMobile || showAllFields}>
|
||||
<FieldRow
|
||||
label="Odometer"
|
||||
field={extractedFields.odometerReading}
|
||||
onEdit={(value) => onFieldEdit('odometerReading', value)}
|
||||
type="number"
|
||||
/>
|
||||
<FieldRow
|
||||
label="Labor"
|
||||
field={extractedFields.laborCost}
|
||||
onEdit={(value) => onFieldEdit('laborCost', value)}
|
||||
type="number"
|
||||
formatDisplay={formatCurrency}
|
||||
/>
|
||||
<FieldRow
|
||||
label="Parts"
|
||||
field={extractedFields.partsCost}
|
||||
onEdit={(value) => onFieldEdit('partsCost', value)}
|
||||
type="number"
|
||||
formatDisplay={formatCurrency}
|
||||
/>
|
||||
<FieldRow
|
||||
label="Vehicle"
|
||||
field={extractedFields.vehicleInfo}
|
||||
onEdit={(value) => onFieldEdit('vehicleInfo', value)}
|
||||
/>
|
||||
</Collapse>
|
||||
|
||||
{isMobile && (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowAllFields(!showAllFields)}
|
||||
sx={{ mt: 1, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{showAllFields ? 'Show Less' : 'Show More Fields'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 2, textAlign: 'center' }}
|
||||
>
|
||||
Tap any field to edit before saving.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions
|
||||
sx={{
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: 1,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={onRetake}
|
||||
startIcon={<CameraAltIcon />}
|
||||
sx={{ order: isMobile ? 2 : 1, minHeight: 44 }}
|
||||
>
|
||||
Retake Photo
|
||||
</Button>
|
||||
<Box sx={{ flex: 1, display: isMobile ? 'none' : 'block' }} />
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
sx={{ order: isMobile ? 3 : 2, minHeight: 44 }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onAccept}
|
||||
startIcon={<CheckIcon />}
|
||||
sx={{ order: isMobile ? 1 : 3, width: isMobile ? '100%' : 'auto', minHeight: 44 }}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaintenanceReceiptReviewModal;
|
||||
@@ -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<MaintenanceRecordEditDialogPr
|
||||
|
||||
const vehiclesQuery = useVehicles();
|
||||
const vehicles = vehiclesQuery.data;
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const isSmallScreen = useMediaQuery('(max-width:600px)');
|
||||
const [receiptThumbnailUrl, setReceiptThumbnailUrl] = useState<string | null>(null);
|
||||
|
||||
// Reset form when record changes
|
||||
useEffect(() => {
|
||||
@@ -76,6 +82,45 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
|
||||
}
|
||||
}, [record]);
|
||||
|
||||
// Load receipt thumbnail when record has a linked receipt document
|
||||
useEffect(() => {
|
||||
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<MaintenanceRecordEditDialogPr
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Linked Receipt Display */}
|
||||
{record.receiptDocument && (
|
||||
<Grid item xs={12}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'center' : 'center',
|
||||
gap: 2,
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'action.hover',
|
||||
}}
|
||||
>
|
||||
{receiptThumbnailUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={receiptThumbnailUrl}
|
||||
alt="Receipt"
|
||||
sx={{
|
||||
width: isMobile ? 64 : 80,
|
||||
height: isMobile ? 64 : 80,
|
||||
borderRadius: 1,
|
||||
objectFit: 'cover',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: isMobile ? 64 : 80,
|
||||
height: isMobile ? 64 : 80,
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'grey.200',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ReceiptIcon color="action" />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ flex: 1, minWidth: 0, textAlign: isMobile ? 'center' : 'left' }}>
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
Linked Receipt
|
||||
</Typography>
|
||||
{record.receiptDocument.fileName && (
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{record.receiptDocument.fileName}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<ReceiptIcon />}
|
||||
onClick={handleViewReceipt}
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
width: isMobile ? '100%' : 'auto',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
View Receipt
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Category */}
|
||||
<Grid item xs={12}>
|
||||
<FormControl fullWidth>
|
||||
|
||||
@@ -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<MaintenanceCategory | null>(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<File | null>(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 = () => {
|
||||
<Card>
|
||||
<CardHeader title="Add Maintenance Record" />
|
||||
<CardContent>
|
||||
{/* Receipt Scan Button */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mb: 3,
|
||||
pb: 2,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<ReceiptCameraButton
|
||||
onClick={() => {
|
||||
if (!hasReceiptScanAccess) {
|
||||
setShowUpgradeDialog(true);
|
||||
return;
|
||||
}
|
||||
startCapture();
|
||||
}}
|
||||
disabled={isProcessing || isRecordMutating}
|
||||
variant="button"
|
||||
locked={!hasReceiptScanAccess}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Grid container spacing={2}>
|
||||
{/* Vehicle Selection */}
|
||||
@@ -374,6 +494,89 @@ export const MaintenanceRecordForm: React.FC = () => {
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Camera Capture Modal */}
|
||||
<Dialog
|
||||
open={isCapturing}
|
||||
onClose={cancelCapture}
|
||||
fullScreen
|
||||
PaperProps={{
|
||||
sx: { backgroundColor: 'black' },
|
||||
}}
|
||||
>
|
||||
<CameraCapture
|
||||
onCapture={handleCaptureImage}
|
||||
onCancel={cancelCapture}
|
||||
guidanceType="receipt"
|
||||
allowCrop={true}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
{/* OCR Processing Overlay */}
|
||||
<Backdrop
|
||||
open={isProcessing}
|
||||
sx={{
|
||||
color: '#fff',
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress color="inherit" />
|
||||
<Typography variant="body1">Extracting receipt data...</Typography>
|
||||
</Backdrop>
|
||||
|
||||
{/* OCR Review Modal */}
|
||||
{ocrResult && (
|
||||
<MaintenanceReceiptReviewModal
|
||||
open={!!ocrResult}
|
||||
extractedFields={ocrResult.extractedFields}
|
||||
receiptImageUrl={receiptImageUrl}
|
||||
categorySuggestion={ocrResult.categorySuggestion}
|
||||
onAccept={handleAcceptOcrResult}
|
||||
onRetake={handleRetakePhoto}
|
||||
onCancel={() => {
|
||||
resetOcr();
|
||||
setCapturedReceiptFile(null);
|
||||
}}
|
||||
onFieldEdit={updateField}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upgrade Required Dialog for Receipt Scan */}
|
||||
<UpgradeRequiredDialog
|
||||
featureKey="maintenance.receiptScan"
|
||||
open={showUpgradeDialog}
|
||||
onClose={() => setShowUpgradeDialog(false)}
|
||||
/>
|
||||
|
||||
{/* OCR Error Display */}
|
||||
{ocrError && (
|
||||
<Dialog open={!!ocrError} onClose={resetOcr} maxWidth="xs">
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
OCR Error
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{ocrError}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
|
||||
<Button onClick={startCapture} variant="contained">
|
||||
Try Again
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
resetOcr();
|
||||
setCapturedReceiptFile(null);
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)}
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<MaintenanceRecordsListProps> = ({
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
{record.receiptDocument && (
|
||||
<Chip
|
||||
icon={<Receipt fontSize="small" />}
|
||||
label="Receipt"
|
||||
size="small"
|
||||
color="info"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{record.notes && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
|
||||
@@ -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<MaintenanceReceiptOcrResult | null>(null);
|
||||
const [receiptImageUrl, setReceiptImageUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
acceptResult: () => MappedMaintenanceFields | null;
|
||||
reset: () => void;
|
||||
updateField: (fieldName: keyof ExtractedMaintenanceReceiptFields, value: string | number | null) => void;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
312
ocr/app/extractors/maintenance_receipt_extractor.py
Normal file
312
ocr/app/extractors/maintenance_receipt_extractor.py
Normal file
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
299
ocr/app/patterns/maintenance_receipt_validation.py
Normal file
299
ocr/app/patterns/maintenance_receipt_validation.py
Normal file
@@ -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"(?<!\d)(\d{5,6})(?!\d)",
|
||||
"standalone_odometer",
|
||||
0.60,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class OdometerMatch:
|
||||
"""Result of odometer pattern matching."""
|
||||
|
||||
value: int
|
||||
raw_match: str
|
||||
confidence: float
|
||||
pattern_name: str
|
||||
|
||||
|
||||
class MaintenanceReceiptValidator:
|
||||
"""Cross-validates Gemini-extracted maintenance receipt fields against regex patterns."""
|
||||
|
||||
def validate(
|
||||
self,
|
||||
gemini_fields: dict,
|
||||
raw_text: str,
|
||||
) -> 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()
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user