Compare commits
399 Commits
8c7de98a9a
...
issue-223-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b5b84729f | ||
|
|
781241966c | ||
|
|
bf6742f6ea | ||
|
|
5bb44be8bc | ||
|
|
361f58d7c6 | ||
|
|
d96736789e | ||
|
|
f590421058 | ||
|
|
5cbf9c764d | ||
|
|
3cd61256ba | ||
|
|
a75f7b5583 | ||
| 00aa2a5411 | |||
|
|
1dac6d342b | ||
|
|
3b62f5a621 | ||
|
|
4f4fb8a886 | ||
|
|
d57c5d6cf8 | ||
|
|
8a73352ddc | ||
|
|
72e557346c | ||
|
|
853a075e8b | ||
|
|
07c3d8511d | ||
|
|
15956a8711 | ||
|
|
714ed92438 | ||
|
|
bc0be75957 | ||
| 7712ec6661 | |||
|
|
e9093138fa | ||
|
|
dd3b58e061 | ||
|
|
28165e4f4a | ||
|
|
7fc80ab49f | ||
|
|
754639c86d | ||
|
|
3b1112a9fe | ||
|
|
fd9d1add24 | ||
| 5f0da87110 | |||
|
|
b418a503b2 | ||
|
|
1321440cd0 | ||
|
|
6011888e91 | ||
|
|
93e79d1170 | ||
|
|
a6eea6c9e2 | ||
|
|
af11b49e26 | ||
|
|
ddae397cb3 | ||
|
|
c1e8807bda | ||
|
|
bb4d2b9699 | ||
|
|
669b51a6e1 | ||
|
|
856a305c9d | ||
| 9177a38414 | |||
|
|
260641e68c | ||
|
|
1a9081c534 | ||
|
|
bb48c55c2e | ||
|
|
4927b6670d | ||
|
|
b73bfaf590 | ||
|
|
a7f12ad580 | ||
|
|
b047199bc5 | ||
|
|
197aeda2ef | ||
|
|
6196ebfc91 | ||
|
|
864da55cec | ||
|
|
d8ab00970d | ||
|
|
b2c9341342 | ||
| 54de28e0e8 | |||
|
|
f6684e72c0 | ||
|
|
654a7f0fc3 | ||
|
|
767df9e9f2 | ||
|
|
505ab8262c | ||
|
|
b57b835eb3 | ||
| 963c17014c | |||
|
|
7140c7e8d4 | ||
| 8d6434f166 | |||
|
|
850f713310 | ||
|
|
b5b82db532 | ||
|
|
da59168d7b | ||
|
|
38debaad5d | ||
|
|
db127eb24c | ||
|
|
15128bfd50 | ||
|
|
723e25e1a7 | ||
|
|
6e493e9bc7 | ||
|
|
a195fa9231 | ||
| 82e8afc215 | |||
|
|
19cd917c66 | ||
| c816dd39ab | |||
|
|
7f6e4e0ec2 | ||
|
|
220f8ea3ac | ||
|
|
5e4515da7c | ||
|
|
5877b531f9 | ||
|
|
653c535165 | ||
|
|
83bacf0e2f | ||
|
|
812823f2f1 | ||
|
|
6751766b0a | ||
|
|
bc72f09557 | ||
|
|
f987e94fed | ||
|
|
da4cd858fa | ||
|
|
553877bfc6 | ||
|
|
daa0cd072e | ||
|
|
afd4583450 | ||
|
|
f03cd420ef | ||
|
|
e4be744643 | ||
|
|
f2b20aab1a | ||
|
|
accb0533c6 | ||
|
|
0dc273d238 | ||
|
|
56be3ed348 | ||
|
|
bc9c386300 | ||
|
|
7a74c7f81f | ||
|
|
73976a7356 | ||
|
|
0e8c6070ef | ||
|
|
325cf08df0 | ||
|
|
75e4660c58 | ||
|
|
ff8b04f146 | ||
| f0b1e57089 | |||
|
|
1bf550ae9b | ||
|
|
8bcac80818 | ||
|
|
fce60759cf | ||
|
|
d9a40f7d37 | ||
|
|
e7f3728771 | ||
|
|
2462fff34d | ||
|
|
877f844be6 | ||
|
|
06ff8101dc | ||
|
|
91166b021c | ||
|
|
88d23d2745 | ||
|
|
90401dc1ba | ||
| 0e97128a31 | |||
|
|
80ee2faed8 | ||
|
|
6bb2c575b4 | ||
|
|
59e7f4053a | ||
|
|
33b489d526 | ||
|
|
55a7bcc874 | ||
|
|
a078962d3f | ||
|
|
b97d226d44 | ||
|
|
48993eb311 | ||
|
|
11f52258db | ||
|
|
ca33f8ad9d | ||
|
|
209425a908 | ||
|
|
f9a650a4d7 | ||
|
|
4e5da4782f | ||
|
|
c79b610145 | ||
|
|
88c2d7fbcd | ||
|
|
1a6400a6bc | ||
|
|
ab0d8463be | ||
|
|
40df5e5b58 | ||
|
|
a281cea9c5 | ||
|
|
57ed04d955 | ||
|
|
3705e63fde | ||
|
|
d8dec64538 | ||
|
|
bc91fbad79 | ||
|
|
399313eb6d | ||
|
|
dfc3924540 | ||
|
|
e0e578a627 | ||
| e98b45eb3a | |||
|
|
91dc847f56 | ||
|
|
7bba28154d | ||
|
|
a416f76c21 | ||
|
|
e6dd7492a1 | ||
|
|
f4a28d009f | ||
|
|
5e4848c4e2 | ||
|
|
9209739e75 | ||
|
|
4abd7d8d5b | ||
|
|
4412700e12 | ||
|
|
c6b99ab29a | ||
| 8248b1a732 | |||
|
|
e9020dbb2f | ||
|
|
e7471d5c27 | ||
|
|
2c3e432fcf | ||
| ee123a2ffd | |||
|
|
1ff1931864 | ||
|
|
efc55cd3db | ||
| dd77cb3836 | |||
|
|
9a2b12c5dc | ||
|
|
3adbb10ff6 | ||
|
|
fcffb0bb43 | ||
|
|
9d2d4e57b7 | ||
|
|
0499c902a8 | ||
|
|
dab4a3bdf3 | ||
|
|
639ca117f1 | ||
|
|
b9fe222f12 | ||
|
|
cf114fad3c | ||
|
|
47c5676498 | ||
|
|
1e96baca6f | ||
|
|
3c1a090ae3 | ||
|
|
9b6417379b | ||
|
|
4ef942cb9d | ||
|
|
013fb0c67a | ||
|
|
ebc633fb36 | ||
| 6b0c18a41c | |||
|
|
75ce316aa5 | ||
|
|
e4336ce9da | ||
|
|
432b3bda36 | ||
|
|
ae5221c759 | ||
|
|
63c027a454 | ||
|
|
a07ec324fe | ||
|
|
0de34983bb | ||
|
|
ce2a8d88f9 | ||
|
|
9ce08cbb89 | ||
|
|
ff3858f750 | ||
|
|
488a267fc7 | ||
|
|
3f0e243087 | ||
|
|
d5696320f1 | ||
|
|
6a4c2137f7 | ||
|
|
45aaeab973 | ||
|
|
c88fbcdc4e | ||
|
|
66314a0493 | ||
| 88db803b6a | |||
|
|
462d306783 | ||
|
|
842b0eb945 | ||
|
|
4b2b318aff | ||
|
|
c891250946 | ||
|
|
0345e3976f | ||
|
|
9e6f130fa6 | ||
|
|
33e561e537 | ||
|
|
6f1195d907 | ||
|
|
cc32831d99 | ||
|
|
10d604463f | ||
|
|
87ee498af7 | ||
| 1580fadcf3 | |||
|
|
38cc8ba5c2 | ||
|
|
9ed4afb9a8 | ||
| b812282d69 | |||
|
|
8331bde4b0 | ||
|
|
5fca156ff2 | ||
|
|
1c50c0c740 | ||
|
|
09f856958c | ||
|
|
fc2dc21547 | ||
|
|
ccdcf9edeb | ||
|
|
1b20673ff6 | ||
|
|
ce6b6cf7cf | ||
|
|
bac4d340bc | ||
|
|
af1edd9ec6 | ||
| 193a13f2a9 | |||
|
|
72275096f8 | ||
| 9c90a1ca84 | |||
|
|
9aa1ad954f | ||
|
|
e83385d729 | ||
|
|
1cf54fb254 | ||
| 915f15c610 | |||
|
|
241478ed80 | ||
|
|
cd843e8bdd | ||
|
|
df24e89311 | ||
|
|
1226dd986d | ||
|
|
83224cf207 | ||
|
|
26196d34ea | ||
|
|
88db25019f | ||
|
|
40f2cace29 | ||
|
|
efbbe34080 | ||
| 58eec46f72 | |||
|
|
6c4d8e47f9 | ||
|
|
2a34f8225e | ||
| 3899cb3935 | |||
|
|
ceaabee7a0 | ||
| 5593459090 | |||
|
|
2ecefc1e10 | ||
| 4e8a724ef7 | |||
|
|
da406d9538 | ||
| 93594ca4d8 | |||
|
|
3eb54211cb | ||
|
|
b226ca59de | ||
|
|
dba00d6108 | ||
| c3f3149f48 | |||
|
|
d78ba24c5e | ||
| 2b9a0608f3 | |||
|
|
6319d50fb1 | ||
| a2f0abb14c | |||
|
|
d6e74d89b3 | ||
|
|
e1d12d049a | ||
|
|
c286c8012e | ||
| 944a5963ab | |||
|
|
54cbd49171 | ||
| 004940b013 | |||
|
|
852c9013b5 | ||
|
|
94e49306dc | ||
|
|
e6736b78ac | ||
|
|
ab682da1f1 | ||
| 0006f1b6fc | |||
|
|
7c8b6fda2a | ||
| 42e0fc1fce | |||
|
|
a31028401b | ||
|
|
99fbf2bbb7 | ||
|
|
3781b05d72 | ||
|
|
99ee00b225 | ||
|
|
1ba491144b | ||
| e3a482e00f | |||
|
|
1614ef697b | ||
|
|
706851f396 | ||
|
|
86b2e46798 | ||
|
|
cc2898f6ff | ||
| a97c9e2579 | |||
|
|
68948484a4 | ||
|
|
b06a5e692b | ||
|
|
de7aa8c13c | ||
|
|
baf576f5cb | ||
|
|
684615a8a2 | ||
| 7c39d2f042 | |||
|
|
8c86d8d492 | ||
|
|
2c0cbd5bf7 | ||
|
|
5707391864 | ||
|
|
a123ac8c1a | ||
| 155eab1b7d | |||
|
|
9f6832097c | ||
| 0b25c655e5 | |||
|
|
0674056e7e | ||
|
|
d646b5db80 | ||
|
|
c407396b85 | ||
|
|
26f9306d6b | ||
|
|
864a6b1e86 | ||
|
|
29948134eb | ||
|
|
254bed18d0 | ||
|
|
52c0b59a86 | ||
|
|
03fa9c3103 | ||
|
|
1718e8d41b | ||
|
|
1cf4b78075 | ||
|
|
56da99de36 | ||
|
|
6c1a100eb9 | ||
|
|
94d1c677bc | ||
|
|
e7461a4836 | ||
|
|
7a0c09b83f | ||
|
|
88b820b1c3 | ||
|
|
411a569788 | ||
|
|
1ff9539f78 | ||
| 66a6d9e30c | |||
|
|
c7df092d78 | ||
| f52ba6e7fb | |||
|
|
48aea409d8 | ||
|
|
5ad5ea12e6 | ||
|
|
5e045526d6 | ||
| 3ad349c171 | |||
|
|
5c62b6ac96 | ||
| 33c88e7591 | |||
|
|
444abf2255 | ||
|
|
574acf3e87 | ||
| 616a9bcc7a | |||
|
|
b6af238f43 | ||
| ef9a48d850 | |||
|
|
7c3eaeb5a3 | ||
|
|
b0e392fef1 | ||
| 2ebae468c6 | |||
|
|
731d67f324 | ||
|
|
a1d3dd965a | ||
| f325ff49d0 | |||
|
|
fbc0186ea6 | ||
| 913e084127 | |||
|
|
96440104c8 | ||
|
|
60aa0acbe0 | ||
|
|
4cc3083da4 | ||
| 6fa643f6a4 | |||
|
|
8c570288f9 | ||
| ec8e6ee5d2 | |||
| 4284cd9fc5 | |||
|
|
a3b119a953 | ||
|
|
1014475c0f | ||
|
|
354ce47fc4 | ||
|
|
bdb329f7c3 | ||
|
|
b71e2cff3c | ||
|
|
8968cad805 | ||
|
|
e558fdf8f9 | ||
|
|
5dbc17e28d | ||
|
|
57debe4252 | ||
| a5d828b6c1 | |||
|
|
025ab30726 | ||
|
|
1d95eba395 | ||
|
|
f0deab8210 | ||
|
|
7928b87ef5 | ||
|
|
81b1c3dd70 | ||
| 5f07123646 | |||
|
|
395670c3bd | ||
|
|
cb93e3ccc5 | ||
|
|
a8c4eba8d1 | ||
|
|
5c93150a58 | ||
|
|
9e8f9a1932 | ||
|
|
5e40754c68 | ||
|
|
47de6898cd | ||
|
|
381f602e9f | ||
|
|
35fd1782b4 | ||
|
|
8517b1ded2 | ||
|
|
b0d79a26ae | ||
|
|
9059c09d2f | ||
|
|
34401179bd | ||
| 6f86b1e7e9 | |||
|
|
28574b0eb4 | ||
|
|
62b4dc31ab | ||
|
|
f48a18287b | ||
|
|
566deae5af | ||
|
|
5648f4c3d0 | ||
|
|
197927ef31 | ||
|
|
7a5579df7b | ||
|
|
068db991a4 | ||
|
|
a35d05f08a | ||
|
|
ffadc48b4f | ||
|
|
e6af7ed5d5 | ||
|
|
bb8fdf33cf | ||
| d5e95ebcd0 | |||
|
|
8703e7758a | ||
|
|
20189a1d37 | ||
| dff743ca36 | |||
|
|
f541c58fa7 | ||
|
|
1bc0e60235 | ||
|
|
a6607d5882 | ||
|
|
19bc10a1f7 | ||
|
|
9b4f94e1ee | ||
|
|
2aae89acbe | ||
| 84baa755d9 | |||
|
|
911b7c0e3a | ||
|
|
fbde51b8fd | ||
|
|
cdfba3c1a8 | ||
|
|
6f2ac3e22b | ||
|
|
80275c1670 | ||
|
|
c98211f4a2 |
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"architecture": "simplified-5-container",
|
"architecture": "9-container",
|
||||||
"repository": {
|
"repository": {
|
||||||
"host": "gitea",
|
"host": "gitea",
|
||||||
"owner": "egullickson",
|
"owner": "egullickson",
|
||||||
"repo": "motovaultpro",
|
"repo": "motovaultpro",
|
||||||
"url": "https://git.motovaultpro.com"
|
"url": "https://git.motovaultpro.com",
|
||||||
|
"default_branch": "main"
|
||||||
},
|
},
|
||||||
"ai_quick_start": {
|
"ai_quick_start": {
|
||||||
"load_order": [
|
"load_order": [
|
||||||
@@ -51,7 +52,7 @@
|
|||||||
"project_overview": {
|
"project_overview": {
|
||||||
"instruction": "Start with README.md for complete architecture context",
|
"instruction": "Start with README.md for complete architecture context",
|
||||||
"files": ["README.md"],
|
"files": ["README.md"],
|
||||||
"completeness": "100% - all navigation and 5-container architecture information"
|
"completeness": "100% - all navigation and 9-container architecture information"
|
||||||
},
|
},
|
||||||
"application_feature_work": {
|
"application_feature_work": {
|
||||||
"instruction": "Load entire application feature directory (features are modules within backend)",
|
"instruction": "Load entire application feature directory (features are modules within backend)",
|
||||||
@@ -104,6 +105,26 @@
|
|||||||
"type": "cache",
|
"type": "cache",
|
||||||
"description": "Redis cache with AOF persistence",
|
"description": "Redis cache with AOF persistence",
|
||||||
"port": 6379
|
"port": 6379
|
||||||
|
},
|
||||||
|
"mvp-ocr": {
|
||||||
|
"type": "ocr_service",
|
||||||
|
"description": "Python OCR service with pluggable engine abstraction (PaddleOCR PP-OCRv4 primary, optional Google Vision cloud fallback, Tesseract backward compat)",
|
||||||
|
"port": 8000
|
||||||
|
},
|
||||||
|
"mvp-loki": {
|
||||||
|
"type": "log_aggregation",
|
||||||
|
"description": "Grafana Loki for centralized log storage (30-day retention)",
|
||||||
|
"port": 3100
|
||||||
|
},
|
||||||
|
"mvp-alloy": {
|
||||||
|
"type": "log_collector",
|
||||||
|
"description": "Grafana Alloy for log collection and forwarding to Loki",
|
||||||
|
"port": 12345
|
||||||
|
},
|
||||||
|
"mvp-grafana": {
|
||||||
|
"type": "log_visualization",
|
||||||
|
"description": "Grafana for log querying and visualization",
|
||||||
|
"port": 3000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"application_features": {
|
"application_features": {
|
||||||
@@ -290,6 +311,6 @@
|
|||||||
"single_tenant_architecture": true,
|
"single_tenant_architecture": true,
|
||||||
"simplified_deployment": true,
|
"simplified_deployment": true,
|
||||||
"docker_first_development": true,
|
"docker_first_development": true,
|
||||||
"container_count": 5
|
"container_count": 9
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,46 +40,79 @@
|
|||||||
"When moving status, remove the previous status/* label first."
|
"When moving status, remove the previous status/* label first."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"sub_issues": {
|
||||||
|
"when": "Multi-file features (3+ files) or features that benefit from smaller AI context windows.",
|
||||||
|
"parent_issue": "The original feature issue. Tracks overall status. Only the parent gets status label transitions.",
|
||||||
|
"sub_issue_title_format": "{type}: {summary} (#{parent_index})",
|
||||||
|
"sub_issue_body": "First line must be 'Relates to #{parent_index}'. Each sub-issue is a self-contained unit of work.",
|
||||||
|
"sub_issue_labels": "status/in-progress + same type/* as parent. Sub-issues move to in-progress as they are worked on.",
|
||||||
|
"sub_issue_milestone": "Same sprint milestone as parent.",
|
||||||
|
"rules": [
|
||||||
|
"ONE branch for the parent issue. Never create branches per sub-issue.",
|
||||||
|
"ONE PR for the parent issue. The PR closes the parent and all sub-issues.",
|
||||||
|
"Commits reference the specific sub-issue index they implement.",
|
||||||
|
"Sub-issues should be small enough to fit in a single AI context window.",
|
||||||
|
"Plan milestones map 1:1 to sub-issues."
|
||||||
|
],
|
||||||
|
"examples": {
|
||||||
|
"parent": "#105 'feat: Add Grafana dashboards and alerting'",
|
||||||
|
"sub_issues": [
|
||||||
|
"#106 'feat: Grafana dashboard provisioning infrastructure (#105)'",
|
||||||
|
"#107 'feat: Application Overview Grafana dashboard (#105)'"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"branching": {
|
"branching": {
|
||||||
"branch_format": "issue-{index}-{slug}",
|
"branch_format": "issue-{parent_index}-{slug}",
|
||||||
"target_branch": "main",
|
"target_branch": "main",
|
||||||
"example": "issue-42-add-fuel-efficiency-report"
|
"note": "Always use the parent issue index. When sub-issues exist, the branch is for the parent.",
|
||||||
|
"examples": [
|
||||||
|
"issue-42-add-fuel-efficiency-report (standalone issue)",
|
||||||
|
"issue-105-add-grafana-dashboards (parent issue with sub-issues #106-#111)"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"commit_conventions": {
|
"commit_conventions": {
|
||||||
"message_format": "{type}: {short summary} (refs #{index})",
|
"message_format": "{type}: {short summary} (refs #{index})",
|
||||||
"allowed_types": ["feat", "fix", "chore", "docs", "refactor", "test"],
|
"allowed_types": ["feat", "fix", "chore", "docs", "refactor", "test"],
|
||||||
|
"note": "When working on a sub-issue, {index} is the sub-issue number. For standalone issues, {index} is the issue number.",
|
||||||
"examples": [
|
"examples": [
|
||||||
"feat: add fuel efficiency calculation (refs #42)",
|
"feat: add fuel efficiency calculation (refs #42)",
|
||||||
"fix: correct VIN validation for pre-1981 vehicles (refs #1)"
|
"fix: correct VIN validation for pre-1981 vehicles (refs #1)",
|
||||||
|
"feat: add dashboard provisioning infrastructure (refs #106)",
|
||||||
|
"feat: add API performance dashboard (refs #108)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"pull_requests": {
|
"pull_requests": {
|
||||||
"title_format": "{type}: {summary} (#{index})",
|
"title_format": "{type}: {summary} (#{parent_index})",
|
||||||
|
"note": "PR title always uses the parent issue index.",
|
||||||
"body_requirements": [
|
"body_requirements": [
|
||||||
"Link issue(s) using 'Fixes #123' or 'Relates to #123'.",
|
"Link parent issue using 'Fixes #{parent_index}'.",
|
||||||
|
"Link all sub-issues using 'Fixes #{sub_index}' on separate lines.",
|
||||||
"Include test plan and results.",
|
"Include test plan and results.",
|
||||||
"Confirm acceptance criteria completion."
|
"Confirm acceptance criteria completion."
|
||||||
],
|
],
|
||||||
|
"body_example": "Fixes #105\nFixes #106\nFixes #107\nFixes #108\nFixes #109\nFixes #110\nFixes #111",
|
||||||
"merge_policy": "squash_or_rebase_ok",
|
"merge_policy": "squash_or_rebase_ok",
|
||||||
"template_location": ".gitea/PULL_REQUEST_TEMPLATE.md"
|
"template_location": ".gitea/PULL_REQUEST_TEMPLATE.md"
|
||||||
},
|
},
|
||||||
"execution_loop": [
|
"execution_loop": [
|
||||||
"List repo issues in current sprint milestone with status/ready; if none, pull from status/backlog and promote the best candidate to status/ready.",
|
"List repo issues in current sprint milestone with status/ready; if none, pull from status/backlog and promote the best candidate to status/ready.",
|
||||||
"Select one issue (prefer smallest size and highest priority).",
|
"Select one issue (prefer smallest size and highest priority).",
|
||||||
"Move issue to status/in-progress.",
|
"Move parent issue to status/in-progress.",
|
||||||
"[SKILL] Codebase Analysis if unfamiliar area.",
|
"[SKILL] Codebase Analysis if unfamiliar area.",
|
||||||
"[SKILL] Problem Analysis if complex problem.",
|
"[SKILL] Problem Analysis if complex problem.",
|
||||||
"[SKILL] Decision Critic if uncertain approach.",
|
"[SKILL] Decision Critic if uncertain approach.",
|
||||||
"[SKILL] Planner writes plan as issue comment.",
|
"If multi-file feature (3+ files): decompose into sub-issues per sub_issues rules. Each sub-issue = one plan milestone.",
|
||||||
|
"[SKILL] Planner writes plan as parent issue comment. Plan milestones map 1:1 to sub-issues.",
|
||||||
"[SKILL] Plan review cycle: QR plan-completeness -> TW plan-scrub -> QR plan-code -> QR plan-docs.",
|
"[SKILL] Plan review cycle: QR plan-completeness -> TW plan-scrub -> QR plan-code -> QR plan-docs.",
|
||||||
"Create branch issue-{index}-{slug}.",
|
"Create ONE branch issue-{parent_index}-{slug} from main.",
|
||||||
"[SKILL] Planner executes plan, delegates to Developer per milestone.",
|
"[SKILL] Planner executes plan, delegates to Developer per milestone/sub-issue.",
|
||||||
"[SKILL] QR post-implementation per milestone (results in issue comment).",
|
"[SKILL] QR post-implementation per milestone (results in parent issue comment).",
|
||||||
"Open PR targeting main and linking issue(s).",
|
"Open ONE PR targeting main. Title uses parent index. Body lists 'Fixes #N' for parent and all sub-issues.",
|
||||||
"Move issue to status/review.",
|
"Move parent issue to status/review.",
|
||||||
"[SKILL] Quality Agent validates with RULE 0/1/2 (result in issue comment).",
|
"[SKILL] Quality Agent validates with RULE 0/1/2 (result in parent issue comment).",
|
||||||
"If CI/tests fail, iterate until pass.",
|
"If CI/tests fail, iterate until pass.",
|
||||||
"When PR is merged, move issue to status/done and close issue if not auto-closed.",
|
"When PR is merged, parent and all sub-issues move to status/done. Close any not auto-closed.",
|
||||||
"[SKILL] Doc-Sync on affected directories."
|
"[SKILL] Doc-Sync on affected directories."
|
||||||
],
|
],
|
||||||
"skill_integration": {
|
"skill_integration": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
| `role-agents/` | Developer, TW, QR, Debugger agents | Delegating execution |
|
| `role-agents/` | Developer, TW, QR, Debugger agents | Delegating execution |
|
||||||
| `agents/` | Domain agents (Feature, Frontend, Platform, Quality) | Domain-specific work |
|
| `agents/` | Domain agents (Feature, Frontend, Platform, Quality) | Domain-specific work |
|
||||||
| `skills/` | Reusable skills | Complex multi-step workflows |
|
| `skills/` | Reusable skills | Complex multi-step workflows |
|
||||||
|
| `hooks/` | PreToolUse hooks (model enforcement) | Debugging hook behavior |
|
||||||
| `output-styles/` | Output formatting templates | Customizing agent output |
|
| `output-styles/` | Output formatting templates | Customizing agent output |
|
||||||
| `tdd-guard/` | TDD enforcement utilities | Test-driven development |
|
| `tdd-guard/` | TDD enforcement utilities | Test-driven development |
|
||||||
|
|
||||||
@@ -24,4 +25,5 @@
|
|||||||
| `skills/incoherence/` | Detect doc/code drift | Periodic audits |
|
| `skills/incoherence/` | Detect doc/code drift | Periodic audits |
|
||||||
| `skills/prompt-engineer/` | Prompt optimization | Improving AI prompts |
|
| `skills/prompt-engineer/` | Prompt optimization | Improving AI prompts |
|
||||||
| `agents/` | Domain agents (Feature, Frontend, Platform, Quality) | Domain-specific work |
|
| `agents/` | Domain agents (Feature, Frontend, Platform, Quality) | Domain-specific work |
|
||||||
|
| `hooks/` | PreToolUse hooks (model enforcement) | Debugging hook behavior |
|
||||||
| `.ai/workflow-contract.json` | Sprint process, skill integration | Issue workflow |
|
| `.ai/workflow-contract.json` | Sprint process, skill integration | Issue workflow |
|
||||||
|
|||||||
38
.claude/hooks/CLAUDE.md
Normal file
38
.claude/hooks/CLAUDE.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# hooks/
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What | When to read |
|
||||||
|
| ---- | ---- | ------------ |
|
||||||
|
| `enforce-agent-model.sh` | Enforces correct model for Task tool calls | Debugging agent model issues |
|
||||||
|
|
||||||
|
## enforce-agent-model.sh
|
||||||
|
|
||||||
|
PreToolUse hook that ensures Task tool calls use the correct model based on `subagent_type`.
|
||||||
|
|
||||||
|
### Agent Model Mapping
|
||||||
|
|
||||||
|
| Agent | Required Model |
|
||||||
|
|-------|----------------|
|
||||||
|
| feature-agent | sonnet |
|
||||||
|
| first-frontend-agent | sonnet |
|
||||||
|
| platform-agent | sonnet |
|
||||||
|
| quality-agent | sonnet |
|
||||||
|
| developer | sonnet |
|
||||||
|
| technical-writer | sonnet |
|
||||||
|
| debugger | sonnet |
|
||||||
|
| quality-reviewer | opus |
|
||||||
|
| Explore | sonnet |
|
||||||
|
| Plan | sonnet |
|
||||||
|
| Bash | sonnet |
|
||||||
|
| general-purpose | sonnet |
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
- Blocks Task calls where `model` parameter doesn't match expected value
|
||||||
|
- Returns error message instructing Claude to retry with correct model
|
||||||
|
- Unknown agent types are allowed through (no enforcement)
|
||||||
|
|
||||||
|
### Adding New Agents
|
||||||
|
|
||||||
|
Edit the `get_expected_model()` function in `enforce-agent-model.sh` to add new agent mappings.
|
||||||
58
.claude/hooks/enforce-agent-model.sh
Executable file
58
.claude/hooks/enforce-agent-model.sh
Executable file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Enforces correct model usage for Task tool based on agent definitions
|
||||||
|
# Blocks Task calls that don't specify the correct model for the subagent_type
|
||||||
|
|
||||||
|
# Read tool input from stdin
|
||||||
|
INPUT=$(cat)
|
||||||
|
|
||||||
|
# Extract subagent_type and model from the input
|
||||||
|
SUBAGENT_TYPE=$(echo "$INPUT" | jq -r '.subagent_type // empty')
|
||||||
|
MODEL=$(echo "$INPUT" | jq -r '.model // empty')
|
||||||
|
|
||||||
|
# If no subagent_type, allow (not an agent call)
|
||||||
|
if [[ -z "$SUBAGENT_TYPE" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get expected model for agent type
|
||||||
|
# Most agents use sonnet, quality-reviewer uses opus
|
||||||
|
get_expected_model() {
|
||||||
|
case "$1" in
|
||||||
|
# Custom project agents
|
||||||
|
feature-agent|first-frontend-agent|platform-agent|quality-agent)
|
||||||
|
echo "sonnet"
|
||||||
|
;;
|
||||||
|
# Role agents
|
||||||
|
developer|technical-writer|debugger)
|
||||||
|
echo "sonnet"
|
||||||
|
;;
|
||||||
|
quality-reviewer)
|
||||||
|
echo "opus"
|
||||||
|
;;
|
||||||
|
# Built-in agents - default to sonnet for cost efficiency
|
||||||
|
Explore|Plan|Bash|general-purpose)
|
||||||
|
echo "sonnet"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# Unknown agent, no enforcement
|
||||||
|
echo ""
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECTED_MODEL=$(get_expected_model "$SUBAGENT_TYPE")
|
||||||
|
|
||||||
|
# If agent not in mapping, allow (unknown agent type)
|
||||||
|
if [[ -z "$EXPECTED_MODEL" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if model matches expected
|
||||||
|
if [[ "$MODEL" != "$EXPECTED_MODEL" ]]; then
|
||||||
|
echo "BLOCKED: Agent '$SUBAGENT_TYPE' requires model: '$EXPECTED_MODEL' but got '${MODEL:-<not specified>}'."
|
||||||
|
echo "Retry with: model: \"$EXPECTED_MODEL\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Model matches, allow the call
|
||||||
|
exit 0
|
||||||
0
.claude/skills/planner/POST
Normal file
0
.claude/skills/planner/POST
Normal file
0
.claude/skills/planner/SubscriptionPage
Normal file
0
.claude/skills/planner/SubscriptionPage
Normal file
0
.claude/skills/planner/sync
Normal file
0
.claude/skills/planner/sync
Normal file
23
.claude/tdd-guard/data/test.json
Normal file
23
.claude/tdd-guard/data/test.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"testModules": [
|
||||||
|
{
|
||||||
|
"moduleId": "/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/src/features/dashboard/components/__tests__/ActionBar.test.tsx",
|
||||||
|
"tests": [
|
||||||
|
{
|
||||||
|
"name": "Module failed to load (Error)",
|
||||||
|
"fullName": "Module failed to load (Error)",
|
||||||
|
"state": "failed",
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"message": "File not found: tsconfig.json (resolved as: /Users/egullickson/Documents/Technology/coding/motovaultpro/tsconfig.json)",
|
||||||
|
"name": "Error",
|
||||||
|
"stack": "Error: File not found: tsconfig.json (resolved as: /Users/egullickson/Documents/Technology/coding/motovaultpro/tsconfig.json)\n at ConfigSet.resolvePath (/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/node_modules/ts-jest/dist/legacy/config/config-set.js:616:19)\n at ConfigSet._setupConfigSet (/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/node_modules/ts-jest/dist/legacy/config/config-set.js:322:71)\n at new ConfigSet (/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/node_modules/ts-jest/dist/legacy/config/config-set.js:206:14)\n at TsJestTransformer._createConfigSet (/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/node_modules/ts-jest/dist/legacy/ts-jest-transformer.js:119:16)\n at TsJestTransformer._configsFor (/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/node_modules/ts-jest/dist/legacy/ts-jest-transformer.js:98:34)\n at TsJestTransformer.getCacheKey (/Users/egullickson/Documents/Technology/coding/motovaultpro/frontend/node_modules/ts-jest/dist/legacy/ts-jest-transformer.js:249:30)\n at ScriptTransformer._getCacheKey (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/@jest/transform/build/index.js:195:41)\n at ScriptTransformer._getFileCachePath (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/@jest/transform/build/index.js:231:27)\n at ScriptTransformer.transformSource (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/@jest/transform/build/index.js:402:32)\n at ScriptTransformer._transformAndBuildScript (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/@jest/transform/build/index.js:519:40)\n at ScriptTransformer.transform (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/@jest/transform/build/index.js:558:19)\n at Runtime.transformFile (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runtime/build/index.js:1290:53)\n at Runtime._execModule (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runtime/build/index.js:1243:34)\n at Runtime._loadModule (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runtime/build/index.js:944:12)\n at Runtime.requireModule (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runtime/build/index.js:832:12)\n at jestAdapter (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-circus/build/runner.js:84:33)\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at runTestInternal (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runner/build/index.js:275:16)\n at runTest (/Users/egullickson/Documents/Technology/coding/motovaultpro/node_modules/jest-runner/build/index.js:343:7)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unhandledErrors": [],
|
||||||
|
"reason": "failed"
|
||||||
|
}
|
||||||
36
.env.example
Normal file
36
.env.example
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# MotoVaultPro Environment Configuration
|
||||||
|
# Copy to .env and fill in environment-specific values
|
||||||
|
# Generated .env files should NOT be committed to version control
|
||||||
|
#
|
||||||
|
# Local dev: No .env needed -- base docker-compose.yml defaults are sandbox values
|
||||||
|
# Staging/Production: CI/CD generates .env from Gitea variables + generate-log-config.sh
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Stripe Price IDs (environment-specific)
|
||||||
|
# ===========================================
|
||||||
|
# Sandbox defaults used for local development
|
||||||
|
STRIPE_PRO_MONTHLY_PRICE_ID=price_1T1ZHMJXoKkh5RcKwKSSGIlR
|
||||||
|
STRIPE_PRO_YEARLY_PRICE_ID=price_1T1ZHnJXoKkh5RcKWlG2MPpX
|
||||||
|
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_1T1ZIBJXoKkh5RcKu2jyhqBN
|
||||||
|
STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_1T1ZIQJXoKkh5RcK34YXiJQm
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Stripe Publishable Key (baked into frontend at build time)
|
||||||
|
# ===========================================
|
||||||
|
# VITE_STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Log Levels (generated by scripts/ci/generate-log-config.sh)
|
||||||
|
# ===========================================
|
||||||
|
# Run: ./scripts/ci/generate-log-config.sh DEBUG >> .env
|
||||||
|
#
|
||||||
|
# BACKEND_LOG_LEVEL=debug
|
||||||
|
# TRAEFIK_LOG_LEVEL=DEBUG
|
||||||
|
# POSTGRES_LOG_STATEMENT=all
|
||||||
|
# POSTGRES_LOG_MIN_DURATION=0
|
||||||
|
# REDIS_LOGLEVEL=debug
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Grafana
|
||||||
|
# ===========================================
|
||||||
|
# GRAFANA_ADMIN_PASSWORD=admin
|
||||||
14
.gitea/CLAUDE.md
Normal file
14
.gitea/CLAUDE.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# .gitea/
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What | When to read |
|
||||||
|
| ---- | ---- | ------------ |
|
||||||
|
| `PULL_REQUEST_TEMPLATE.md` | PR template | Creating pull requests |
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
|
||||||
|
| Directory | What | When to read |
|
||||||
|
| --------- | ---- | ------------ |
|
||||||
|
| `workflows/` | CI/CD workflow definitions | Pipeline configuration |
|
||||||
|
| `ISSUE_TEMPLATE/` | Issue templates (bug, feature, chore) | Creating issues |
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# SPRINTS.md — MotoVaultPro Sprint Calendar (2026)
|
|
||||||
|
|
||||||
**Cadence:** 2 weeks (14 days)
|
|
||||||
**Sprint weeks:** Monday → Sunday
|
|
||||||
**Naming convention:** `Sprint YYYY-MM-DD` (the Monday start date)
|
|
||||||
|
|
||||||
> Note: Sprint 26 ends on **2027-01-03** (it crosses into the next year).
|
|
||||||
|
|
||||||
| # | Sprint | Start (Mon) | End (Sun) |
|
|
||||||
|---:|---|---|---|
|
|
||||||
| 1 | Sprint 2026-01-05 | 2026-01-05 | 2026-01-18 |
|
|
||||||
| 2 | Sprint 2026-01-19 | 2026-01-19 | 2026-02-01 |
|
|
||||||
| 3 | Sprint 2026-02-02 | 2026-02-02 | 2026-02-15 |
|
|
||||||
| 4 | Sprint 2026-02-16 | 2026-02-16 | 2026-03-01 |
|
|
||||||
| 5 | Sprint 2026-03-02 | 2026-03-02 | 2026-03-15 |
|
|
||||||
| 6 | Sprint 2026-03-16 | 2026-03-16 | 2026-03-29 |
|
|
||||||
| 7 | Sprint 2026-03-30 | 2026-03-30 | 2026-04-12 |
|
|
||||||
| 8 | Sprint 2026-04-13 | 2026-04-13 | 2026-04-26 |
|
|
||||||
| 9 | Sprint 2026-04-27 | 2026-04-27 | 2026-05-10 |
|
|
||||||
| 10 | Sprint 2026-05-11 | 2026-05-11 | 2026-05-24 |
|
|
||||||
| 11 | Sprint 2026-05-25 | 2026-05-25 | 2026-06-07 |
|
|
||||||
| 12 | Sprint 2026-06-08 | 2026-06-08 | 2026-06-21 |
|
|
||||||
| 13 | Sprint 2026-06-22 | 2026-06-22 | 2026-07-05 |
|
|
||||||
| 14 | Sprint 2026-07-06 | 2026-07-06 | 2026-07-19 |
|
|
||||||
| 15 | Sprint 2026-07-20 | 2026-07-20 | 2026-08-02 |
|
|
||||||
| 16 | Sprint 2026-08-03 | 2026-08-03 | 2026-08-16 |
|
|
||||||
| 17 | Sprint 2026-08-17 | 2026-08-17 | 2026-08-30 |
|
|
||||||
| 18 | Sprint 2026-08-31 | 2026-08-31 | 2026-09-13 |
|
|
||||||
| 19 | Sprint 2026-09-14 | 2026-09-14 | 2026-09-27 |
|
|
||||||
| 20 | Sprint 2026-09-28 | 2026-09-28 | 2026-10-11 |
|
|
||||||
| 21 | Sprint 2026-10-12 | 2026-10-12 | 2026-10-25 |
|
|
||||||
| 22 | Sprint 2026-10-26 | 2026-10-26 | 2026-11-08 |
|
|
||||||
| 23 | Sprint 2026-11-09 | 2026-11-09 | 2026-11-22 |
|
|
||||||
| 24 | Sprint 2026-11-23 | 2026-11-23 | 2026-12-06 |
|
|
||||||
| 25 | Sprint 2026-12-07 | 2026-12-07 | 2026-12-20 |
|
|
||||||
| 26 | Sprint 2026-12-21 | 2026-12-21 | 2027-01-03 |
|
|
||||||
@@ -19,9 +19,11 @@ on:
|
|||||||
env:
|
env:
|
||||||
REGISTRY: git.motovaultpro.com
|
REGISTRY: git.motovaultpro.com
|
||||||
DEPLOY_PATH: /opt/motovaultpro
|
DEPLOY_PATH: /opt/motovaultpro
|
||||||
COMPOSE_FILE: docker-compose.yml
|
BASE_COMPOSE_FILE: docker-compose.yml
|
||||||
COMPOSE_BLUE_GREEN: docker-compose.blue-green.yml
|
COMPOSE_BLUE_GREEN: docker-compose.blue-green.yml
|
||||||
HEALTH_CHECK_TIMEOUT: "60"
|
COMPOSE_PROD: docker-compose.prod.yml
|
||||||
|
HEALTH_CHECK_TIMEOUT: "240"
|
||||||
|
LOG_LEVEL: INFO
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -34,6 +36,7 @@ jobs:
|
|||||||
target_stack: ${{ steps.determine-stack.outputs.target_stack }}
|
target_stack: ${{ steps.determine-stack.outputs.target_stack }}
|
||||||
backend_image: ${{ steps.set-images.outputs.backend_image }}
|
backend_image: ${{ steps.set-images.outputs.backend_image }}
|
||||||
frontend_image: ${{ steps.set-images.outputs.frontend_image }}
|
frontend_image: ${{ steps.set-images.outputs.frontend_image }}
|
||||||
|
ocr_image: ${{ steps.set-images.outputs.ocr_image }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check Docker availability
|
- name: Check Docker availability
|
||||||
run: |
|
run: |
|
||||||
@@ -53,6 +56,7 @@ jobs:
|
|||||||
TAG="${{ inputs.image_tag }}"
|
TAG="${{ inputs.image_tag }}"
|
||||||
echo "backend_image=$REGISTRY/egullickson/backend:$TAG" >> $GITHUB_OUTPUT
|
echo "backend_image=$REGISTRY/egullickson/backend:$TAG" >> $GITHUB_OUTPUT
|
||||||
echo "frontend_image=$REGISTRY/egullickson/frontend:$TAG" >> $GITHUB_OUTPUT
|
echo "frontend_image=$REGISTRY/egullickson/frontend:$TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "ocr_image=$REGISTRY/egullickson/ocr:$TAG" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Determine target stack
|
- name: Determine target stack
|
||||||
id: determine-stack
|
id: determine-stack
|
||||||
@@ -83,6 +87,7 @@ jobs:
|
|||||||
TARGET_STACK: ${{ needs.validate.outputs.target_stack }}
|
TARGET_STACK: ${{ needs.validate.outputs.target_stack }}
|
||||||
BACKEND_IMAGE: ${{ needs.validate.outputs.backend_image }}
|
BACKEND_IMAGE: ${{ needs.validate.outputs.backend_image }}
|
||||||
FRONTEND_IMAGE: ${{ needs.validate.outputs.frontend_image }}
|
FRONTEND_IMAGE: ${{ needs.validate.outputs.frontend_image }}
|
||||||
|
OCR_IMAGE: ${{ needs.validate.outputs.ocr_image }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout scripts, config, and compose files
|
- name: Checkout scripts, config, and compose files
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -90,8 +95,11 @@ jobs:
|
|||||||
sparse-checkout: |
|
sparse-checkout: |
|
||||||
scripts/
|
scripts/
|
||||||
config/
|
config/
|
||||||
|
secrets/app/google-wif-config.json
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
docker-compose.blue-green.yml
|
docker-compose.blue-green.yml
|
||||||
|
docker-compose.prod.yml
|
||||||
|
.env.example
|
||||||
sparse-checkout-cone-mode: false
|
sparse-checkout-cone-mode: false
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
@@ -101,6 +109,27 @@ jobs:
|
|||||||
rsync -av --delete "$GITHUB_WORKSPACE/scripts/" "$DEPLOY_PATH/scripts/"
|
rsync -av --delete "$GITHUB_WORKSPACE/scripts/" "$DEPLOY_PATH/scripts/"
|
||||||
cp "$GITHUB_WORKSPACE/docker-compose.yml" "$DEPLOY_PATH/"
|
cp "$GITHUB_WORKSPACE/docker-compose.yml" "$DEPLOY_PATH/"
|
||||||
cp "$GITHUB_WORKSPACE/docker-compose.blue-green.yml" "$DEPLOY_PATH/"
|
cp "$GITHUB_WORKSPACE/docker-compose.blue-green.yml" "$DEPLOY_PATH/"
|
||||||
|
cp "$GITHUB_WORKSPACE/docker-compose.prod.yml" "$DEPLOY_PATH/"
|
||||||
|
# WIF credential config (not a secret -- references Auth0 token script path)
|
||||||
|
# Remove any Docker-created directory artifact from failed bind mounts
|
||||||
|
rm -rf "$DEPLOY_PATH/secrets/app/google-wif-config.json"
|
||||||
|
mkdir -p "$DEPLOY_PATH/secrets/app"
|
||||||
|
cp "$GITHUB_WORKSPACE/secrets/app/google-wif-config.json" "$DEPLOY_PATH/secrets/app/"
|
||||||
|
|
||||||
|
- name: Generate environment configuration
|
||||||
|
run: |
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
{
|
||||||
|
echo "# Generated by CI/CD - DO NOT EDIT"
|
||||||
|
echo "STRIPE_PRO_MONTHLY_PRICE_ID=${{ vars.STRIPE_PRO_MONTHLY_PRICE_ID }}"
|
||||||
|
echo "STRIPE_PRO_YEARLY_PRICE_ID=${{ vars.STRIPE_PRO_YEARLY_PRICE_ID }}"
|
||||||
|
echo "STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${{ vars.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID }}"
|
||||||
|
echo "STRIPE_ENTERPRISE_YEARLY_PRICE_ID=${{ vars.STRIPE_ENTERPRISE_YEARLY_PRICE_ID }}"
|
||||||
|
echo "VITE_STRIPE_PUBLISHABLE_KEY=${{ vars.VITE_STRIPE_PUBLISHABLE_KEY }}"
|
||||||
|
echo "GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}"
|
||||||
|
} > .env
|
||||||
|
chmod +x scripts/ci/generate-log-config.sh
|
||||||
|
./scripts/ci/generate-log-config.sh "$LOG_LEVEL" >> .env
|
||||||
|
|
||||||
- name: Login to registry
|
- name: Login to registry
|
||||||
run: |
|
run: |
|
||||||
@@ -108,17 +137,22 @@ jobs:
|
|||||||
|
|
||||||
- name: Inject secrets
|
- name: Inject secrets
|
||||||
run: |
|
run: |
|
||||||
chmod +x "$GITHUB_WORKSPACE/scripts/inject-secrets.sh"
|
cd "$DEPLOY_PATH"
|
||||||
"$GITHUB_WORKSPACE/scripts/inject-secrets.sh"
|
chmod +x scripts/inject-secrets.sh
|
||||||
|
SECRETS_DIR="$DEPLOY_PATH/secrets/app" ./scripts/inject-secrets.sh
|
||||||
env:
|
env:
|
||||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }}
|
AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }}
|
||||||
AUTH0_MANAGEMENT_CLIENT_ID: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_ID }}
|
AUTH0_MANAGEMENT_CLIENT_ID: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_ID }}
|
||||||
AUTH0_MANAGEMENT_CLIENT_SECRET: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_SECRET }}
|
AUTH0_MANAGEMENT_CLIENT_SECRET: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_SECRET }}
|
||||||
|
AUTH0_OCR_CLIENT_ID: ${{ secrets.AUTH0_OCR_CLIENT_ID }}
|
||||||
|
AUTH0_OCR_CLIENT_SECRET: ${{ secrets.AUTH0_OCR_CLIENT_SECRET }}
|
||||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||||
GOOGLE_MAPS_MAP_ID: ${{ secrets.GOOGLE_MAPS_MAP_ID }}
|
GOOGLE_MAPS_MAP_ID: ${{ secrets.GOOGLE_MAPS_MAP_ID }}
|
||||||
CF_DNS_API_TOKEN: ${{ secrets.CF_DNS_API_TOKEN }}
|
CF_DNS_API_TOKEN: ${{ secrets.CF_DNS_API_TOKEN }}
|
||||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
|
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||||
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||||
|
|
||||||
- name: Initialize data directories
|
- name: Initialize data directories
|
||||||
run: |
|
run: |
|
||||||
@@ -136,6 +170,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker pull $BACKEND_IMAGE
|
docker pull $BACKEND_IMAGE
|
||||||
docker pull $FRONTEND_IMAGE
|
docker pull $FRONTEND_IMAGE
|
||||||
|
docker pull $OCR_IMAGE
|
||||||
|
|
||||||
- name: Record expected image IDs
|
- name: Record expected image IDs
|
||||||
id: expected-images
|
id: expected-images
|
||||||
@@ -148,18 +183,50 @@ jobs:
|
|||||||
echo "frontend_id=$FRONTEND_ID" >> $GITHUB_OUTPUT
|
echo "frontend_id=$FRONTEND_ID" >> $GITHUB_OUTPUT
|
||||||
echo "backend_id=$BACKEND_ID" >> $GITHUB_OUTPUT
|
echo "backend_id=$BACKEND_ID" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Start shared services
|
||||||
|
run: |
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
# Start shared infrastructure services (database, cache, logging)
|
||||||
|
# --no-recreate prevents restarting postgres/redis when config files change
|
||||||
|
# These must persist across blue-green deployments to avoid data service disruption
|
||||||
|
docker compose -f $BASE_COMPOSE_FILE -f $COMPOSE_BLUE_GREEN -f $COMPOSE_PROD up -d --no-recreate \
|
||||||
|
mvp-postgres mvp-redis mvp-loki mvp-alloy mvp-grafana
|
||||||
|
|
||||||
|
- name: Wait for shared services health
|
||||||
|
run: |
|
||||||
|
echo "Waiting for PostgreSQL and Redis to be healthy..."
|
||||||
|
for service in mvp-postgres mvp-redis; do
|
||||||
|
for i in $(seq 1 24); do
|
||||||
|
health=$(docker inspect --format='{{.State.Health.Status}}' $service 2>/dev/null || echo "unknown")
|
||||||
|
if [ "$health" = "healthy" ]; then
|
||||||
|
echo "OK: $service is healthy"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ $i -eq 24 ]; then
|
||||||
|
echo "ERROR: $service health check timed out (status: $health)"
|
||||||
|
docker logs $service --tail 50 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Waiting for $service... (attempt $i/24, status: $health)"
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
done
|
||||||
|
echo "All shared services healthy"
|
||||||
|
|
||||||
- name: Start target stack
|
- name: Start target stack
|
||||||
run: |
|
run: |
|
||||||
cd "$DEPLOY_PATH"
|
cd "$DEPLOY_PATH"
|
||||||
export BACKEND_IMAGE=$BACKEND_IMAGE
|
export BACKEND_IMAGE=$BACKEND_IMAGE
|
||||||
export FRONTEND_IMAGE=$FRONTEND_IMAGE
|
export FRONTEND_IMAGE=$FRONTEND_IMAGE
|
||||||
|
export OCR_IMAGE=$OCR_IMAGE
|
||||||
# --force-recreate ensures containers are recreated even if image tag is same
|
# --force-recreate ensures containers are recreated even if image tag is same
|
||||||
# This prevents stale container content when image digest changes
|
# This prevents stale container content when image digest changes
|
||||||
docker compose -f $COMPOSE_FILE -f $COMPOSE_BLUE_GREEN up -d --force-recreate \
|
# Start shared OCR service and target stack
|
||||||
mvp-frontend-$TARGET_STACK mvp-backend-$TARGET_STACK
|
docker compose -f $BASE_COMPOSE_FILE -f $COMPOSE_BLUE_GREEN -f $COMPOSE_PROD up -d --force-recreate \
|
||||||
|
mvp-ocr mvp-frontend-$TARGET_STACK mvp-backend-$TARGET_STACK
|
||||||
|
|
||||||
- name: Wait for stack initialization
|
- name: Wait for stack initialization
|
||||||
run: sleep 10
|
run: sleep 5
|
||||||
|
|
||||||
- name: Verify container images
|
- name: Verify container images
|
||||||
run: |
|
run: |
|
||||||
@@ -194,7 +261,7 @@ jobs:
|
|||||||
- name: Start Traefik
|
- name: Start Traefik
|
||||||
run: |
|
run: |
|
||||||
cd "$DEPLOY_PATH"
|
cd "$DEPLOY_PATH"
|
||||||
docker compose -f $COMPOSE_FILE -f $COMPOSE_BLUE_GREEN up -d mvp-traefik
|
docker compose -f $BASE_COMPOSE_FILE -f $COMPOSE_BLUE_GREEN -f $COMPOSE_PROD up -d mvp-traefik
|
||||||
|
|
||||||
- name: Wait for Traefik
|
- name: Wait for Traefik
|
||||||
run: |
|
run: |
|
||||||
@@ -238,22 +305,79 @@ jobs:
|
|||||||
- name: Wait for routing propagation
|
- name: Wait for routing propagation
|
||||||
run: sleep 5
|
run: sleep 5
|
||||||
|
|
||||||
|
- name: Check container status and health
|
||||||
|
run: |
|
||||||
|
for service in mvp-frontend-$TARGET_STACK mvp-backend-$TARGET_STACK mvp-ocr; do
|
||||||
|
status=$(docker inspect --format='{{.State.Status}}' $service 2>/dev/null || echo "not found")
|
||||||
|
if [ "$status" != "running" ]; then
|
||||||
|
echo "ERROR: $service is not running (status: $status)"
|
||||||
|
docker logs $service --tail 50 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK: $service is running"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Wait for Docker healthchecks to complete (services with healthcheck defined)
|
||||||
|
echo ""
|
||||||
|
echo "Waiting for Docker healthchecks..."
|
||||||
|
for service in mvp-frontend-$TARGET_STACK mvp-backend-$TARGET_STACK mvp-ocr; do
|
||||||
|
# Check if service has a healthcheck defined
|
||||||
|
has_healthcheck=$(docker inspect --format='{{if .Config.Healthcheck}}true{{else}}false{{end}}' $service 2>/dev/null || echo "false")
|
||||||
|
if [ "$has_healthcheck" = "true" ]; then
|
||||||
|
# 48 attempts x 5 seconds = 4 minutes max wait (backend with fresh migrations can take ~3 min)
|
||||||
|
for i in $(seq 1 48); do
|
||||||
|
health=$(docker inspect --format='{{.State.Health.Status}}' $service 2>/dev/null || echo "unknown")
|
||||||
|
if [ "$health" = "healthy" ]; then
|
||||||
|
echo "OK: $service is healthy"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
# Don't fail immediately on unhealthy - container may still be starting up
|
||||||
|
# and can recover. Let the timeout handle truly broken containers.
|
||||||
|
if [ $i -eq 48 ]; then
|
||||||
|
echo "ERROR: $service health check timed out (status: $health)"
|
||||||
|
docker logs $service --tail 100 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Waiting for $service healthcheck... (attempt $i/48, status: $health)"
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "SKIP: $service has no healthcheck defined"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Wait for backend health
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
if docker exec mvp-backend-$TARGET_STACK curl -sf http://localhost:3001/health > /dev/null 2>&1; then
|
||||||
|
echo "OK: Backend health check passed"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ $i -eq 12 ]; then
|
||||||
|
echo "ERROR: Backend health check failed after 12 attempts"
|
||||||
|
docker logs mvp-backend-$TARGET_STACK --tail 100
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Attempt $i/12: Backend not ready, waiting 5s..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
- name: External health check
|
- name: External health check
|
||||||
run: |
|
run: |
|
||||||
REQUIRED_FEATURES='["admin","auth","onboarding","vehicles","documents","fuel-logs","stations","maintenance","platform","notifications","user-profile","user-preferences","user-export"]'
|
REQUIRED_FEATURES='["admin","auth","onboarding","vehicles","documents","fuel-logs","stations","maintenance","platform","notifications","user-profile","user-preferences","user-export"]'
|
||||||
|
|
||||||
for i in 1 2 3 4 5 6; do
|
for i in $(seq 1 12); do
|
||||||
RESPONSE=$(curl -sf https://motovaultpro.com/api/health 2>/dev/null) || {
|
RESPONSE=$(curl -sf https://motovaultpro.com/api/health 2>/dev/null) || {
|
||||||
echo "Attempt $i/6: Connection failed, waiting 10s..."
|
echo "Attempt $i/12: Connection failed, waiting 5s..."
|
||||||
sleep 10
|
sleep 5
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check status is "healthy"
|
# Check status is "healthy"
|
||||||
STATUS=$(echo "$RESPONSE" | jq -r '.status')
|
STATUS=$(echo "$RESPONSE" | jq -r '.status')
|
||||||
if [ "$STATUS" != "healthy" ]; then
|
if [ "$STATUS" != "healthy" ]; then
|
||||||
echo "Attempt $i/6: Status is '$STATUS', not 'healthy'. Waiting 10s..."
|
echo "Attempt $i/12: Status is '$STATUS', not 'healthy'. Waiting 5s..."
|
||||||
sleep 10
|
sleep 5
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -263,8 +387,8 @@ jobs:
|
|||||||
')
|
')
|
||||||
|
|
||||||
if [ -n "$MISSING" ]; then
|
if [ -n "$MISSING" ]; then
|
||||||
echo "Attempt $i/6: Missing features: $MISSING. Waiting 10s..."
|
echo "Attempt $i/12: Missing features: $MISSING. Waiting 5s..."
|
||||||
sleep 10
|
sleep 5
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -273,7 +397,7 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "ERROR: Production health check failed after 6 attempts"
|
echo "ERROR: Production health check failed after 12 attempts"
|
||||||
echo "Last response: $RESPONSE"
|
echo "Last response: $RESPONSE"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ on:
|
|||||||
env:
|
env:
|
||||||
REGISTRY: git.motovaultpro.com
|
REGISTRY: git.motovaultpro.com
|
||||||
DEPLOY_PATH: /opt/motovaultpro
|
DEPLOY_PATH: /opt/motovaultpro
|
||||||
COMPOSE_FILE: docker-compose.yml
|
BASE_COMPOSE_FILE: docker-compose.yml
|
||||||
COMPOSE_STAGING: docker-compose.staging.yml
|
STAGING_COMPOSE_FILE: docker-compose.staging.yml
|
||||||
HEALTH_CHECK_TIMEOUT: "60"
|
HEALTH_CHECK_TIMEOUT: "60"
|
||||||
|
LOG_LEVEL: DEBUG
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -29,6 +30,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
backend_image: ${{ steps.tags.outputs.backend_image }}
|
backend_image: ${{ steps.tags.outputs.backend_image }}
|
||||||
frontend_image: ${{ steps.tags.outputs.frontend_image }}
|
frontend_image: ${{ steps.tags.outputs.frontend_image }}
|
||||||
|
ocr_image: ${{ steps.tags.outputs.ocr_image }}
|
||||||
short_sha: ${{ steps.tags.outputs.short_sha }}
|
short_sha: ${{ steps.tags.outputs.short_sha }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -45,6 +47,7 @@ jobs:
|
|||||||
SHORT_SHA="${SHORT_SHA:0:7}"
|
SHORT_SHA="${SHORT_SHA:0:7}"
|
||||||
echo "backend_image=$REGISTRY/egullickson/backend:$SHORT_SHA" >> $GITHUB_OUTPUT
|
echo "backend_image=$REGISTRY/egullickson/backend:$SHORT_SHA" >> $GITHUB_OUTPUT
|
||||||
echo "frontend_image=$REGISTRY/egullickson/frontend:$SHORT_SHA" >> $GITHUB_OUTPUT
|
echo "frontend_image=$REGISTRY/egullickson/frontend:$SHORT_SHA" >> $GITHUB_OUTPUT
|
||||||
|
echo "ocr_image=$REGISTRY/egullickson/ocr:$SHORT_SHA" >> $GITHUB_OUTPUT
|
||||||
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build backend image
|
- name: Build backend image
|
||||||
@@ -67,18 +70,32 @@ jobs:
|
|||||||
--build-arg VITE_AUTH0_CLIENT_ID=${{ vars.VITE_AUTH0_CLIENT_ID }} \
|
--build-arg VITE_AUTH0_CLIENT_ID=${{ vars.VITE_AUTH0_CLIENT_ID }} \
|
||||||
--build-arg VITE_AUTH0_AUDIENCE=${{ vars.VITE_AUTH0_AUDIENCE }} \
|
--build-arg VITE_AUTH0_AUDIENCE=${{ vars.VITE_AUTH0_AUDIENCE }} \
|
||||||
--build-arg VITE_API_BASE_URL=/api \
|
--build-arg VITE_API_BASE_URL=/api \
|
||||||
|
--build-arg VITE_STRIPE_PUBLISHABLE_KEY=${{ vars.VITE_STRIPE_PUBLISHABLE_KEY }} \
|
||||||
--cache-from $REGISTRY/egullickson/frontend:latest \
|
--cache-from $REGISTRY/egullickson/frontend:latest \
|
||||||
-t ${{ steps.tags.outputs.frontend_image }} \
|
-t ${{ steps.tags.outputs.frontend_image }} \
|
||||||
-t $REGISTRY/egullickson/frontend:latest \
|
-t $REGISTRY/egullickson/frontend:latest \
|
||||||
-f frontend/Dockerfile \
|
-f frontend/Dockerfile \
|
||||||
frontend
|
frontend
|
||||||
|
|
||||||
|
- name: Build OCR image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||||
|
--build-arg REGISTRY_MIRRORS=$REGISTRY/egullickson/mirrors \
|
||||||
|
--cache-from $REGISTRY/egullickson/ocr:latest \
|
||||||
|
-t ${{ steps.tags.outputs.ocr_image }} \
|
||||||
|
-t $REGISTRY/egullickson/ocr:latest \
|
||||||
|
-f ocr/Dockerfile \
|
||||||
|
ocr
|
||||||
|
|
||||||
- name: Push images
|
- name: Push images
|
||||||
run: |
|
run: |
|
||||||
docker push ${{ steps.tags.outputs.backend_image }}
|
docker push ${{ steps.tags.outputs.backend_image }}
|
||||||
docker push ${{ steps.tags.outputs.frontend_image }}
|
docker push ${{ steps.tags.outputs.frontend_image }}
|
||||||
|
docker push ${{ steps.tags.outputs.ocr_image }}
|
||||||
docker push $REGISTRY/egullickson/backend:latest
|
docker push $REGISTRY/egullickson/backend:latest
|
||||||
docker push $REGISTRY/egullickson/frontend:latest
|
docker push $REGISTRY/egullickson/frontend:latest
|
||||||
|
docker push $REGISTRY/egullickson/ocr:latest
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# DEPLOY STAGING - Deploy to staging server
|
# DEPLOY STAGING - Deploy to staging server
|
||||||
@@ -90,10 +107,38 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
BACKEND_IMAGE: ${{ needs.build.outputs.backend_image }}
|
BACKEND_IMAGE: ${{ needs.build.outputs.backend_image }}
|
||||||
FRONTEND_IMAGE: ${{ needs.build.outputs.frontend_image }}
|
FRONTEND_IMAGE: ${{ needs.build.outputs.frontend_image }}
|
||||||
|
OCR_IMAGE: ${{ needs.build.outputs.ocr_image }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Sync config, scripts, and compose files to deploy path
|
||||||
|
run: |
|
||||||
|
rsync -av --delete "$GITHUB_WORKSPACE/config/" "$DEPLOY_PATH/config/"
|
||||||
|
rsync -av --delete "$GITHUB_WORKSPACE/scripts/" "$DEPLOY_PATH/scripts/"
|
||||||
|
cp "$GITHUB_WORKSPACE/docker-compose.yml" "$DEPLOY_PATH/"
|
||||||
|
cp "$GITHUB_WORKSPACE/docker-compose.staging.yml" "$DEPLOY_PATH/"
|
||||||
|
# WIF credential config (not a secret -- references Auth0 token script path)
|
||||||
|
# Remove any Docker-created directory artifact from failed bind mounts
|
||||||
|
rm -rf "$DEPLOY_PATH/secrets/app/google-wif-config.json"
|
||||||
|
mkdir -p "$DEPLOY_PATH/secrets/app"
|
||||||
|
cp "$GITHUB_WORKSPACE/secrets/app/google-wif-config.json" "$DEPLOY_PATH/secrets/app/"
|
||||||
|
|
||||||
|
- name: Generate environment configuration
|
||||||
|
run: |
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
{
|
||||||
|
echo "# Generated by CI/CD - DO NOT EDIT"
|
||||||
|
echo "STRIPE_PRO_MONTHLY_PRICE_ID=${{ vars.STRIPE_PRO_MONTHLY_PRICE_ID }}"
|
||||||
|
echo "STRIPE_PRO_YEARLY_PRICE_ID=${{ vars.STRIPE_PRO_YEARLY_PRICE_ID }}"
|
||||||
|
echo "STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${{ vars.STRIPE_ENTERPRISE_MONTHLY_PRICE_ID }}"
|
||||||
|
echo "STRIPE_ENTERPRISE_YEARLY_PRICE_ID=${{ vars.STRIPE_ENTERPRISE_YEARLY_PRICE_ID }}"
|
||||||
|
echo "VITE_STRIPE_PUBLISHABLE_KEY=${{ vars.VITE_STRIPE_PUBLISHABLE_KEY }}"
|
||||||
|
echo "GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}"
|
||||||
|
} > .env
|
||||||
|
chmod +x scripts/ci/generate-log-config.sh
|
||||||
|
./scripts/ci/generate-log-config.sh "$LOG_LEVEL" >> .env
|
||||||
|
|
||||||
- name: Login to registry
|
- name: Login to registry
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login -u "${{ secrets.REGISTRY_USER }}" --password-stdin "$REGISTRY"
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login -u "${{ secrets.REGISTRY_USER }}" --password-stdin "$REGISTRY"
|
||||||
@@ -108,10 +153,14 @@ jobs:
|
|||||||
AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }}
|
AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }}
|
||||||
AUTH0_MANAGEMENT_CLIENT_ID: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_ID }}
|
AUTH0_MANAGEMENT_CLIENT_ID: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_ID }}
|
||||||
AUTH0_MANAGEMENT_CLIENT_SECRET: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_SECRET }}
|
AUTH0_MANAGEMENT_CLIENT_SECRET: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_SECRET }}
|
||||||
|
AUTH0_OCR_CLIENT_ID: ${{ secrets.AUTH0_OCR_CLIENT_ID }}
|
||||||
|
AUTH0_OCR_CLIENT_SECRET: ${{ secrets.AUTH0_OCR_CLIENT_SECRET }}
|
||||||
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
|
||||||
GOOGLE_MAPS_MAP_ID: ${{ secrets.GOOGLE_MAPS_MAP_ID }}
|
GOOGLE_MAPS_MAP_ID: ${{ secrets.GOOGLE_MAPS_MAP_ID }}
|
||||||
CF_DNS_API_TOKEN: ${{ secrets.CF_DNS_API_TOKEN }}
|
CF_DNS_API_TOKEN: ${{ secrets.CF_DNS_API_TOKEN }}
|
||||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
|
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||||
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||||
|
|
||||||
- name: Initialize data directories
|
- name: Initialize data directories
|
||||||
run: |
|
run: |
|
||||||
@@ -129,17 +178,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker pull $BACKEND_IMAGE
|
docker pull $BACKEND_IMAGE
|
||||||
docker pull $FRONTEND_IMAGE
|
docker pull $FRONTEND_IMAGE
|
||||||
|
docker pull $OCR_IMAGE
|
||||||
|
|
||||||
- name: Deploy staging stack
|
- name: Deploy staging stack
|
||||||
run: |
|
run: |
|
||||||
cd "$DEPLOY_PATH"
|
cd "$DEPLOY_PATH"
|
||||||
export BACKEND_IMAGE=$BACKEND_IMAGE
|
export BACKEND_IMAGE=$BACKEND_IMAGE
|
||||||
export FRONTEND_IMAGE=$FRONTEND_IMAGE
|
export FRONTEND_IMAGE=$FRONTEND_IMAGE
|
||||||
docker compose -f $COMPOSE_FILE -f $COMPOSE_STAGING down --timeout 30 || true
|
export OCR_IMAGE=$OCR_IMAGE
|
||||||
docker compose -f $COMPOSE_FILE -f $COMPOSE_STAGING up -d
|
docker compose -f $BASE_COMPOSE_FILE -f $STAGING_COMPOSE_FILE down --timeout 30 || true
|
||||||
|
docker compose -f $BASE_COMPOSE_FILE -f $STAGING_COMPOSE_FILE up -d
|
||||||
|
|
||||||
- name: Wait for services
|
- name: Wait for services
|
||||||
run: sleep 15
|
run: sleep 5
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# VERIFY STAGING - Health checks
|
# VERIFY STAGING - Health checks
|
||||||
@@ -154,7 +205,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Check container status and health
|
- name: Check container status and health
|
||||||
run: |
|
run: |
|
||||||
for service in mvp-frontend-staging mvp-backend-staging mvp-postgres-staging mvp-redis-staging; do
|
for service in mvp-frontend-staging mvp-backend-staging mvp-ocr-staging mvp-postgres-staging mvp-redis-staging; do
|
||||||
status=$(docker inspect --format='{{.State.Status}}' $service 2>/dev/null || echo "not found")
|
status=$(docker inspect --format='{{.State.Status}}' $service 2>/dev/null || echo "not found")
|
||||||
if [ "$status" != "running" ]; then
|
if [ "$status" != "running" ]; then
|
||||||
echo "ERROR: $service is not running (status: $status)"
|
echo "ERROR: $service is not running (status: $status)"
|
||||||
@@ -167,26 +218,25 @@ jobs:
|
|||||||
# Wait for Docker healthchecks to complete (services with healthcheck defined)
|
# Wait for Docker healthchecks to complete (services with healthcheck defined)
|
||||||
echo ""
|
echo ""
|
||||||
echo "Waiting for Docker healthchecks..."
|
echo "Waiting for Docker healthchecks..."
|
||||||
for service in mvp-frontend-staging mvp-backend-staging mvp-postgres-staging mvp-redis-staging; do
|
for service in mvp-frontend-staging mvp-backend-staging mvp-ocr-staging mvp-postgres-staging mvp-redis-staging; do
|
||||||
# Check if service has a healthcheck defined
|
# Check if service has a healthcheck defined
|
||||||
has_healthcheck=$(docker inspect --format='{{if .Config.Healthcheck}}true{{else}}false{{end}}' $service 2>/dev/null || echo "false")
|
has_healthcheck=$(docker inspect --format='{{if .Config.Healthcheck}}true{{else}}false{{end}}' $service 2>/dev/null || echo "false")
|
||||||
if [ "$has_healthcheck" = "true" ]; then
|
if [ "$has_healthcheck" = "true" ]; then
|
||||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
# 48 attempts x 5 seconds = 4 minutes max wait (backend with fresh migrations can take ~3 min)
|
||||||
|
for i in $(seq 1 48); do
|
||||||
health=$(docker inspect --format='{{.State.Health.Status}}' $service 2>/dev/null || echo "unknown")
|
health=$(docker inspect --format='{{.State.Health.Status}}' $service 2>/dev/null || echo "unknown")
|
||||||
if [ "$health" = "healthy" ]; then
|
if [ "$health" = "healthy" ]; then
|
||||||
echo "OK: $service is healthy"
|
echo "OK: $service is healthy"
|
||||||
break
|
break
|
||||||
elif [ "$health" = "unhealthy" ]; then
|
|
||||||
echo "ERROR: $service is unhealthy"
|
|
||||||
docker logs $service --tail 50 2>/dev/null || true
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
if [ $i -eq 10 ]; then
|
# Don't fail immediately on unhealthy - container may still be starting up
|
||||||
|
# and can recover. Let the timeout handle truly broken containers.
|
||||||
|
if [ $i -eq 48 ]; then
|
||||||
echo "ERROR: $service health check timed out (status: $health)"
|
echo "ERROR: $service health check timed out (status: $health)"
|
||||||
docker logs $service --tail 50 2>/dev/null || true
|
docker logs $service --tail 100 2>/dev/null || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Waiting for $service healthcheck... (attempt $i/10, status: $health)"
|
echo "Waiting for $service healthcheck... (attempt $i/48, status: $health)"
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
else
|
else
|
||||||
@@ -196,36 +246,36 @@ jobs:
|
|||||||
|
|
||||||
- name: Wait for backend health
|
- name: Wait for backend health
|
||||||
run: |
|
run: |
|
||||||
for i in 1 2 3 4 5 6; do
|
for i in $(seq 1 12); do
|
||||||
if docker exec mvp-backend-staging curl -sf http://localhost:3001/health > /dev/null 2>&1; then
|
if docker exec mvp-backend-staging curl -sf http://localhost:3001/health > /dev/null 2>&1; then
|
||||||
echo "OK: Backend health check passed"
|
echo "OK: Backend health check passed"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [ $i -eq 6 ]; then
|
if [ $i -eq 12 ]; then
|
||||||
echo "ERROR: Backend health check failed after 6 attempts"
|
echo "ERROR: Backend health check failed after 12 attempts"
|
||||||
docker logs mvp-backend-staging --tail 100
|
docker logs mvp-backend-staging --tail 100
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Attempt $i/6: Backend not ready, waiting 10s..."
|
echo "Attempt $i/12: Backend not ready, waiting 5s..."
|
||||||
sleep 10
|
sleep 5
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Check external endpoint
|
- name: Check external endpoint
|
||||||
run: |
|
run: |
|
||||||
REQUIRED_FEATURES='["admin","auth","onboarding","vehicles","documents","fuel-logs","stations","maintenance","platform","notifications","user-profile","user-preferences","user-export"]'
|
REQUIRED_FEATURES='["admin","auth","onboarding","vehicles","documents","fuel-logs","stations","maintenance","platform","notifications","user-profile","user-preferences","user-export"]'
|
||||||
|
|
||||||
for i in 1 2 3 4 5 6; do
|
for i in $(seq 1 12); do
|
||||||
RESPONSE=$(curl -sf https://staging.motovaultpro.com/api/health 2>/dev/null) || {
|
RESPONSE=$(curl -sf https://staging.motovaultpro.com/api/health 2>/dev/null) || {
|
||||||
echo "Attempt $i/6: Connection failed, waiting 10s..."
|
echo "Attempt $i/12: Connection failed, waiting 5s..."
|
||||||
sleep 10
|
sleep 5
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check status is "healthy"
|
# Check status is "healthy"
|
||||||
STATUS=$(echo "$RESPONSE" | jq -r '.status')
|
STATUS=$(echo "$RESPONSE" | jq -r '.status')
|
||||||
if [ "$STATUS" != "healthy" ]; then
|
if [ "$STATUS" != "healthy" ]; then
|
||||||
echo "Attempt $i/6: Status is '$STATUS', not 'healthy'. Waiting 10s..."
|
echo "Attempt $i/12: Status is '$STATUS', not 'healthy'. Waiting 5s..."
|
||||||
sleep 10
|
sleep 5
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -235,8 +285,8 @@ jobs:
|
|||||||
')
|
')
|
||||||
|
|
||||||
if [ -n "$MISSING" ]; then
|
if [ -n "$MISSING" ]; then
|
||||||
echo "Attempt $i/6: Missing features: $MISSING. Waiting 10s..."
|
echo "Attempt $i/12: Missing features: $MISSING. Waiting 5s..."
|
||||||
sleep 10
|
sleep 5
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -245,7 +295,7 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "ERROR: Staging health check failed after 6 attempts"
|
echo "ERROR: Staging health check failed after 12 attempts"
|
||||||
echo "Last response: $RESPONSE"
|
echo "Last response: $RESPONSE"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,6 +2,7 @@ node_modules/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.backup
|
.env.backup
|
||||||
|
.env.logging
|
||||||
dist/
|
dist/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -12,12 +13,16 @@ coverage/
|
|||||||
*.swo
|
*.swo
|
||||||
.venv
|
.venv
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
# K8s-aligned secret mounts (real files ignored; examples committed)
|
# K8s-aligned secret mounts (real files ignored; examples committed)
|
||||||
secrets/**
|
secrets/**
|
||||||
!secrets/
|
!secrets/
|
||||||
!secrets/**/
|
!secrets/**/
|
||||||
!secrets/**/*.example
|
!secrets/**/*.example
|
||||||
|
!secrets/app/google-wif-config.json
|
||||||
|
|
||||||
# Traefik ACME certificates (contains private keys)
|
# Traefik ACME certificates (contains private keys)
|
||||||
data/traefik/acme.json
|
data/traefik/acme.json
|
||||||
35
CLAUDE.md
35
CLAUDE.md
@@ -1,6 +1,6 @@
|
|||||||
# MotoVaultPro
|
# MotoVaultPro
|
||||||
|
|
||||||
Single-tenant vehicle management application with 5-container architecture (Traefik, Frontend, Backend, PostgreSQL, Redis).
|
Single-tenant vehicle management application with 9-container architecture (6 application: Traefik, Frontend, Backend, OCR, PostgreSQL, Redis + 3 logging: Loki, Alloy, Grafana).
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
@@ -8,6 +8,9 @@ Single-tenant vehicle management application with 5-container architecture (Trae
|
|||||||
| ---- | ---- | ------------ |
|
| ---- | ---- | ------------ |
|
||||||
| `Makefile` | Build, test, deploy commands | Running any make command |
|
| `Makefile` | Build, test, deploy commands | Running any make command |
|
||||||
| `docker-compose.yml` | Development container orchestration | Local development setup |
|
| `docker-compose.yml` | Development container orchestration | Local development setup |
|
||||||
|
| `docker-compose.staging.yml` | Staging container orchestration | Staging deployment |
|
||||||
|
| `docker-compose.prod.yml` | Production container orchestration | Production deployment |
|
||||||
|
| `docker-compose.blue-green.yml` | Blue-green deployment orchestration | Zero-downtime deploys |
|
||||||
| `package.json` | Root workspace dependencies | Dependency management |
|
| `package.json` | Root workspace dependencies | Dependency management |
|
||||||
| `README.md` | Project overview | First-time setup |
|
| `README.md` | Project overview | First-time setup |
|
||||||
|
|
||||||
@@ -17,19 +20,23 @@ Single-tenant vehicle management application with 5-container architecture (Trae
|
|||||||
| --------- | ---- | ------------ |
|
| --------- | ---- | ------------ |
|
||||||
| `backend/` | Fastify API server with feature capsules | Backend development |
|
| `backend/` | Fastify API server with feature capsules | Backend development |
|
||||||
| `frontend/` | React/Vite SPA with MUI | Frontend development |
|
| `frontend/` | React/Vite SPA with MUI | Frontend development |
|
||||||
|
| `ocr/` | Python OCR microservice (Tesseract) | OCR pipeline, receipt/VIN extraction |
|
||||||
| `docs/` | Project documentation hub | Architecture, APIs, testing |
|
| `docs/` | Project documentation hub | Architecture, APIs, testing |
|
||||||
| `config/` | Configuration files (Traefik, monitoring) | Infrastructure setup |
|
| `config/` | Configuration files (Traefik, logging stack) | Infrastructure setup |
|
||||||
| `scripts/` | Utility scripts (backup, deploy) | Automation tasks |
|
| `scripts/` | Utility scripts (backup, deploy, CI) | Automation tasks |
|
||||||
| `.ai/` | AI context and workflow contracts | AI-assisted development |
|
| `.ai/` | AI context and workflow contracts | AI-assisted development |
|
||||||
| `.claude/` | Claude Code agents and skills | Delegating to agents, using skills |
|
| `.claude/` | Claude Code agents and skills | Delegating to agents, using skills |
|
||||||
| `.gitea/` | Gitea workflows and templates | CI/CD, issue templates |
|
| `.gitea/` | Gitea workflows and templates | CI/CD, issue templates |
|
||||||
| `ansible/` | Ansible deployment playbooks | Server provisioning |
|
| `ansible/` | Ansible deployment playbooks | Server provisioning |
|
||||||
|
| `certs/` | TLS certificates | SSL/TLS configuration |
|
||||||
|
| `secrets/` | Docker secrets (Stripe keys, Traefik) | Secret management |
|
||||||
|
| `data/` | Persistent data volumes (backups, documents) | Storage paths, volume mounts |
|
||||||
|
|
||||||
## Build
|
## Build for staging and production. NOT FOR DEVELOPMENT
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make setup # First-time setup (builds containers, runs migrations)
|
make setup # First-time setup
|
||||||
make rebuild # Rebuild containers after changes
|
make rebuild # Rebuild containers
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test
|
## Test
|
||||||
@@ -167,13 +174,23 @@ Issues are the source of truth. See `.ai/workflow-contract.json` for complete wo
|
|||||||
- Every PR must link to at least one issue
|
- Every PR must link to at least one issue
|
||||||
- Use Gitea MCP tools for issue/label/branch/PR operations
|
- Use Gitea MCP tools for issue/label/branch/PR operations
|
||||||
- Labels: `status/backlog` -> `status/ready` -> `status/in-progress` -> `status/review` -> `status/done`
|
- Labels: `status/backlog` -> `status/ready` -> `status/in-progress` -> `status/review` -> `status/done`
|
||||||
- Branches: `issue-{index}-{slug}` (e.g., `issue-42-add-fuel-report`)
|
- Branches: `issue-{parent_index}-{slug}` (e.g., `issue-42-add-fuel-report`)
|
||||||
- Commits: `{type}: {summary} (refs #{index})` (e.g., `feat: add fuel report (refs #42)`)
|
- Commits: `{type}: {summary} (refs #{index})` (e.g., `feat: add fuel report (refs #42)`)
|
||||||
|
|
||||||
|
### Sub-Issue Decomposition
|
||||||
|
Multi-file features (3+ files) must be broken into sub-issues for smaller AI context windows:
|
||||||
|
- **Sub-issue title**: `{type}: {summary} (#{parent_index})` -- parent index in title
|
||||||
|
- **Sub-issue body**: First line `Relates to #{parent_index}`
|
||||||
|
- **ONE branch** per parent issue only. Never branch per sub-issue.
|
||||||
|
- **ONE PR** per parent issue. Body lists `Fixes #N` for parent and every sub-issue.
|
||||||
|
- **Commits** reference the specific sub-issue: `feat: add dashboard (refs #107)`
|
||||||
|
- **Status labels** tracked on parent only. Sub-issues stay `status/backlog`.
|
||||||
|
- **Plan milestones** map 1:1 to sub-issues.
|
||||||
|
|
||||||
## Architecture Context for AI
|
## Architecture Context for AI
|
||||||
|
|
||||||
### Simplified 5-Container Architecture
|
### 9-Container Architecture
|
||||||
**MotoVaultPro uses a simplified architecture:** A single-tenant application with 5 containers - Traefik, Frontend, Backend, PostgreSQL, and Redis. Application features in `backend/src/features/[name]/` are self-contained modules within the backend service, including the platform feature for vehicle data and VIN decoding.
|
**MotoVaultPro uses a unified architecture:** A single-tenant application with 9 containers - 6 application (Traefik, Frontend, Backend, OCR, PostgreSQL, Redis) + 3 logging (Loki, Alloy, Grafana). Application features in `backend/src/features/[name]/` are self-contained modules within the backend service, including the platform feature for vehicle data and VIN decoding. See `docs/LOGGING.md` for unified logging system documentation.
|
||||||
|
|
||||||
### Key Principles for AI Understanding
|
### Key Principles for AI Understanding
|
||||||
- **Feature Capsule Organization**: Application features are self-contained modules within the backend
|
- **Feature Capsule Organization**: Application features are self-contained modules within the backend
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -1,17 +1,17 @@
|
|||||||
# MotoVaultPro — Simplified Architecture
|
# MotoVaultPro — Simplified Architecture
|
||||||
|
|
||||||
Simplified 5-container architecture with integrated platform feature.
|
9-container architecture (6 application + 3 logging) with integrated platform feature.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
- Mobile + Desktop: Implement and test every feature on both.
|
- Mobile + Desktop: Implement and test every feature on both.
|
||||||
- Docker-first, production-only: All testing and validation in containers.
|
- Docker-first, production-only: All testing and validation in containers.
|
||||||
- See `CLAUDE.md` for development partnership guidelines.
|
- See `CLAUDE.md` for development partnership guidelines.
|
||||||
|
|
||||||
## Quick Start (containers)
|
## Staging and Production Commands. NOT FOR DEVELOPMENT (containers)
|
||||||
```bash
|
```bash
|
||||||
make setup # build + start + migrate (uses mvp-* containers)
|
make setup # build + start + migrate (uses mvp-* containers)
|
||||||
make start # start 5 services
|
make start # start 5 services
|
||||||
make rebuild # rebuild on changes
|
make rebuild #
|
||||||
make logs # tail all logs
|
make logs # tail all logs
|
||||||
make migrate # run DB migrations
|
make migrate # run DB migrations
|
||||||
```
|
```
|
||||||
@@ -245,6 +245,15 @@ make migrate # run DB migrations
|
|||||||
Commits: {type}: {summary} (refs #{N}) | Types: feat, fix, chore, docs, refactor, test
|
Commits: {type}: {summary} (refs #{N}) | Types: feat, fix, chore, docs, refactor, test
|
||||||
Branches: issue-{N}-{slug} | Example: issue-42-add-fuel-report
|
Branches: issue-{N}-{slug} | Example: issue-42-add-fuel-report
|
||||||
|
|
||||||
|
SUB-ISSUE PATTERN (multi-file features)
|
||||||
|
----------------------------------------
|
||||||
|
Parent: #105 "feat: Add Grafana dashboards"
|
||||||
|
Sub: #106 "feat: Dashboard provisioning (#105)" <-- parent index in title
|
||||||
|
Branch: issue-105-add-grafana-dashboards <-- ONE branch, parent index
|
||||||
|
Commit: feat: add provisioning (refs #106) <-- refs specific sub-issue
|
||||||
|
PR: feat: Add Grafana dashboards (#105) <-- ONE PR, parent index
|
||||||
|
Body: Fixes #105, Fixes #106, Fixes #107... <-- closes all
|
||||||
|
|
||||||
QUALITY RULES
|
QUALITY RULES
|
||||||
-------------
|
-------------
|
||||||
RULE 0 (CRITICAL): Production reliability - unhandled errors, security, resource exhaustion
|
RULE 0 (CRITICAL): Production reliability - unhandled errors, security, resource exhaustion
|
||||||
|
|||||||
11
ansible/CLAUDE.md
Normal file
11
ansible/CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# ansible/
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What | When to read |
|
||||||
|
| ---- | ---- | ------------ |
|
||||||
|
| `deploy-production-runner.yml` | Production runner deployment | Production deployments |
|
||||||
|
| `deploy-staging-runner.yml` | Staging runner deployment | Staging deployments |
|
||||||
|
| `inventory.yml` | Server inventory | Server host configuration |
|
||||||
|
| `inventory.yml.example` | Example inventory template | Setting up new environments |
|
||||||
|
| `config.yaml.j2` | Jinja2 config template | Runner configuration |
|
||||||
@@ -269,24 +269,17 @@
|
|||||||
when: gitea_registry_token is defined
|
when: gitea_registry_token is defined
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Maintenance Scripts
|
# Remove Legacy Docker Cleanup (was destroying volumes)
|
||||||
# ============================================
|
# ============================================
|
||||||
- name: Create Docker cleanup script
|
- name: Remove legacy Docker cleanup cron job
|
||||||
copy:
|
|
||||||
dest: /usr/local/bin/docker-cleanup.sh
|
|
||||||
content: |
|
|
||||||
#!/bin/bash
|
|
||||||
# Remove unused Docker resources older than 7 days
|
|
||||||
docker system prune -af --filter "until=168h"
|
|
||||||
docker volume prune -f
|
|
||||||
mode: '0755'
|
|
||||||
|
|
||||||
- name: Schedule Docker cleanup cron job
|
|
||||||
cron:
|
cron:
|
||||||
name: "Docker cleanup"
|
name: "Docker cleanup"
|
||||||
minute: "0"
|
state: absent
|
||||||
hour: "3"
|
|
||||||
job: "/usr/local/bin/docker-cleanup.sh >> /var/log/docker-cleanup.log 2>&1"
|
- name: Remove legacy Docker cleanup script
|
||||||
|
file:
|
||||||
|
path: /usr/local/bin/docker-cleanup.sh
|
||||||
|
state: absent
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Production-Specific Security Hardening
|
# Production-Specific Security Hardening
|
||||||
|
|||||||
@@ -300,24 +300,17 @@
|
|||||||
when: gitea_registry_token is defined
|
when: gitea_registry_token is defined
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Maintenance Scripts
|
# Remove Legacy Docker Cleanup (was destroying volumes)
|
||||||
# ============================================
|
# ============================================
|
||||||
- name: Create Docker cleanup script
|
- name: Remove legacy Docker cleanup cron job
|
||||||
copy:
|
|
||||||
dest: /usr/local/bin/docker-cleanup.sh
|
|
||||||
content: |
|
|
||||||
#!/bin/bash
|
|
||||||
# Remove unused Docker resources older than 7 days
|
|
||||||
docker system prune -af --filter "until=168h"
|
|
||||||
docker volume prune -f
|
|
||||||
mode: '0755'
|
|
||||||
|
|
||||||
- name: Schedule Docker cleanup cron job
|
|
||||||
cron:
|
cron:
|
||||||
name: "Docker cleanup"
|
name: "Docker cleanup"
|
||||||
minute: "0"
|
state: absent
|
||||||
hour: "3"
|
|
||||||
job: "/usr/local/bin/docker-cleanup.sh >> /var/log/docker-cleanup.log 2>&1"
|
- name: Remove legacy Docker cleanup script
|
||||||
|
file:
|
||||||
|
path: /usr/local/bin/docker-cleanup.sh
|
||||||
|
state: absent
|
||||||
|
|
||||||
handlers:
|
handlers:
|
||||||
- name: Restart act_runner
|
- name: Restart act_runner
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
| `README.md` | Backend quickstart and commands | Getting started with backend development |
|
| `README.md` | Backend quickstart and commands | Getting started with backend development |
|
||||||
| `package.json` | Dependencies and npm scripts | Adding dependencies, understanding build |
|
| `package.json` | Dependencies and npm scripts | Adding dependencies, understanding build |
|
||||||
| `tsconfig.json` | TypeScript configuration | Compiler settings, path aliases |
|
| `tsconfig.json` | TypeScript configuration | Compiler settings, path aliases |
|
||||||
| `jest.config.ts` | Jest test configuration | Test setup, coverage settings |
|
| `eslint.config.js` | ESLint configuration | Linting rules, code style |
|
||||||
|
| `jest.config.js` | Jest test configuration | Test setup, coverage settings |
|
||||||
| `Dockerfile` | Container build definition | Docker builds, deployment |
|
| `Dockerfile` | Container build definition | Docker builds, deployment |
|
||||||
|
|
||||||
## Subdirectories
|
## Subdirectories
|
||||||
@@ -15,4 +16,4 @@
|
|||||||
| Directory | What | When to read |
|
| Directory | What | When to read |
|
||||||
| --------- | ---- | ------------ |
|
| --------- | ---- | ------------ |
|
||||||
| `src/` | Application source code | Any backend development |
|
| `src/` | Application source code | Any backend development |
|
||||||
| `scripts/` | Utility scripts | Database scripts, automation |
|
| `scripts/` | Utility scripts (docker-entrypoint) | Container startup, automation |
|
||||||
|
|||||||
538
backend/package-lock.json
generated
538
backend/package-lock.json
generated
@@ -20,21 +20,26 @@
|
|||||||
"fastify": "^5.2.0",
|
"fastify": "^5.2.0",
|
||||||
"fastify-plugin": "^5.0.1",
|
"fastify-plugin": "^5.0.1",
|
||||||
"file-type": "^16.5.4",
|
"file-type": "^16.5.4",
|
||||||
|
"form-data": "^4.0.0",
|
||||||
"get-jwks": "^11.0.3",
|
"get-jwks": "^11.0.3",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
|
"mailparser": "^3.9.3",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"opossum": "^8.0.0",
|
"opossum": "^8.0.0",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
|
"pino": "^9.6.0",
|
||||||
"resend": "^3.0.0",
|
"resend": "^3.0.0",
|
||||||
|
"stripe": "^20.2.0",
|
||||||
|
"svix": "^1.85.0",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"winston": "^3.17.0",
|
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
"@types/jest": "^29.5.10",
|
"@types/jest": "^29.5.10",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/opossum": "^8.0.0",
|
"@types/opossum": "^8.0.0",
|
||||||
@@ -81,7 +86,6 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -577,15 +581,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@colors/colors": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.1.90"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@cspotcode/source-map-support": {
|
"node_modules/@cspotcode/source-map-support": {
|
||||||
"version": "0.8.1",
|
"version": "0.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||||
@@ -610,17 +605,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@dabh/diagnostics": {
|
|
||||||
"version": "2.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz",
|
|
||||||
"integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@so-ric/colorspace": "^1.1.6",
|
|
||||||
"enabled": "2.0.x",
|
|
||||||
"kuler": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.0",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
||||||
@@ -1784,15 +1768,11 @@
|
|||||||
"@sinonjs/commons": "^3.0.0"
|
"@sinonjs/commons": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@so-ric/colorspace": {
|
"node_modules/@stablelib/base64": {
|
||||||
"version": "1.1.6",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
"integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==",
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"dependencies": {
|
|
||||||
"color": "^5.0.2",
|
|
||||||
"text-hex": "1.0.x"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@tokenizer/token": {
|
"node_modules/@tokenizer/token": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
@@ -1949,6 +1929,30 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/methods": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||||
@@ -1960,9 +1964,8 @@
|
|||||||
"version": "22.19.3",
|
"version": "22.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
||||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -2027,12 +2030,6 @@
|
|||||||
"@types/superagent": "^8.1.0"
|
"@types/superagent": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/triple-beam": {
|
|
||||||
"version": "1.3.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
|
|
||||||
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
@@ -2095,7 +2092,6 @@
|
|||||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.50.0",
|
"@typescript-eslint/scope-manager": "8.50.0",
|
||||||
"@typescript-eslint/types": "8.50.0",
|
"@typescript-eslint/types": "8.50.0",
|
||||||
@@ -2307,6 +2303,17 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"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": {
|
"node_modules/abbrev": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
|
||||||
@@ -2340,7 +2347,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2513,12 +2519,6 @@
|
|||||||
"safer-buffer": "^2.1.0"
|
"safer-buffer": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/async": {
|
|
||||||
"version": "3.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
|
||||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -2813,7 +2813,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -2899,7 +2898,6 @@
|
|||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -3092,19 +3090,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/color": {
|
|
||||||
"version": "5.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
|
||||||
"integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-convert": "^3.1.3",
|
|
||||||
"color-string": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -3123,48 +3108,6 @@
|
|||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/color-string": {
|
|
||||||
"version": "2.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
|
|
||||||
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-name": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-string/node_modules/color-name": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color/node_modules/color-convert": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"color-name": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color/node_modules/color-name": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -3566,11 +3509,14 @@
|
|||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/enabled": {
|
"node_modules/encoding-japanese": {
|
||||||
"version": "2.0.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
|
||||||
"integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==",
|
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
@@ -3668,7 +3614,6 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -4002,6 +3947,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||||
@@ -4079,6 +4030,49 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fastify/node_modules/pino": {
|
||||||
|
"version": "10.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
|
||||||
|
"integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pinojs/redact": "^0.4.0",
|
||||||
|
"atomic-sleep": "^1.0.0",
|
||||||
|
"on-exit-leak-free": "^2.1.0",
|
||||||
|
"pino-abstract-transport": "^3.0.0",
|
||||||
|
"pino-std-serializers": "^7.0.0",
|
||||||
|
"process-warning": "^5.0.0",
|
||||||
|
"quick-format-unescaped": "^4.0.3",
|
||||||
|
"real-require": "^0.2.0",
|
||||||
|
"safe-stable-stringify": "^2.3.1",
|
||||||
|
"sonic-boom": "^4.0.1",
|
||||||
|
"thread-stream": "^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pino": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fastify/node_modules/pino-abstract-transport": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fastify/node_modules/thread-stream": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"real-require": "^0.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fastparallel": {
|
"node_modules/fastparallel": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
|
||||||
@@ -4118,12 +4112,6 @@
|
|||||||
"bser": "2.1.1"
|
"bser": "2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fecha": {
|
|
||||||
"version": "4.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
|
|
||||||
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -4219,12 +4207,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/fn.name": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.11",
|
"version": "1.15.11",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
@@ -4579,6 +4561,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/helmet": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||||
@@ -4651,6 +4642,22 @@
|
|||||||
"node": ">=10.17.0"
|
"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": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -4884,6 +4891,7 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -4990,7 +4998,6 @@
|
|||||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/core": "^29.7.0",
|
"@jest/core": "^29.7.0",
|
||||||
"@jest/types": "^29.6.3",
|
"@jest/types": "^29.6.3",
|
||||||
@@ -5773,12 +5780,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/kuler": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/leac": {
|
"node_modules/leac": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||||
@@ -5812,6 +5813,42 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/light-my-request": {
|
||||||
"version": "6.6.0",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||||
@@ -5856,6 +5893,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -5898,28 +5944,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/logform": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@colors/colors": "1.6.0",
|
|
||||||
"@types/triple-beam": "^1.3.2",
|
|
||||||
"fecha": "^4.2.0",
|
|
||||||
"ms": "^2.1.1",
|
|
||||||
"safe-stable-stringify": "^2.3.1",
|
|
||||||
"triple-beam": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
@@ -5936,6 +5966,24 @@
|
|||||||
"node": "20 || >=22"
|
"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": {
|
"node_modules/make-dir": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
@@ -6164,6 +6212,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.11",
|
"version": "3.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
||||||
@@ -6258,7 +6315,6 @@
|
|||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -6292,15 +6348,6 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/one-time": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fn.name": "1.x.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/onetime": {
|
"node_modules/onetime": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||||
@@ -6522,7 +6569,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.9.1",
|
"pg-connection-string": "^2.9.1",
|
||||||
"pg-pool": "^3.10.1",
|
"pg-pool": "^3.10.1",
|
||||||
@@ -6628,9 +6674,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pino": {
|
"node_modules/pino": {
|
||||||
"version": "10.1.0",
|
"version": "9.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||||
"integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==",
|
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinojs/redact": "^0.4.0",
|
"@pinojs/redact": "^0.4.0",
|
||||||
@@ -6888,6 +6934,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/pure-rand": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
@@ -6906,10 +6961,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
@@ -6976,20 +7030,6 @@
|
|||||||
"integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==",
|
"integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/readable-stream": {
|
|
||||||
"version": "3.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
|
||||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"inherits": "^2.0.3",
|
|
||||||
"string_decoder": "^1.1.1",
|
|
||||||
"util-deprecate": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readable-web-to-node-stream": {
|
"node_modules/readable-web-to-node-stream": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz",
|
||||||
@@ -7244,6 +7284,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
}
|
}
|
||||||
@@ -7319,7 +7360,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -7339,7 +7379,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -7356,7 +7395,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@@ -7375,7 +7413,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@@ -7474,15 +7511,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/stack-trace": {
|
|
||||||
"version": "0.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
|
||||||
"integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/stack-utils": {
|
"node_modules/stack-utils": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||||
@@ -7512,6 +7540,16 @@
|
|||||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/steed": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
|
||||||
@@ -7635,6 +7673,26 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stripe": {
|
||||||
|
"version": "20.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.2.0.tgz",
|
||||||
|
"integrity": "sha512-m8niTfdm3nPP/yQswRWMwQxqEUcTtB3RTJQ9oo6NINDzgi7aPOadsH/fPXIIfL1Sc5+lqQFKSk7WiO6CXmvaeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"qs": "^6.14.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strtok3": {
|
"node_modules/strtok3": {
|
||||||
"version": "6.3.0",
|
"version": "6.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
|
||||||
@@ -7713,6 +7771,29 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.2",
|
"version": "7.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz",
|
||||||
@@ -7753,12 +7834,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/text-hex": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/thread-stream": {
|
"node_modules/thread-stream": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
||||||
@@ -7809,7 +7884,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -7817,6 +7891,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/tmpl": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||||
@@ -7873,15 +7956,6 @@
|
|||||||
"nodetouch": "bin/nodetouch.js"
|
"nodetouch": "bin/nodetouch.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/triple-beam": {
|
|
||||||
"version": "1.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
|
|
||||||
"integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||||
@@ -7967,7 +8041,6 @@
|
|||||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cspotcode/source-map-support": "^0.8.0",
|
"@cspotcode/source-map-support": "^0.8.0",
|
||||||
"@tsconfig/node10": "^1.0.7",
|
"@tsconfig/node10": "^1.0.7",
|
||||||
@@ -8055,7 +8128,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -8088,6 +8160,12 @@
|
|||||||
"typescript": ">=4.8.4 <6.0.0"
|
"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": {
|
"node_modules/uglify-js": {
|
||||||
"version": "3.19.3",
|
"version": "3.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||||
@@ -8156,12 +8234,6 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/util-deprecate": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "8.3.2",
|
"version": "8.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
@@ -8218,42 +8290,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/winston": {
|
|
||||||
"version": "3.19.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
|
||||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@colors/colors": "^1.6.0",
|
|
||||||
"@dabh/diagnostics": "^2.0.8",
|
|
||||||
"async": "^3.2.3",
|
|
||||||
"is-stream": "^2.0.0",
|
|
||||||
"logform": "^2.7.0",
|
|
||||||
"one-time": "^1.0.0",
|
|
||||||
"readable-stream": "^3.4.0",
|
|
||||||
"safe-stable-stringify": "^2.3.1",
|
|
||||||
"stack-trace": "0.0.x",
|
|
||||||
"triple-beam": "^1.3.0",
|
|
||||||
"winston-transport": "^4.9.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/winston-transport": {
|
|
||||||
"version": "4.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",
|
|
||||||
"integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"logform": "^2.7.0",
|
|
||||||
"readable-stream": "^3.6.2",
|
|
||||||
"triple-beam": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
@@ -18,45 +18,50 @@
|
|||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg": "^8.13.1",
|
"@fastify/autoload": "^6.0.1",
|
||||||
"ioredis": "^5.4.2",
|
|
||||||
"@fastify/multipart": "^9.0.1",
|
|
||||||
"axios": "^1.7.9",
|
|
||||||
"opossum": "^8.0.0",
|
|
||||||
"winston": "^3.17.0",
|
|
||||||
"zod": "^3.24.1",
|
|
||||||
"js-yaml": "^4.1.0",
|
|
||||||
"fastify": "^5.2.0",
|
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
|
"@fastify/multipart": "^9.0.1",
|
||||||
"@fastify/type-provider-typebox": "^6.1.0",
|
"@fastify/type-provider-typebox": "^6.1.0",
|
||||||
"@sinclair/typebox": "^0.34.0",
|
"@sinclair/typebox": "^0.34.0",
|
||||||
"fastify-plugin": "^5.0.1",
|
|
||||||
"@fastify/autoload": "^6.0.1",
|
|
||||||
"get-jwks": "^11.0.3",
|
|
||||||
"file-type": "^16.5.4",
|
|
||||||
"resend": "^3.0.0",
|
|
||||||
"node-cron": "^3.0.3",
|
|
||||||
"auth0": "^4.12.0",
|
"auth0": "^4.12.0",
|
||||||
"tar": "^7.4.3"
|
"axios": "^1.7.9",
|
||||||
|
"fastify": "^5.2.0",
|
||||||
|
"fastify-plugin": "^5.0.1",
|
||||||
|
"file-type": "^16.5.4",
|
||||||
|
"form-data": "^4.0.0",
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"@types/pg": "^8.10.9",
|
|
||||||
"@types/js-yaml": "^4.0.9",
|
|
||||||
"@types/node-cron": "^3.0.11",
|
|
||||||
"typescript": "^5.7.2",
|
|
||||||
"ts-node": "^10.9.1",
|
|
||||||
"nodemon": "^3.1.9",
|
|
||||||
"jest": "^29.7.0",
|
|
||||||
"@types/jest": "^29.5.10",
|
|
||||||
"ts-jest": "^29.1.1",
|
|
||||||
"supertest": "^7.1.4",
|
|
||||||
"@types/supertest": "^6.0.3",
|
|
||||||
"@types/opossum": "^8.0.0",
|
|
||||||
"eslint": "^9.17.0",
|
|
||||||
"@eslint/js": "^9.17.0",
|
"@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",
|
||||||
|
"@types/pg": "^8.10.9",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"nodemon": "^3.1.9",
|
||||||
|
"supertest": "^7.1.4",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
"typescript-eslint": "^8.18.1"
|
"typescript-eslint": "^8.18.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
backend/src/_system/CLAUDE.md
Normal file
10
backend/src/_system/CLAUDE.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# _system/
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
|
||||||
|
| Directory | What | When to read |
|
||||||
|
| --------- | ---- | ------------ |
|
||||||
|
| `cli/` | CLI commands and tools | Running backend CLI commands |
|
||||||
|
| `migrations/` | Database migration runner | Running or writing migrations |
|
||||||
|
| `schema/` | Database schema generation | Schema export, documentation |
|
||||||
|
| `scripts/` | System utility scripts | Database maintenance, automation |
|
||||||
@@ -17,7 +17,8 @@ const pool = new Pool({
|
|||||||
const MIGRATION_ORDER = [
|
const MIGRATION_ORDER = [
|
||||||
'features/vehicles', // Primary entity, defines update_updated_at_column()
|
'features/vehicles', // Primary entity, defines update_updated_at_column()
|
||||||
'features/platform', // Normalized make/model/trim schema for dropdowns
|
'features/platform', // Normalized make/model/trim schema for dropdowns
|
||||||
'features/documents', // Depends on vehicles; provides documents table
|
'features/user-profile', // User profile management; needed by documents migration
|
||||||
|
'features/documents', // Depends on vehicles, user-profile; provides documents table
|
||||||
'core/user-preferences', // Depends on update_updated_at_column()
|
'core/user-preferences', // Depends on update_updated_at_column()
|
||||||
'features/fuel-logs', // Depends on vehicles
|
'features/fuel-logs', // Depends on vehicles
|
||||||
'features/maintenance', // Depends on vehicles
|
'features/maintenance', // Depends on vehicles
|
||||||
@@ -25,8 +26,12 @@ const MIGRATION_ORDER = [
|
|||||||
'features/admin', // Admin role management and oversight; depends on update_updated_at_column()
|
'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/backup', // Admin backup feature; depends on update_updated_at_column()
|
||||||
'features/notifications', // Depends on maintenance and documents
|
'features/notifications', // Depends on maintenance and documents
|
||||||
'features/user-profile', // User profile management; independent
|
'features/email-ingestion', // Depends on documents, notifications (extends email_templates)
|
||||||
'features/terms-agreement', // Terms & Conditions acceptance audit trail
|
'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
|
||||||
|
'features/subscriptions', // Stripe subscriptions; depends on user-profile, vehicles
|
||||||
|
'core/identity-migration', // Cross-cutting UUID migration; must run after all feature tables exist
|
||||||
];
|
];
|
||||||
|
|
||||||
// Base directory where migrations are copied inside the image (set by Dockerfile)
|
// Base directory where migrations are copied inside the image (set by Dockerfile)
|
||||||
|
|||||||
@@ -25,11 +25,17 @@ import { documentsRoutes } from './features/documents/api/documents.routes';
|
|||||||
import { maintenanceRoutes } from './features/maintenance';
|
import { maintenanceRoutes } from './features/maintenance';
|
||||||
import { platformRoutes } from './features/platform';
|
import { platformRoutes } from './features/platform';
|
||||||
import { adminRoutes } from './features/admin/api/admin.routes';
|
import { adminRoutes } from './features/admin/api/admin.routes';
|
||||||
|
import { auditLogRoutes } from './features/audit-log/api/audit-log.routes';
|
||||||
import { notificationsRoutes } from './features/notifications';
|
import { notificationsRoutes } from './features/notifications';
|
||||||
import { userProfileRoutes } from './features/user-profile';
|
import { userProfileRoutes } from './features/user-profile';
|
||||||
import { onboardingRoutes } from './features/onboarding';
|
import { onboardingRoutes } from './features/onboarding';
|
||||||
import { userPreferencesRoutes } from './features/user-preferences';
|
import { userPreferencesRoutes } from './features/user-preferences';
|
||||||
import { userExportRoutes } from './features/user-export';
|
import { userExportRoutes } from './features/user-export';
|
||||||
|
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 { pool } from './core/config/database';
|
||||||
import { configRoutes } from './core/config/config.routes';
|
import { configRoutes } from './core/config/config.routes';
|
||||||
|
|
||||||
@@ -91,7 +97,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
|||||||
status: 'healthy',
|
status: 'healthy',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
environment: process.env['NODE_ENV'],
|
environment: process.env['NODE_ENV'],
|
||||||
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export']
|
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']
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,7 +107,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
|||||||
status: 'healthy',
|
status: 'healthy',
|
||||||
scope: 'api',
|
scope: 'api',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export']
|
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']
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,10 +143,19 @@ async function buildApp(): Promise<FastifyInstance> {
|
|||||||
await app.register(communityStationsRoutes, { prefix: '/api' });
|
await app.register(communityStationsRoutes, { prefix: '/api' });
|
||||||
await app.register(maintenanceRoutes, { prefix: '/api' });
|
await app.register(maintenanceRoutes, { prefix: '/api' });
|
||||||
await app.register(adminRoutes, { prefix: '/api' });
|
await app.register(adminRoutes, { prefix: '/api' });
|
||||||
|
await app.register(auditLogRoutes, { prefix: '/api' });
|
||||||
await app.register(notificationsRoutes, { prefix: '/api' });
|
await app.register(notificationsRoutes, { prefix: '/api' });
|
||||||
await app.register(userProfileRoutes, { prefix: '/api' });
|
await app.register(userProfileRoutes, { prefix: '/api' });
|
||||||
await app.register(userPreferencesRoutes, { prefix: '/api' });
|
await app.register(userPreferencesRoutes, { prefix: '/api' });
|
||||||
await app.register(userExportRoutes, { prefix: '/api' });
|
await app.register(userExportRoutes, { prefix: '/api' });
|
||||||
|
await app.register(userImportRoutes, { prefix: '/api' });
|
||||||
|
await app.register(ownershipCostsRoutes, { prefix: '/api' });
|
||||||
|
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' });
|
await app.register(configRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
| Directory | What | When to read |
|
| Directory | What | When to read |
|
||||||
| --------- | ---- | ------------ |
|
| --------- | ---- | ------------ |
|
||||||
| `auth/` | Authentication utilities | JWT handling, user context |
|
| `auth/` | Authentication utilities | JWT handling, user context |
|
||||||
| `config/` | Configuration loading (env, database, redis) | Environment setup, connection pools |
|
| `config/` | Configuration loading (env, database, redis) and feature tier gating (fuelLog.receiptScan, document.scanMaintenanceSchedule, vehicle.vinDecode) | Environment setup, connection pools, tier requirements |
|
||||||
| `logging/` | Winston structured logging | Log configuration, debugging |
|
| `logging/` | Winston structured logging | Log configuration, debugging |
|
||||||
| `middleware/` | Fastify middleware | Request processing, user extraction |
|
| `middleware/` | Fastify middleware | Request processing, user extraction |
|
||||||
| `plugins/` | Fastify plugins (auth, error, logging) | Plugin registration, hooks |
|
| `plugins/` | Fastify plugins (auth, error, logging, tier guard) | Plugin registration, hooks, tier gating |
|
||||||
| `scheduler/` | Job scheduling infrastructure | Scheduled tasks, cron jobs |
|
| `scheduler/` | Job scheduling infrastructure | Scheduled tasks, cron jobs |
|
||||||
| `storage/` | Storage abstraction and adapters | File storage, S3/filesystem |
|
| `storage/` | Storage abstraction and adapters | File storage, S3/filesystem |
|
||||||
| `user-preferences/` | User preferences data and migrations | User settings storage |
|
| `user-preferences/` | User preferences data and migrations | User settings storage |
|
||||||
|
|||||||
@@ -41,14 +41,6 @@ const configSchema = z.object({
|
|||||||
audience: z.string(),
|
audience: z.string(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// External APIs configuration (optional)
|
|
||||||
external: z.object({
|
|
||||||
vpic: z.object({
|
|
||||||
url: z.string(),
|
|
||||||
timeout: z.string(),
|
|
||||||
}).optional(),
|
|
||||||
}).optional(),
|
|
||||||
|
|
||||||
// Service configuration
|
// Service configuration
|
||||||
service: z.object({
|
service: z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -126,6 +118,10 @@ const secretsSchema = z.object({
|
|||||||
auth0_management_client_secret: z.string(),
|
auth0_management_client_secret: z.string(),
|
||||||
google_maps_api_key: z.string(),
|
google_maps_api_key: z.string(),
|
||||||
resend_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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Config = z.infer<typeof configSchema>;
|
type Config = z.infer<typeof configSchema>;
|
||||||
@@ -140,6 +136,14 @@ export interface AppConfiguration {
|
|||||||
getRedisUrl(): string;
|
getRedisUrl(): string;
|
||||||
getAuth0Config(): { domain: string; audience: string; clientSecret: string };
|
getAuth0Config(): { domain: string; audience: string; clientSecret: string };
|
||||||
getAuth0ManagementConfig(): { domain: string; clientId: string; clientSecret: string };
|
getAuth0ManagementConfig(): { domain: string; clientId: string; clientSecret: string };
|
||||||
|
getResendConfig(): {
|
||||||
|
apiKey: string;
|
||||||
|
webhookSecret: string | undefined;
|
||||||
|
};
|
||||||
|
getStripeConfig(): {
|
||||||
|
secretKey: string;
|
||||||
|
webhookSecret: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class ConfigurationLoader {
|
class ConfigurationLoader {
|
||||||
@@ -178,6 +182,9 @@ class ConfigurationLoader {
|
|||||||
'auth0-management-client-secret',
|
'auth0-management-client-secret',
|
||||||
'google-maps-api-key',
|
'google-maps-api-key',
|
||||||
'resend-api-key',
|
'resend-api-key',
|
||||||
|
'resend-webhook-secret',
|
||||||
|
'stripe-secret-key',
|
||||||
|
'stripe-webhook-secret',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const secretFile of secretFiles) {
|
for (const secretFile of secretFiles) {
|
||||||
@@ -240,10 +247,27 @@ class ConfigurationLoader {
|
|||||||
clientSecret: secrets.auth0_management_client_secret,
|
clientSecret: secrets.auth0_management_client_secret,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getResendConfig() {
|
||||||
|
return {
|
||||||
|
apiKey: secrets.resend_api_key,
|
||||||
|
webhookSecret: secrets.resend_webhook_secret,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getStripeConfig() {
|
||||||
|
return {
|
||||||
|
secretKey: secrets.stripe_secret_key,
|
||||||
|
webhookSecret: secrets.stripe_webhook_secret,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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;
|
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', {
|
logger.info('Configuration loaded successfully', {
|
||||||
configSource: 'yaml',
|
configSource: 'yaml',
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ export const FEATURE_TIERS: Record<string, FeatureConfig> = {
|
|||||||
name: 'Scan for Maintenance Schedule',
|
name: 'Scan for Maintenance Schedule',
|
||||||
upgradePrompt: 'Upgrade to Pro to automatically extract maintenance schedules from your vehicle manuals.',
|
upgradePrompt: 'Upgrade to Pro to automatically extract maintenance schedules from your vehicle manuals.',
|
||||||
},
|
},
|
||||||
|
'vehicle.vinDecode': {
|
||||||
|
minTier: 'pro',
|
||||||
|
name: 'VIN Decode',
|
||||||
|
upgradePrompt: 'Upgrade to Pro to automatically decode VIN and populate vehicle details from the vehicle database.',
|
||||||
|
},
|
||||||
|
'fuelLog.receiptScan': {
|
||||||
|
minTier: 'pro',
|
||||||
|
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;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,3 +86,75 @@ export function getFeatureConfig(featureKey: string): FeatureConfig | undefined
|
|||||||
export function getAllFeatureConfigs(): Record<string, FeatureConfig> {
|
export function getAllFeatureConfigs(): Record<string, FeatureConfig> {
|
||||||
return { ...FEATURE_TIERS };
|
return { ...FEATURE_TIERS };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vehicle limits per tier
|
||||||
|
// null indicates unlimited (enterprise tier)
|
||||||
|
export const VEHICLE_LIMITS: Record<SubscriptionTier, number | null> = {
|
||||||
|
free: 2,
|
||||||
|
pro: 5,
|
||||||
|
enterprise: null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vehicle limits vary by subscription tier and must be queryable
|
||||||
|
* at runtime for both backend enforcement and frontend UI state.
|
||||||
|
*
|
||||||
|
* @param tier - User's subscription tier
|
||||||
|
* @returns Maximum vehicles allowed, or null for unlimited (enterprise tier)
|
||||||
|
*/
|
||||||
|
export function getVehicleLimit(tier: SubscriptionTier): number | null {
|
||||||
|
return VEHICLE_LIMITS[tier] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user can add another vehicle based on their tier and current count.
|
||||||
|
*
|
||||||
|
* @param tier - User's subscription tier
|
||||||
|
* @param currentCount - Number of vehicles user currently has
|
||||||
|
* @returns true if user can add another vehicle, false if at/over limit
|
||||||
|
*/
|
||||||
|
export function canAddVehicle(tier: SubscriptionTier, currentCount: number): boolean {
|
||||||
|
const limit = getVehicleLimit(tier);
|
||||||
|
// null limit means unlimited (enterprise)
|
||||||
|
if (limit === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return currentCount < limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vehicle limit configuration with upgrade prompt.
|
||||||
|
* Structure supports additional resource types in the future.
|
||||||
|
*/
|
||||||
|
export interface VehicleLimitConfig {
|
||||||
|
limit: number | null;
|
||||||
|
tier: SubscriptionTier;
|
||||||
|
upgradePrompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get vehicle limit configuration with upgrade prompt for a tier.
|
||||||
|
*
|
||||||
|
* @param tier - User's subscription tier
|
||||||
|
* @returns Configuration with limit and upgrade prompt
|
||||||
|
*/
|
||||||
|
export function getVehicleLimitConfig(tier: SubscriptionTier): VehicleLimitConfig {
|
||||||
|
const limit = getVehicleLimit(tier);
|
||||||
|
|
||||||
|
const defaultPrompt = 'Upgrade to access additional vehicles.';
|
||||||
|
|
||||||
|
let upgradePrompt: string;
|
||||||
|
if (tier === 'free') {
|
||||||
|
upgradePrompt = 'Free tier is limited to 2 vehicles. Upgrade to Pro for up to 5 vehicles, or Enterprise for unlimited.';
|
||||||
|
} else if (tier === 'pro') {
|
||||||
|
upgradePrompt = 'Pro tier is limited to 5 vehicles. Upgrade to Enterprise for unlimited vehicles.';
|
||||||
|
} else {
|
||||||
|
upgradePrompt = defaultPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
limit,
|
||||||
|
tier,
|
||||||
|
upgradePrompt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
TIER_LEVELS,
|
TIER_LEVELS,
|
||||||
FEATURE_TIERS,
|
FEATURE_TIERS,
|
||||||
|
VEHICLE_LIMITS,
|
||||||
getTierLevel,
|
getTierLevel,
|
||||||
canAccessFeature,
|
canAccessFeature,
|
||||||
getRequiredTier,
|
getRequiredTier,
|
||||||
getFeatureConfig,
|
getFeatureConfig,
|
||||||
getAllFeatureConfigs,
|
getAllFeatureConfigs,
|
||||||
|
getVehicleLimit,
|
||||||
|
canAddVehicle,
|
||||||
|
getVehicleLimitConfig,
|
||||||
} from '../feature-tiers';
|
} from '../feature-tiers';
|
||||||
|
|
||||||
describe('feature-tiers', () => {
|
describe('feature-tiers', () => {
|
||||||
@@ -30,6 +34,30 @@ describe('feature-tiers', () => {
|
|||||||
expect(feature.name).toBe('Scan for Maintenance Schedule');
|
expect(feature.name).toBe('Scan for Maintenance Schedule');
|
||||||
expect(feature.upgradePrompt).toBeTruthy();
|
expect(feature.upgradePrompt).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes fuelLog.receiptScan feature', () => {
|
||||||
|
const feature = FEATURE_TIERS['fuelLog.receiptScan'];
|
||||||
|
expect(feature).toBeDefined();
|
||||||
|
expect(feature.minTier).toBe('pro');
|
||||||
|
expect(feature.name).toBe('Receipt Scan');
|
||||||
|
expect(feature.upgradePrompt).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canAccessFeature - fuelLog.receiptScan', () => {
|
||||||
|
const featureKey = 'fuelLog.receiptScan';
|
||||||
|
|
||||||
|
it('denies access for free tier user', () => {
|
||||||
|
expect(canAccessFeature('free', featureKey)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows access for pro tier user', () => {
|
||||||
|
expect(canAccessFeature('pro', featureKey)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows access for enterprise tier user (inherits pro)', () => {
|
||||||
|
expect(canAccessFeature('enterprise', featureKey)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getTierLevel', () => {
|
describe('getTierLevel', () => {
|
||||||
@@ -101,4 +129,97 @@ describe('feature-tiers', () => {
|
|||||||
expect(FEATURE_TIERS['test' as keyof typeof FEATURE_TIERS]).toBeUndefined();
|
expect(FEATURE_TIERS['test' as keyof typeof FEATURE_TIERS]).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('VEHICLE_LIMITS', () => {
|
||||||
|
it('defines correct limits for each tier', () => {
|
||||||
|
expect(VEHICLE_LIMITS.free).toBe(2);
|
||||||
|
expect(VEHICLE_LIMITS.pro).toBe(5);
|
||||||
|
expect(VEHICLE_LIMITS.enterprise).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVehicleLimit', () => {
|
||||||
|
it('returns 2 for free tier', () => {
|
||||||
|
expect(getVehicleLimit('free')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 5 for pro tier', () => {
|
||||||
|
expect(getVehicleLimit('pro')).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for enterprise tier (unlimited)', () => {
|
||||||
|
expect(getVehicleLimit('enterprise')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canAddVehicle', () => {
|
||||||
|
describe('free tier (limit 2)', () => {
|
||||||
|
it('returns true when below limit', () => {
|
||||||
|
expect(canAddVehicle('free', 0)).toBe(true);
|
||||||
|
expect(canAddVehicle('free', 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when at limit', () => {
|
||||||
|
expect(canAddVehicle('free', 2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when over limit', () => {
|
||||||
|
expect(canAddVehicle('free', 3)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pro tier (limit 5)', () => {
|
||||||
|
it('returns true when below limit', () => {
|
||||||
|
expect(canAddVehicle('pro', 0)).toBe(true);
|
||||||
|
expect(canAddVehicle('pro', 4)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when at limit', () => {
|
||||||
|
expect(canAddVehicle('pro', 5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when over limit', () => {
|
||||||
|
expect(canAddVehicle('pro', 6)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enterprise tier (unlimited)', () => {
|
||||||
|
it('always returns true regardless of count', () => {
|
||||||
|
expect(canAddVehicle('enterprise', 0)).toBe(true);
|
||||||
|
expect(canAddVehicle('enterprise', 100)).toBe(true);
|
||||||
|
expect(canAddVehicle('enterprise', 999999)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVehicleLimitConfig', () => {
|
||||||
|
it('returns correct config for free tier', () => {
|
||||||
|
const config = getVehicleLimitConfig('free');
|
||||||
|
expect(config.limit).toBe(2);
|
||||||
|
expect(config.tier).toBe('free');
|
||||||
|
expect(config.upgradePrompt).toContain('Free tier is limited to 2 vehicles');
|
||||||
|
expect(config.upgradePrompt).toContain('Pro');
|
||||||
|
expect(config.upgradePrompt).toContain('Enterprise');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct config for pro tier', () => {
|
||||||
|
const config = getVehicleLimitConfig('pro');
|
||||||
|
expect(config.limit).toBe(5);
|
||||||
|
expect(config.tier).toBe('pro');
|
||||||
|
expect(config.upgradePrompt).toContain('Pro tier is limited to 5 vehicles');
|
||||||
|
expect(config.upgradePrompt).toContain('Enterprise');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct config for enterprise tier', () => {
|
||||||
|
const config = getVehicleLimitConfig('enterprise');
|
||||||
|
expect(config.limit).toBeNull();
|
||||||
|
expect(config.tier).toBe('enterprise');
|
||||||
|
expect(config.upgradePrompt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides default upgradePrompt fallback', () => {
|
||||||
|
const config = getVehicleLimitConfig('enterprise');
|
||||||
|
expect(config.upgradePrompt).toBe('Upgrade to access additional vehicles.');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
-- Migration: 001_migrate_user_id_to_uuid.sql
|
||||||
|
-- Feature: identity-migration (cross-cutting)
|
||||||
|
-- Description: Migrate all user identity columns from VARCHAR(255) storing auth0_sub
|
||||||
|
-- to UUID referencing user_profiles.id. Admin tables restructured with UUID PKs.
|
||||||
|
-- Requires: All feature tables must exist (runs last in MIGRATION_ORDER)
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- PHASE 1: Add new UUID columns alongside existing VARCHAR columns
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 1a. Feature tables (17 tables with user_id VARCHAR)
|
||||||
|
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE fuel_logs ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE maintenance_records ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE maintenance_schedules ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE notification_logs ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE user_notifications ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE saved_stations ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE ownership_costs ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE email_ingestion_queue ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE pending_vehicle_associations ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE donations ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE tier_vehicle_selections ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
ALTER TABLE terms_agreements ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
|
||||||
|
-- 1b. Special user-reference columns (submitted_by/reported_by store auth0_sub)
|
||||||
|
ALTER TABLE community_stations ADD COLUMN IF NOT EXISTS submitted_by_uuid UUID;
|
||||||
|
ALTER TABLE station_removal_reports ADD COLUMN IF NOT EXISTS reported_by_uuid UUID;
|
||||||
|
|
||||||
|
-- 1c. Admin table: add id UUID and user_profile_id UUID
|
||||||
|
ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS id UUID;
|
||||||
|
ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS user_profile_id UUID;
|
||||||
|
|
||||||
|
-- 1d. Admin-referencing columns: add UUID equivalents
|
||||||
|
ALTER TABLE admin_audit_logs ADD COLUMN IF NOT EXISTS actor_admin_uuid UUID;
|
||||||
|
ALTER TABLE admin_audit_logs ADD COLUMN IF NOT EXISTS target_admin_uuid UUID;
|
||||||
|
ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS created_by_uuid UUID;
|
||||||
|
ALTER TABLE community_stations ADD COLUMN IF NOT EXISTS reviewed_by_uuid UUID;
|
||||||
|
ALTER TABLE backup_history ADD COLUMN IF NOT EXISTS created_by_uuid UUID;
|
||||||
|
ALTER TABLE platform_change_log ADD COLUMN IF NOT EXISTS changed_by_uuid UUID;
|
||||||
|
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS deactivated_by_uuid UUID;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- PHASE 2: Backfill UUID values from user_profiles join
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 2a. Feature tables: map user_id (auth0_sub) -> user_profiles.id (UUID)
|
||||||
|
UPDATE vehicles SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE vehicles.user_id = up.auth0_sub AND vehicles.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE fuel_logs SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE fuel_logs.user_id = up.auth0_sub AND fuel_logs.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE maintenance_records SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE maintenance_records.user_id = up.auth0_sub AND maintenance_records.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE maintenance_schedules SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE maintenance_schedules.user_id = up.auth0_sub AND maintenance_schedules.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE documents SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE documents.user_id = up.auth0_sub AND documents.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE notification_logs SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE notification_logs.user_id = up.auth0_sub AND notification_logs.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE user_notifications SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE user_notifications.user_id = up.auth0_sub AND user_notifications.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE user_preferences SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE user_preferences.user_id = up.auth0_sub AND user_preferences.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
-- 2a-fix. user_preferences has rows where user_id already contains user_profiles.id (UUID)
|
||||||
|
-- instead of auth0_sub. Match these directly by casting to UUID.
|
||||||
|
UPDATE user_preferences SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up
|
||||||
|
WHERE user_preferences.user_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||||
|
AND user_preferences.user_id::uuid = up.id
|
||||||
|
AND user_preferences.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
-- Delete truly orphaned user_preferences (UUID user_id with no matching user_profile)
|
||||||
|
DELETE FROM user_preferences
|
||||||
|
WHERE user_profile_id IS NULL
|
||||||
|
AND user_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM user_profiles WHERE id = user_preferences.user_id::uuid);
|
||||||
|
|
||||||
|
-- Deduplicate user_preferences: same user may have both an auth0_sub row and
|
||||||
|
-- a UUID row, both now mapping to the same user_profile_id. Keep the newest.
|
||||||
|
DELETE FROM user_preferences a
|
||||||
|
USING user_preferences b
|
||||||
|
WHERE a.user_profile_id = b.user_profile_id
|
||||||
|
AND a.user_profile_id IS NOT NULL
|
||||||
|
AND (a.updated_at < b.updated_at OR (a.updated_at = b.updated_at AND a.id < b.id));
|
||||||
|
|
||||||
|
UPDATE saved_stations SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE saved_stations.user_id = up.auth0_sub AND saved_stations.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE audit_logs SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE audit_logs.user_id = up.auth0_sub AND audit_logs.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE ownership_costs SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE ownership_costs.user_id = up.auth0_sub AND ownership_costs.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE email_ingestion_queue SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE email_ingestion_queue.user_id = up.auth0_sub AND email_ingestion_queue.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE pending_vehicle_associations SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE pending_vehicle_associations.user_id = up.auth0_sub AND pending_vehicle_associations.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE subscriptions SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE subscriptions.user_id = up.auth0_sub AND subscriptions.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE donations SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE donations.user_id = up.auth0_sub AND donations.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE tier_vehicle_selections SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE tier_vehicle_selections.user_id = up.auth0_sub AND tier_vehicle_selections.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE terms_agreements SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE terms_agreements.user_id = up.auth0_sub AND terms_agreements.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
-- 2b. Special user columns
|
||||||
|
UPDATE community_stations SET submitted_by_uuid = up.id
|
||||||
|
FROM user_profiles up WHERE community_stations.submitted_by = up.auth0_sub AND community_stations.submitted_by_uuid IS NULL;
|
||||||
|
|
||||||
|
UPDATE station_removal_reports SET reported_by_uuid = up.id
|
||||||
|
FROM user_profiles up WHERE station_removal_reports.reported_by = up.auth0_sub AND station_removal_reports.reported_by_uuid IS NULL;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- PHASE 3: Admin-specific transformations
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 3a. Create user_profiles entries for any admin_users that lack one
|
||||||
|
INSERT INTO user_profiles (auth0_sub, email)
|
||||||
|
SELECT au.auth0_sub, au.email
|
||||||
|
FROM admin_users au
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM user_profiles up WHERE up.auth0_sub = au.auth0_sub
|
||||||
|
)
|
||||||
|
ON CONFLICT (auth0_sub) DO NOTHING;
|
||||||
|
|
||||||
|
-- 3b. Populate admin_users.id (DEFAULT doesn't auto-fill on ALTER ADD COLUMN for existing rows)
|
||||||
|
UPDATE admin_users SET id = uuid_generate_v4() WHERE id IS NULL;
|
||||||
|
|
||||||
|
-- 3c. Backfill admin_users.user_profile_id from user_profiles join
|
||||||
|
UPDATE admin_users SET user_profile_id = up.id
|
||||||
|
FROM user_profiles up WHERE admin_users.auth0_sub = up.auth0_sub AND admin_users.user_profile_id IS NULL;
|
||||||
|
|
||||||
|
-- 3d. Backfill admin-referencing columns: map auth0_sub -> admin_users.id UUID
|
||||||
|
UPDATE admin_audit_logs SET actor_admin_uuid = au.id
|
||||||
|
FROM admin_users au WHERE admin_audit_logs.actor_admin_id = au.auth0_sub AND admin_audit_logs.actor_admin_uuid IS NULL;
|
||||||
|
|
||||||
|
UPDATE admin_audit_logs SET target_admin_uuid = au.id
|
||||||
|
FROM admin_users au WHERE admin_audit_logs.target_admin_id = au.auth0_sub AND admin_audit_logs.target_admin_uuid IS NULL;
|
||||||
|
|
||||||
|
UPDATE admin_users au SET created_by_uuid = creator.id
|
||||||
|
FROM admin_users creator WHERE au.created_by = creator.auth0_sub AND au.created_by_uuid IS NULL;
|
||||||
|
|
||||||
|
UPDATE community_stations SET reviewed_by_uuid = au.id
|
||||||
|
FROM admin_users au WHERE community_stations.reviewed_by = au.auth0_sub AND community_stations.reviewed_by_uuid IS NULL;
|
||||||
|
|
||||||
|
UPDATE backup_history SET created_by_uuid = au.id
|
||||||
|
FROM admin_users au WHERE backup_history.created_by = au.auth0_sub AND backup_history.created_by_uuid IS NULL;
|
||||||
|
|
||||||
|
UPDATE platform_change_log SET changed_by_uuid = au.id
|
||||||
|
FROM admin_users au WHERE platform_change_log.changed_by = au.auth0_sub AND platform_change_log.changed_by_uuid IS NULL;
|
||||||
|
|
||||||
|
UPDATE user_profiles SET deactivated_by_uuid = au.id
|
||||||
|
FROM admin_users au WHERE user_profiles.deactivated_by = au.auth0_sub AND user_profiles.deactivated_by_uuid IS NULL;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- PHASE 4: Add constraints
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 4a. Set NOT NULL on feature table UUID columns (audit_logs stays nullable)
|
||||||
|
ALTER TABLE vehicles ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE fuel_logs ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE maintenance_records ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE maintenance_schedules ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE documents ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE notification_logs ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE user_notifications ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE user_preferences ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE saved_stations ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
-- audit_logs.user_profile_id stays NULLABLE (system actions have no user)
|
||||||
|
ALTER TABLE ownership_costs ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE email_ingestion_queue ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE pending_vehicle_associations ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE subscriptions ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE donations ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE tier_vehicle_selections ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE terms_agreements ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE community_stations ALTER COLUMN submitted_by_uuid SET NOT NULL;
|
||||||
|
ALTER TABLE station_removal_reports ALTER COLUMN reported_by_uuid SET NOT NULL;
|
||||||
|
|
||||||
|
-- 4b. Admin table NOT NULL constraints
|
||||||
|
ALTER TABLE admin_users ALTER COLUMN id SET NOT NULL;
|
||||||
|
ALTER TABLE admin_users ALTER COLUMN user_profile_id SET NOT NULL;
|
||||||
|
ALTER TABLE admin_audit_logs ALTER COLUMN actor_admin_uuid SET NOT NULL;
|
||||||
|
-- target_admin_uuid stays nullable (some actions have no target)
|
||||||
|
-- created_by_uuid stays nullable (bootstrap admin may not have a creator)
|
||||||
|
ALTER TABLE platform_change_log ALTER COLUMN changed_by_uuid SET NOT NULL;
|
||||||
|
|
||||||
|
-- 4c. Admin table PK transformation
|
||||||
|
ALTER TABLE admin_users DROP CONSTRAINT admin_users_pkey;
|
||||||
|
ALTER TABLE admin_users ADD PRIMARY KEY (id);
|
||||||
|
|
||||||
|
-- 4d. Add FK constraints to user_profiles(id) with ON DELETE CASCADE
|
||||||
|
ALTER TABLE vehicles ADD CONSTRAINT fk_vehicles_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE fuel_logs ADD CONSTRAINT fk_fuel_logs_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE maintenance_records ADD CONSTRAINT fk_maintenance_records_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE maintenance_schedules ADD CONSTRAINT fk_maintenance_schedules_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE documents ADD CONSTRAINT fk_documents_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE notification_logs ADD CONSTRAINT fk_notification_logs_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE user_notifications ADD CONSTRAINT fk_user_notifications_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE user_preferences ADD CONSTRAINT fk_user_preferences_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE saved_stations ADD CONSTRAINT fk_saved_stations_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE audit_logs ADD CONSTRAINT fk_audit_logs_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE ownership_costs ADD CONSTRAINT fk_ownership_costs_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE email_ingestion_queue ADD CONSTRAINT fk_email_ingestion_queue_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE pending_vehicle_associations ADD CONSTRAINT fk_pending_vehicle_assoc_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE subscriptions ADD CONSTRAINT fk_subscriptions_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE donations ADD CONSTRAINT fk_donations_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE tier_vehicle_selections ADD CONSTRAINT fk_tier_vehicle_selections_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE terms_agreements ADD CONSTRAINT fk_terms_agreements_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE community_stations ADD CONSTRAINT fk_community_stations_submitted_by
|
||||||
|
FOREIGN KEY (submitted_by_uuid) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE station_removal_reports ADD CONSTRAINT fk_station_removal_reports_reported_by
|
||||||
|
FOREIGN KEY (reported_by_uuid) REFERENCES user_profiles(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- 4e. Admin FK constraints
|
||||||
|
ALTER TABLE admin_users ADD CONSTRAINT fk_admin_users_user_profile_id
|
||||||
|
FOREIGN KEY (user_profile_id) REFERENCES user_profiles(id);
|
||||||
|
ALTER TABLE admin_users ADD CONSTRAINT uq_admin_users_user_profile_id
|
||||||
|
UNIQUE (user_profile_id);
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- PHASE 5: Drop old columns, rename new ones, recreate indexes
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 5a. Drop old FK constraints on VARCHAR user_id columns
|
||||||
|
ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS fk_subscriptions_user_id;
|
||||||
|
ALTER TABLE donations DROP CONSTRAINT IF EXISTS fk_donations_user_id;
|
||||||
|
ALTER TABLE tier_vehicle_selections DROP CONSTRAINT IF EXISTS fk_tier_vehicle_selections_user_id;
|
||||||
|
|
||||||
|
-- 5b. Drop old UNIQUE constraints involving VARCHAR columns
|
||||||
|
ALTER TABLE vehicles DROP CONSTRAINT IF EXISTS unique_user_vin;
|
||||||
|
ALTER TABLE saved_stations DROP CONSTRAINT IF EXISTS unique_user_station;
|
||||||
|
ALTER TABLE user_preferences DROP CONSTRAINT IF EXISTS user_preferences_user_id_key;
|
||||||
|
ALTER TABLE station_removal_reports DROP CONSTRAINT IF EXISTS unique_user_station_report;
|
||||||
|
|
||||||
|
-- 5c. Drop old indexes on VARCHAR columns
|
||||||
|
DROP INDEX IF EXISTS idx_vehicles_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_fuel_logs_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_maintenance_records_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_maintenance_schedules_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_documents_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_documents_user_vehicle;
|
||||||
|
DROP INDEX IF EXISTS idx_notification_logs_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_user_notifications_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_user_notifications_unread;
|
||||||
|
DROP INDEX IF EXISTS idx_user_preferences_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_saved_stations_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_audit_logs_user_created;
|
||||||
|
DROP INDEX IF EXISTS idx_ownership_costs_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_email_ingestion_queue_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_pending_vehicle_assoc_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_subscriptions_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_donations_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_tier_vehicle_selections_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_terms_agreements_user_id;
|
||||||
|
DROP INDEX IF EXISTS idx_community_stations_submitted_by;
|
||||||
|
DROP INDEX IF EXISTS idx_removal_reports_reported_by;
|
||||||
|
DROP INDEX IF EXISTS idx_admin_audit_logs_actor_id;
|
||||||
|
DROP INDEX IF EXISTS idx_admin_audit_logs_target_id;
|
||||||
|
DROP INDEX IF EXISTS idx_platform_change_log_changed_by;
|
||||||
|
|
||||||
|
-- 5d. Drop old VARCHAR user_id columns from feature tables
|
||||||
|
ALTER TABLE vehicles DROP COLUMN user_id;
|
||||||
|
ALTER TABLE fuel_logs DROP COLUMN user_id;
|
||||||
|
ALTER TABLE maintenance_records DROP COLUMN user_id;
|
||||||
|
ALTER TABLE maintenance_schedules DROP COLUMN user_id;
|
||||||
|
ALTER TABLE documents DROP COLUMN user_id;
|
||||||
|
ALTER TABLE notification_logs DROP COLUMN user_id;
|
||||||
|
ALTER TABLE user_notifications DROP COLUMN user_id;
|
||||||
|
ALTER TABLE user_preferences DROP COLUMN user_id;
|
||||||
|
ALTER TABLE saved_stations DROP COLUMN user_id;
|
||||||
|
ALTER TABLE audit_logs DROP COLUMN user_id;
|
||||||
|
ALTER TABLE ownership_costs DROP COLUMN user_id;
|
||||||
|
ALTER TABLE email_ingestion_queue DROP COLUMN user_id;
|
||||||
|
ALTER TABLE pending_vehicle_associations DROP COLUMN user_id;
|
||||||
|
ALTER TABLE subscriptions DROP COLUMN user_id;
|
||||||
|
ALTER TABLE donations DROP COLUMN user_id;
|
||||||
|
ALTER TABLE tier_vehicle_selections DROP COLUMN user_id;
|
||||||
|
ALTER TABLE terms_agreements DROP COLUMN user_id;
|
||||||
|
|
||||||
|
-- 5e. Rename user_profile_id -> user_id in feature tables
|
||||||
|
ALTER TABLE vehicles RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE fuel_logs RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE maintenance_records RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE maintenance_schedules RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE documents RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE notification_logs RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE user_notifications RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE user_preferences RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE saved_stations RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE audit_logs RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE ownership_costs RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE email_ingestion_queue RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE pending_vehicle_associations RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE subscriptions RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE donations RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE tier_vehicle_selections RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
ALTER TABLE terms_agreements RENAME COLUMN user_profile_id TO user_id;
|
||||||
|
|
||||||
|
-- 5f. Drop and rename special user columns
|
||||||
|
ALTER TABLE community_stations DROP COLUMN submitted_by;
|
||||||
|
ALTER TABLE community_stations RENAME COLUMN submitted_by_uuid TO submitted_by;
|
||||||
|
ALTER TABLE station_removal_reports DROP COLUMN reported_by;
|
||||||
|
ALTER TABLE station_removal_reports RENAME COLUMN reported_by_uuid TO reported_by;
|
||||||
|
|
||||||
|
-- 5g. Drop and rename admin-referencing columns
|
||||||
|
ALTER TABLE admin_users DROP COLUMN auth0_sub;
|
||||||
|
ALTER TABLE admin_users DROP COLUMN created_by;
|
||||||
|
ALTER TABLE admin_users RENAME COLUMN created_by_uuid TO created_by;
|
||||||
|
|
||||||
|
ALTER TABLE admin_audit_logs DROP COLUMN actor_admin_id;
|
||||||
|
ALTER TABLE admin_audit_logs DROP COLUMN target_admin_id;
|
||||||
|
ALTER TABLE admin_audit_logs RENAME COLUMN actor_admin_uuid TO actor_admin_id;
|
||||||
|
ALTER TABLE admin_audit_logs RENAME COLUMN target_admin_uuid TO target_admin_id;
|
||||||
|
|
||||||
|
ALTER TABLE community_stations DROP COLUMN reviewed_by;
|
||||||
|
ALTER TABLE community_stations RENAME COLUMN reviewed_by_uuid TO reviewed_by;
|
||||||
|
|
||||||
|
ALTER TABLE backup_history DROP COLUMN created_by;
|
||||||
|
ALTER TABLE backup_history RENAME COLUMN created_by_uuid TO created_by;
|
||||||
|
|
||||||
|
ALTER TABLE platform_change_log DROP COLUMN changed_by;
|
||||||
|
ALTER TABLE platform_change_log RENAME COLUMN changed_by_uuid TO changed_by;
|
||||||
|
|
||||||
|
ALTER TABLE user_profiles DROP COLUMN deactivated_by;
|
||||||
|
ALTER TABLE user_profiles RENAME COLUMN deactivated_by_uuid TO deactivated_by;
|
||||||
|
|
||||||
|
-- 5h. Recreate indexes on new UUID columns (feature tables)
|
||||||
|
CREATE INDEX idx_vehicles_user_id ON vehicles(user_id);
|
||||||
|
CREATE INDEX idx_fuel_logs_user_id ON fuel_logs(user_id);
|
||||||
|
CREATE INDEX idx_maintenance_records_user_id ON maintenance_records(user_id);
|
||||||
|
CREATE INDEX idx_maintenance_schedules_user_id ON maintenance_schedules(user_id);
|
||||||
|
CREATE INDEX idx_documents_user_id ON documents(user_id);
|
||||||
|
CREATE INDEX idx_documents_user_vehicle ON documents(user_id, vehicle_id);
|
||||||
|
CREATE INDEX idx_notification_logs_user_id ON notification_logs(user_id);
|
||||||
|
CREATE INDEX idx_user_notifications_user_id ON user_notifications(user_id);
|
||||||
|
CREATE INDEX idx_user_notifications_unread ON user_notifications(user_id, created_at DESC) WHERE is_read = false;
|
||||||
|
CREATE INDEX idx_user_preferences_user_id ON user_preferences(user_id);
|
||||||
|
CREATE INDEX idx_saved_stations_user_id ON saved_stations(user_id);
|
||||||
|
CREATE INDEX idx_audit_logs_user_created ON audit_logs(user_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_ownership_costs_user_id ON ownership_costs(user_id);
|
||||||
|
CREATE INDEX idx_email_ingestion_queue_user_id ON email_ingestion_queue(user_id);
|
||||||
|
CREATE INDEX idx_pending_vehicle_assoc_user_id ON pending_vehicle_associations(user_id);
|
||||||
|
CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);
|
||||||
|
CREATE INDEX idx_donations_user_id ON donations(user_id);
|
||||||
|
CREATE INDEX idx_tier_vehicle_selections_user_id ON tier_vehicle_selections(user_id);
|
||||||
|
CREATE INDEX idx_terms_agreements_user_id ON terms_agreements(user_id);
|
||||||
|
|
||||||
|
-- 5i. Recreate indexes on special columns
|
||||||
|
CREATE INDEX idx_community_stations_submitted_by ON community_stations(submitted_by);
|
||||||
|
CREATE INDEX idx_removal_reports_reported_by ON station_removal_reports(reported_by);
|
||||||
|
CREATE INDEX idx_admin_audit_logs_actor_id ON admin_audit_logs(actor_admin_id);
|
||||||
|
CREATE INDEX idx_admin_audit_logs_target_id ON admin_audit_logs(target_admin_id);
|
||||||
|
CREATE INDEX idx_platform_change_log_changed_by ON platform_change_log(changed_by);
|
||||||
|
|
||||||
|
-- 5j. Recreate UNIQUE constraints on new UUID columns
|
||||||
|
ALTER TABLE vehicles ADD CONSTRAINT unique_user_vin UNIQUE(user_id, vin);
|
||||||
|
ALTER TABLE saved_stations ADD CONSTRAINT unique_user_station UNIQUE(user_id, place_id);
|
||||||
|
ALTER TABLE user_preferences ADD CONSTRAINT user_preferences_user_id_key UNIQUE(user_id);
|
||||||
|
ALTER TABLE station_removal_reports ADD CONSTRAINT unique_user_station_report UNIQUE(station_id, reported_by);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -1,24 +1,42 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Structured logging with Winston
|
* @ai-summary Structured logging with Pino (Winston-compatible wrapper)
|
||||||
* @ai-context All features use this for consistent logging
|
* @ai-context All features use this for consistent logging. API maintains Winston compatibility.
|
||||||
*/
|
*/
|
||||||
import * as winston from 'winston';
|
import pino from 'pino';
|
||||||
|
|
||||||
export const logger = winston.createLogger({
|
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||||
level: 'info',
|
const validLevels: LogLevel[] = ['debug', 'info', 'warn', 'error'];
|
||||||
format: winston.format.combine(
|
|
||||||
winston.format.timestamp(),
|
const rawLevel = (process.env.LOG_LEVEL?.toLowerCase() || 'info') as LogLevel;
|
||||||
winston.format.errors({ stack: true }),
|
const level = validLevels.includes(rawLevel) ? rawLevel : 'info';
|
||||||
winston.format.json()
|
|
||||||
),
|
if (process.env.LOG_LEVEL && rawLevel !== level) {
|
||||||
defaultMeta: {
|
console.warn(`Invalid LOG_LEVEL "${process.env.LOG_LEVEL}", falling back to "info"`);
|
||||||
service: 'motovaultpro-backend',
|
}
|
||||||
|
|
||||||
|
const pinoLogger = pino({
|
||||||
|
level,
|
||||||
|
formatters: {
|
||||||
|
level: (label) => ({ level: label }),
|
||||||
},
|
},
|
||||||
transports: [
|
timestamp: pino.stdTimeFunctions.isoTime,
|
||||||
new winston.transports.Console({
|
|
||||||
format: winston.format.json(),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wrapper maintains logger.info(msg, meta) API for backward compatibility
|
||||||
|
export const logger = {
|
||||||
|
info: (msg: string, meta?: object) => pinoLogger.info(meta || {}, msg),
|
||||||
|
warn: (msg: string, meta?: object) => pinoLogger.warn(meta || {}, msg),
|
||||||
|
error: (msg: string, meta?: object) => pinoLogger.error(meta || {}, msg),
|
||||||
|
debug: (msg: string, meta?: object) => pinoLogger.debug(meta || {}, msg),
|
||||||
|
child: (bindings: object) => {
|
||||||
|
const childPino = pinoLogger.child(bindings);
|
||||||
|
return {
|
||||||
|
info: (msg: string, meta?: object) => childPino.info(meta || {}, msg),
|
||||||
|
warn: (msg: string, meta?: object) => childPino.warn(meta || {}, msg),
|
||||||
|
error: (msg: string, meta?: object) => childPino.error(meta || {}, msg),
|
||||||
|
debug: (msg: string, meta?: object) => childPino.debug(meta || {}, msg),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default logger;
|
export default logger;
|
||||||
191
backend/src/core/middleware/require-tier.test.ts
Normal file
191
backend/src/core/middleware/require-tier.test.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { requireTier } from './require-tier';
|
||||||
|
|
||||||
|
// Mock logger to suppress output during tests
|
||||||
|
jest.mock('../logging/logger', () => ({
|
||||||
|
logger: {
|
||||||
|
error: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createRequest = (subscriptionTier?: string): Partial<FastifyRequest> => {
|
||||||
|
if (subscriptionTier === undefined) {
|
||||||
|
return { userContext: undefined };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userContext: {
|
||||||
|
userId: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
email: 'user@example.com',
|
||||||
|
emailVerified: true,
|
||||||
|
onboardingCompleted: true,
|
||||||
|
isAdmin: false,
|
||||||
|
subscriptionTier: subscriptionTier as any,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createReply = (): Partial<FastifyReply> & { statusCode?: number; payload?: unknown } => {
|
||||||
|
const reply: any = {
|
||||||
|
sent: false,
|
||||||
|
code: jest.fn(function (this: any, status: number) {
|
||||||
|
this.statusCode = status;
|
||||||
|
return this;
|
||||||
|
}),
|
||||||
|
send: jest.fn(function (this: any, payload: unknown) {
|
||||||
|
this.payload = payload;
|
||||||
|
this.sent = true;
|
||||||
|
return this;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return reply;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('requireTier middleware', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pro user passes fuelLog.receiptScan check', () => {
|
||||||
|
it('allows pro user through without sending a response', async () => {
|
||||||
|
const handler = requireTier('fuelLog.receiptScan');
|
||||||
|
const request = createRequest('pro');
|
||||||
|
const reply = createReply();
|
||||||
|
|
||||||
|
await handler(request as FastifyRequest, reply as FastifyReply);
|
||||||
|
|
||||||
|
expect(reply.code).not.toHaveBeenCalled();
|
||||||
|
expect(reply.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enterprise user passes all checks (tier inheritance)', () => {
|
||||||
|
it('allows enterprise user access to pro-gated features', async () => {
|
||||||
|
const handler = requireTier('fuelLog.receiptScan');
|
||||||
|
const request = createRequest('enterprise');
|
||||||
|
const reply = createReply();
|
||||||
|
|
||||||
|
await handler(request as FastifyRequest, reply as FastifyReply);
|
||||||
|
|
||||||
|
expect(reply.code).not.toHaveBeenCalled();
|
||||||
|
expect(reply.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows enterprise user access to document.scanMaintenanceSchedule', async () => {
|
||||||
|
const handler = requireTier('document.scanMaintenanceSchedule');
|
||||||
|
const request = createRequest('enterprise');
|
||||||
|
const reply = createReply();
|
||||||
|
|
||||||
|
await handler(request as FastifyRequest, reply as FastifyReply);
|
||||||
|
|
||||||
|
expect(reply.code).not.toHaveBeenCalled();
|
||||||
|
expect(reply.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows enterprise user access to vehicle.vinDecode', async () => {
|
||||||
|
const handler = requireTier('vehicle.vinDecode');
|
||||||
|
const request = createRequest('enterprise');
|
||||||
|
const reply = createReply();
|
||||||
|
|
||||||
|
await handler(request as FastifyRequest, reply as FastifyReply);
|
||||||
|
|
||||||
|
expect(reply.code).not.toHaveBeenCalled();
|
||||||
|
expect(reply.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('free user blocked with 403 and correct response body', () => {
|
||||||
|
it('blocks free user from fuelLog.receiptScan', async () => {
|
||||||
|
const handler = requireTier('fuelLog.receiptScan');
|
||||||
|
const request = createRequest('free');
|
||||||
|
const reply = createReply();
|
||||||
|
|
||||||
|
await handler(request as FastifyRequest, reply as FastifyReply);
|
||||||
|
|
||||||
|
expect(reply.code).toHaveBeenCalledWith(403);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: 'TIER_REQUIRED',
|
||||||
|
requiredTier: 'pro',
|
||||||
|
currentTier: 'free',
|
||||||
|
featureName: 'Receipt Scan',
|
||||||
|
upgradePrompt: expect.any(String),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks free user from document.scanMaintenanceSchedule', async () => {
|
||||||
|
const handler = requireTier('document.scanMaintenanceSchedule');
|
||||||
|
const request = createRequest('free');
|
||||||
|
const reply = createReply();
|
||||||
|
|
||||||
|
await handler(request as FastifyRequest, reply as FastifyReply);
|
||||||
|
|
||||||
|
expect(reply.code).toHaveBeenCalledWith(403);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: 'TIER_REQUIRED',
|
||||||
|
requiredTier: 'pro',
|
||||||
|
currentTier: 'free',
|
||||||
|
featureName: 'Scan for Maintenance Schedule',
|
||||||
|
upgradePrompt: expect.any(String),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('response body includes all required fields', async () => {
|
||||||
|
const handler = requireTier('fuelLog.receiptScan');
|
||||||
|
const request = createRequest('free');
|
||||||
|
const reply = createReply();
|
||||||
|
|
||||||
|
await handler(request as FastifyRequest, reply as FastifyReply);
|
||||||
|
|
||||||
|
const body = (reply.send as jest.Mock).mock.calls[0][0];
|
||||||
|
expect(body).toHaveProperty('requiredTier', 'pro');
|
||||||
|
expect(body).toHaveProperty('currentTier', 'free');
|
||||||
|
expect(body).toHaveProperty('featureName', 'Receipt Scan');
|
||||||
|
expect(body).toHaveProperty('upgradePrompt');
|
||||||
|
expect(typeof body.upgradePrompt).toBe('string');
|
||||||
|
expect(body.upgradePrompt.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unknown feature key returns 500', () => {
|
||||||
|
it('returns 500 INTERNAL_ERROR for unregistered feature', async () => {
|
||||||
|
const handler = requireTier('unknown.nonexistent.feature');
|
||||||
|
const request = createRequest('pro');
|
||||||
|
const reply = createReply();
|
||||||
|
|
||||||
|
await handler(request as FastifyRequest, reply as FastifyReply);
|
||||||
|
|
||||||
|
expect(reply.code).toHaveBeenCalledWith(500);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Unknown feature configuration',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('missing user.tier on request returns 403', () => {
|
||||||
|
it('defaults to free tier when userContext is undefined', async () => {
|
||||||
|
const handler = requireTier('fuelLog.receiptScan');
|
||||||
|
const request = createRequest(); // no tier = undefined userContext
|
||||||
|
const reply = createReply();
|
||||||
|
|
||||||
|
await handler(request as FastifyRequest, reply as FastifyReply);
|
||||||
|
|
||||||
|
expect(reply.code).toHaveBeenCalledWith(403);
|
||||||
|
expect(reply.send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: 'TIER_REQUIRED',
|
||||||
|
currentTier: 'free',
|
||||||
|
requiredTier: 'pro',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
64
backend/src/core/middleware/require-tier.ts
Normal file
64
backend/src/core/middleware/require-tier.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Standalone tier guard middleware for route-level feature gating
|
||||||
|
* @ai-context Returns a Fastify preHandler that checks user subscription tier against feature requirements.
|
||||||
|
* Must be composed AFTER requireAuth in preHandler arrays.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { canAccessFeature, getFeatureConfig } from '../config/feature-tiers';
|
||||||
|
import { logger } from '../logging/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a preHandler middleware that enforces subscription tier requirements.
|
||||||
|
*
|
||||||
|
* Reads the user's tier from request.userContext.subscriptionTier (set by auth middleware).
|
||||||
|
* Must be placed AFTER requireAuth in the preHandler chain.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* fastify.post('/premium-route', {
|
||||||
|
* preHandler: [requireAuth, requireTier('fuelLog.receiptScan')],
|
||||||
|
* handler: controller.method
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @param featureKey - Key from FEATURE_TIERS registry (e.g. 'fuelLog.receiptScan')
|
||||||
|
* @returns Fastify preHandler function
|
||||||
|
*/
|
||||||
|
export function requireTier(featureKey: string) {
|
||||||
|
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||||
|
// Validate feature key exists in registry
|
||||||
|
const featureConfig = getFeatureConfig(featureKey);
|
||||||
|
if (!featureConfig) {
|
||||||
|
logger.error('requireTier: unknown feature key', { featureKey });
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'INTERNAL_ERROR',
|
||||||
|
message: 'Unknown feature configuration',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user tier from userContext (populated by auth middleware)
|
||||||
|
const currentTier = request.userContext?.subscriptionTier || 'free';
|
||||||
|
|
||||||
|
if (!canAccessFeature(currentTier, featureKey)) {
|
||||||
|
logger.warn('requireTier: access denied', {
|
||||||
|
userId: request.userContext?.userId?.substring(0, 8) + '...',
|
||||||
|
currentTier,
|
||||||
|
requiredTier: featureConfig.minTier,
|
||||||
|
featureKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: 'TIER_REQUIRED',
|
||||||
|
requiredTier: featureConfig.minTier,
|
||||||
|
currentTier,
|
||||||
|
featureName: featureConfig.name,
|
||||||
|
upgradePrompt: featureConfig.upgradePrompt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('requireTier: access granted', {
|
||||||
|
userId: request.userContext?.userId?.substring(0, 8) + '...',
|
||||||
|
currentTier,
|
||||||
|
featureKey,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -58,9 +58,9 @@ const adminGuardPlugin: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// Check if user is in admin_users table and not revoked
|
// Check if user is in admin_users table and not revoked
|
||||||
const query = `
|
const query = `
|
||||||
SELECT auth0_sub, email, role, revoked_at
|
SELECT id, user_profile_id, email, role, revoked_at
|
||||||
FROM admin_users
|
FROM admin_users
|
||||||
WHERE auth0_sub = $1 AND revoked_at IS NULL
|
WHERE user_profile_id = $1 AND revoked_at IS NULL
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -121,11 +121,14 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
|||||||
try {
|
try {
|
||||||
await request.jwtVerify();
|
await request.jwtVerify();
|
||||||
|
|
||||||
const userId = request.user?.sub;
|
// Two identifiers: auth0Sub (external, for Auth0 API) and userId (internal UUID, for all DB operations)
|
||||||
if (!userId) {
|
const auth0Sub = request.user?.sub;
|
||||||
|
if (!auth0Sub) {
|
||||||
throw new Error('Missing user ID in JWT');
|
throw new Error('Missing user ID in JWT');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let userId: string = auth0Sub; // Default to auth0Sub; overwritten with UUID after profile load
|
||||||
|
|
||||||
// Get or create user profile from database
|
// Get or create user profile from database
|
||||||
let email = request.user?.email;
|
let email = request.user?.email;
|
||||||
let displayName: string | undefined;
|
let displayName: string | undefined;
|
||||||
@@ -137,28 +140,29 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
|||||||
// If JWT doesn't have email, fetch from Auth0 Management API
|
// If JWT doesn't have email, fetch from Auth0 Management API
|
||||||
if (!email || email.includes('@unknown.local')) {
|
if (!email || email.includes('@unknown.local')) {
|
||||||
try {
|
try {
|
||||||
const auth0User = await auth0ManagementClient.getUser(userId);
|
const auth0User = await auth0ManagementClient.getUser(auth0Sub);
|
||||||
if (auth0User.email) {
|
if (auth0User.email) {
|
||||||
email = auth0User.email;
|
email = auth0User.email;
|
||||||
emailVerified = auth0User.emailVerified;
|
emailVerified = auth0User.emailVerified;
|
||||||
logger.info('Fetched email from Auth0 Management API', {
|
logger.info('Fetched email from Auth0 Management API', {
|
||||||
userId: userId.substring(0, 8) + '...',
|
userId: auth0Sub.substring(0, 8) + '...',
|
||||||
hasEmail: true,
|
hasEmail: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (auth0Error) {
|
} catch (auth0Error) {
|
||||||
logger.warn('Failed to fetch user from Auth0 Management API', {
|
logger.warn('Failed to fetch user from Auth0 Management API', {
|
||||||
userId: userId.substring(0, 8) + '...',
|
userId: auth0Sub.substring(0, 8) + '...',
|
||||||
error: auth0Error instanceof Error ? auth0Error.message : 'Unknown error',
|
error: auth0Error instanceof Error ? auth0Error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get or create profile with correct email
|
// Get or create profile with correct email
|
||||||
const profile = await profileRepo.getOrCreate(userId, {
|
const profile = await profileRepo.getOrCreate(auth0Sub, {
|
||||||
email: email || `${userId}@unknown.local`,
|
email: email || `${auth0Sub}@unknown.local`,
|
||||||
displayName: request.user?.name || request.user?.nickname,
|
displayName: request.user?.name || request.user?.nickname,
|
||||||
});
|
});
|
||||||
|
userId = profile.id;
|
||||||
|
|
||||||
// If profile has placeholder email but we now have real email, update it
|
// If profile has placeholder email but we now have real email, update it
|
||||||
if (profile.email.includes('@unknown.local') && email && !email.includes('@unknown.local')) {
|
if (profile.email.includes('@unknown.local') && email && !email.includes('@unknown.local')) {
|
||||||
@@ -178,7 +182,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Sync email verification status from Auth0 if needed
|
// Sync email verification status from Auth0 if needed
|
||||||
if (!emailVerified) {
|
if (!emailVerified) {
|
||||||
try {
|
try {
|
||||||
const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(userId);
|
const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(auth0Sub);
|
||||||
if (isVerifiedInAuth0 && !profile.emailVerified) {
|
if (isVerifiedInAuth0 && !profile.emailVerified) {
|
||||||
await profileRepo.updateEmailVerified(userId, true);
|
await profileRepo.updateEmailVerified(userId, true);
|
||||||
emailVerified = true;
|
emailVerified = true;
|
||||||
@@ -197,7 +201,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
|||||||
} catch (profileError) {
|
} catch (profileError) {
|
||||||
// Log but don't fail auth if profile fetch fails
|
// Log but don't fail auth if profile fetch fails
|
||||||
logger.warn('Failed to fetch user profile', {
|
logger.warn('Failed to fetch user profile', {
|
||||||
userId: userId.substring(0, 8) + '...',
|
userId: auth0Sub.substring(0, 8) + '...',
|
||||||
error: profileError instanceof Error ? profileError.message : 'Unknown error',
|
error: profileError instanceof Error ? profileError.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
// Fall back to JWT email if available
|
// Fall back to JWT email if available
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Fastify request logging plugin
|
* @ai-summary Fastify request logging plugin with correlation IDs
|
||||||
* @ai-context Logs request/response details with timing
|
* @ai-context Logs request/response details with timing and requestId
|
||||||
*/
|
*/
|
||||||
import { FastifyPluginAsync } from 'fastify';
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
import fp from 'fastify-plugin';
|
import fp from 'fastify-plugin';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import { logger } from '../logging/logger';
|
import { logger } from '../logging/logger';
|
||||||
|
|
||||||
const loggingPlugin: FastifyPluginAsync = async (fastify) => {
|
const loggingPlugin: FastifyPluginAsync = async (fastify) => {
|
||||||
fastify.addHook('onRequest', async (request) => {
|
fastify.addHook('onRequest', async (request) => {
|
||||||
request.startTime = Date.now();
|
request.startTime = Date.now();
|
||||||
|
// Extract X-Request-Id from Traefik or generate new UUID
|
||||||
|
request.requestId = (request.headers['x-request-id'] as string) || randomUUID();
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.addHook('onResponse', async (request, reply) => {
|
fastify.addHook('onResponse', async (request, reply) => {
|
||||||
const duration = Date.now() - (request.startTime || Date.now());
|
const duration = Date.now() - (request.startTime || Date.now());
|
||||||
|
|
||||||
logger.info('Request processed', {
|
logger.info('Request processed', {
|
||||||
|
requestId: request.requestId,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
path: request.url,
|
path: request.url,
|
||||||
status: reply.statusCode,
|
status: reply.statusCode,
|
||||||
@@ -24,10 +28,10 @@ const loggingPlugin: FastifyPluginAsync = async (fastify) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Augment FastifyRequest to include startTime
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
startTime?: number;
|
startTime?: number;
|
||||||
|
requestId?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe('tier guard plugin', () => {
|
|||||||
// Mock authenticate to set userContext
|
// Mock authenticate to set userContext
|
||||||
authenticateMock = jest.fn(async (request: FastifyRequest) => {
|
authenticateMock = jest.fn(async (request: FastifyRequest) => {
|
||||||
request.userContext = {
|
request.userContext = {
|
||||||
userId: 'auth0|user123',
|
userId: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
onboardingCompleted: true,
|
onboardingCompleted: true,
|
||||||
@@ -48,7 +48,7 @@ describe('tier guard plugin', () => {
|
|||||||
it('allows access when user tier meets minimum', async () => {
|
it('allows access when user tier meets minimum', async () => {
|
||||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||||
request.userContext = {
|
request.userContext = {
|
||||||
userId: 'auth0|user123',
|
userId: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
onboardingCompleted: true,
|
onboardingCompleted: true,
|
||||||
@@ -71,7 +71,7 @@ describe('tier guard plugin', () => {
|
|||||||
it('allows access when user tier exceeds minimum', async () => {
|
it('allows access when user tier exceeds minimum', async () => {
|
||||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||||
request.userContext = {
|
request.userContext = {
|
||||||
userId: 'auth0|user123',
|
userId: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
onboardingCompleted: true,
|
onboardingCompleted: true,
|
||||||
@@ -130,7 +130,7 @@ describe('tier guard plugin', () => {
|
|||||||
it('allows pro tier access to pro feature', async () => {
|
it('allows pro tier access to pro feature', async () => {
|
||||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||||
request.userContext = {
|
request.userContext = {
|
||||||
userId: 'auth0|user123',
|
userId: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
onboardingCompleted: true,
|
onboardingCompleted: true,
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ import {
|
|||||||
processBackupRetention,
|
processBackupRetention,
|
||||||
setBackupCleanupJobPool,
|
setBackupCleanupJobPool,
|
||||||
} from '../../features/backup/jobs/backup-cleanup.job';
|
} from '../../features/backup/jobs/backup-cleanup.job';
|
||||||
|
import {
|
||||||
|
processAuditLogCleanup,
|
||||||
|
setAuditLogCleanupJobPool,
|
||||||
|
} from '../../features/audit-log/jobs/cleanup.job';
|
||||||
|
import {
|
||||||
|
processGracePeriodExpirations,
|
||||||
|
setGracePeriodJobPool,
|
||||||
|
} from '../../features/subscriptions/jobs/grace-period.job';
|
||||||
import { pool } from '../config/database';
|
import { pool } from '../config/database';
|
||||||
|
|
||||||
let schedulerInitialized = false;
|
let schedulerInitialized = false;
|
||||||
@@ -31,6 +39,12 @@ export function initializeScheduler(): void {
|
|||||||
setBackupJobPool(pool);
|
setBackupJobPool(pool);
|
||||||
setBackupCleanupJobPool(pool);
|
setBackupCleanupJobPool(pool);
|
||||||
|
|
||||||
|
// Initialize audit log cleanup job pool
|
||||||
|
setAuditLogCleanupJobPool(pool);
|
||||||
|
|
||||||
|
// Initialize grace period job pool
|
||||||
|
setGracePeriodJobPool(pool);
|
||||||
|
|
||||||
// Daily notification processing at 8 AM
|
// Daily notification processing at 8 AM
|
||||||
cron.schedule('0 8 * * *', async () => {
|
cron.schedule('0 8 * * *', async () => {
|
||||||
logger.info('Running scheduled notification job');
|
logger.info('Running scheduled notification job');
|
||||||
@@ -60,6 +74,23 @@ export function initializeScheduler(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Grace period expiration check at 2:30 AM daily
|
||||||
|
cron.schedule('30 2 * * *', async () => {
|
||||||
|
logger.info('Running grace period expiration job');
|
||||||
|
try {
|
||||||
|
const result = await processGracePeriodExpirations();
|
||||||
|
logger.info('Grace period job completed', {
|
||||||
|
processed: result.processed,
|
||||||
|
downgraded: result.downgraded,
|
||||||
|
errors: result.errors.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Grace period job failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Check for scheduled backups every minute
|
// Check for scheduled backups every minute
|
||||||
cron.schedule('* * * * *', async () => {
|
cron.schedule('* * * * *', async () => {
|
||||||
logger.debug('Checking for scheduled backups');
|
logger.debug('Checking for scheduled backups');
|
||||||
@@ -90,8 +121,30 @@ export function initializeScheduler(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Audit log retention cleanup at 3 AM daily (90-day retention)
|
||||||
|
cron.schedule('0 3 * * *', async () => {
|
||||||
|
logger.info('Running audit log cleanup job');
|
||||||
|
try {
|
||||||
|
const result = await processAuditLogCleanup();
|
||||||
|
if (result.success) {
|
||||||
|
logger.info('Audit log cleanup job completed', {
|
||||||
|
deletedCount: result.deletedCount,
|
||||||
|
retentionDays: result.retentionDays,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Audit log cleanup job failed', {
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Audit log cleanup job failed', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
schedulerInitialized = true;
|
schedulerInitialized = true;
|
||||||
logger.info('Cron scheduler initialized - notification (8 AM), account purge (2 AM), backup check (every min), retention cleanup (4 AM)');
|
logger.info('Cron scheduler initialized - notification (8 AM), account purge (2 AM), grace period (2:30 AM), audit log cleanup (3 AM), backup check (every min), retention cleanup (4 AM)');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSchedulerInitialized(): boolean {
|
export function isSchedulerInitialized(): boolean {
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
# backend/src/features/
|
# backend/src/features/
|
||||||
|
|
||||||
Feature capsule directory. Each feature is 100% self-contained with api/, domain/, data/, migrations/, tests/.
|
|
||||||
|
|
||||||
## Subdirectories
|
## Subdirectories
|
||||||
|
|
||||||
| Directory | What | When to read |
|
| Directory | What | When to read |
|
||||||
| --------- | ---- | ------------ |
|
| --------- | ---- | ------------ |
|
||||||
| `admin/` | Admin role management, catalog CRUD | Admin functionality, user oversight |
|
| `admin/` | Admin role management, catalog CRUD | Admin functionality, user oversight |
|
||||||
|
| `audit-log/` | Centralized audit logging | Cross-feature event logging, admin logs UI |
|
||||||
| `auth/` | Authentication endpoints | Login, logout, session management |
|
| `auth/` | Authentication endpoints | Login, logout, session management |
|
||||||
| `backup/` | Database backup and restore | Backup jobs, data export/import |
|
| `backup/` | Database backup and restore | Backup jobs, data export/import |
|
||||||
| `documents/` | Document storage and management | File uploads, document handling |
|
| `documents/` | Document storage and management | File uploads, document handling |
|
||||||
| `fuel-logs/` | Fuel consumption tracking | Fuel log CRUD, statistics |
|
| `fuel-logs/` | Fuel consumption tracking | Fuel log CRUD, statistics |
|
||||||
| `maintenance/` | Maintenance record management | Service records, reminders |
|
| `maintenance/` | Maintenance record management | Service records, reminders |
|
||||||
| `notifications/` | Email and push notifications | Alert system, email templates |
|
| `notifications/` | Email and push notifications | Alert system, email templates |
|
||||||
|
| `ocr/` | OCR proxy to mvp-ocr service (VIN, receipt, manual extraction) | Image text extraction, receipt scanning, manual PDF extraction, async jobs |
|
||||||
| `onboarding/` | User onboarding flow | First-time user setup |
|
| `onboarding/` | User onboarding flow | First-time user setup |
|
||||||
|
| `ownership-costs/` | Ownership cost tracking and reports | Cost aggregation, expense analysis |
|
||||||
| `platform/` | Vehicle data and VIN decoding | Make/model lookup, VIN validation |
|
| `platform/` | Vehicle data and VIN decoding | Make/model lookup, VIN validation |
|
||||||
| `stations/` | Gas station search and favorites | Google Maps integration, station data |
|
| `stations/` | Gas station search and favorites | Google Maps integration, station data |
|
||||||
|
| `subscriptions/` | Stripe payment and billing | Subscription tiers, donations, webhooks |
|
||||||
| `terms-agreement/` | Terms & Conditions acceptance audit | Signup T&C, legal compliance |
|
| `terms-agreement/` | Terms & Conditions acceptance audit | Signup T&C, legal compliance |
|
||||||
| `user-export/` | User data export | GDPR compliance, data portability |
|
| `user-export/` | User data export | GDPR compliance, data portability |
|
||||||
|
| `user-import/` | User data import | Restore from backup, data migration |
|
||||||
| `user-preferences/` | User preference management | User settings API |
|
| `user-preferences/` | User preference management | User settings API |
|
||||||
| `user-profile/` | User profile management | Profile CRUD, avatar handling |
|
| `user-profile/` | User profile management | Profile CRUD, avatar handling |
|
||||||
| `vehicles/` | Vehicle management | Vehicle CRUD, fleet operations |
|
| `vehicles/` | Vehicle management | Vehicle CRUD, fleet operations |
|
||||||
|
|||||||
18
backend/src/features/admin/CLAUDE.md
Normal file
18
backend/src/features/admin/CLAUDE.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# admin/
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What | When to read |
|
||||||
|
| ---- | ---- | ------------ |
|
||||||
|
| `README.md` | Feature documentation | Understanding admin functionality |
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
|
||||||
|
| Directory | What | When to read |
|
||||||
|
| --------- | ---- | ------------ |
|
||||||
|
| `api/` | HTTP endpoints and routes | API changes |
|
||||||
|
| `domain/` | Business logic, services, types | Core admin logic |
|
||||||
|
| `data/` | Repository, database queries | Database operations |
|
||||||
|
| `migrations/` | Database schema | Schema changes |
|
||||||
|
| `scripts/` | Admin utility scripts | Admin automation |
|
||||||
|
| `tests/` | Unit and integration tests | Adding or modifying tests |
|
||||||
@@ -6,11 +6,12 @@
|
|||||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { AdminService } from '../domain/admin.service';
|
import { AdminService } from '../domain/admin.service';
|
||||||
import { AdminRepository } from '../data/admin.repository';
|
import { AdminRepository } from '../data/admin.repository';
|
||||||
|
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||||
import { pool } from '../../../core/config/database';
|
import { pool } from '../../../core/config/database';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
import {
|
import {
|
||||||
CreateAdminInput,
|
CreateAdminInput,
|
||||||
AdminAuth0SubInput,
|
AdminIdInput,
|
||||||
AuditLogsQueryInput,
|
AuditLogsQueryInput,
|
||||||
BulkCreateAdminInput,
|
BulkCreateAdminInput,
|
||||||
BulkRevokeAdminInput,
|
BulkRevokeAdminInput,
|
||||||
@@ -18,7 +19,7 @@ import {
|
|||||||
} from './admin.validation';
|
} from './admin.validation';
|
||||||
import {
|
import {
|
||||||
createAdminSchema,
|
createAdminSchema,
|
||||||
adminAuth0SubSchema,
|
adminIdSchema,
|
||||||
auditLogsQuerySchema,
|
auditLogsQuerySchema,
|
||||||
bulkCreateAdminSchema,
|
bulkCreateAdminSchema,
|
||||||
bulkRevokeAdminSchema,
|
bulkRevokeAdminSchema,
|
||||||
@@ -33,10 +34,12 @@ import {
|
|||||||
|
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
private adminService: AdminService;
|
private adminService: AdminService;
|
||||||
|
private userProfileRepository: UserProfileRepository;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const repository = new AdminRepository(pool);
|
const repository = new AdminRepository(pool);
|
||||||
this.adminService = new AdminService(repository);
|
this.adminService = new AdminService(repository);
|
||||||
|
this.userProfileRepository = new UserProfileRepository(pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,49 +50,18 @@ export class AdminController {
|
|||||||
const userId = request.userContext?.userId;
|
const userId = request.userContext?.userId;
|
||||||
const userEmail = this.resolveUserEmail(request);
|
const userEmail = this.resolveUserEmail(request);
|
||||||
|
|
||||||
console.log('[DEBUG] Admin verify - userId:', userId);
|
|
||||||
console.log('[DEBUG] Admin verify - userEmail:', userEmail);
|
|
||||||
|
|
||||||
if (userEmail && request.userContext) {
|
if (userEmail && request.userContext) {
|
||||||
request.userContext.email = userEmail.toLowerCase();
|
request.userContext.email = userEmail.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userId && !userEmail) {
|
if (!userId) {
|
||||||
console.log('[DEBUG] Admin verify - No userId or userEmail, returning 401');
|
|
||||||
return reply.code(401).send({
|
return reply.code(401).send({
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
message: 'User context missing'
|
message: 'User context missing'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let adminRecord = userId
|
const adminRecord = await this.adminService.getAdminByUserProfileId(userId);
|
||||||
? await this.adminService.getAdminByAuth0Sub(userId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
console.log('[DEBUG] Admin verify - adminRecord by auth0Sub:', adminRecord ? 'FOUND' : 'NOT FOUND');
|
|
||||||
|
|
||||||
// Fallback: attempt to resolve admin by email for legacy records
|
|
||||||
if (!adminRecord && userEmail) {
|
|
||||||
const emailMatch = await this.adminService.getAdminByEmail(userEmail.toLowerCase());
|
|
||||||
|
|
||||||
console.log('[DEBUG] Admin verify - emailMatch:', emailMatch ? 'FOUND' : 'NOT FOUND');
|
|
||||||
if (emailMatch) {
|
|
||||||
console.log('[DEBUG] Admin verify - emailMatch.auth0Sub:', emailMatch.auth0Sub);
|
|
||||||
console.log('[DEBUG] Admin verify - emailMatch.revokedAt:', emailMatch.revokedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emailMatch && !emailMatch.revokedAt) {
|
|
||||||
// If the stored auth0Sub differs, link it to the authenticated user
|
|
||||||
if (userId && emailMatch.auth0Sub !== userId) {
|
|
||||||
console.log('[DEBUG] Admin verify - Calling linkAdminAuth0Sub to update auth0Sub');
|
|
||||||
adminRecord = await this.adminService.linkAdminAuth0Sub(userEmail, userId);
|
|
||||||
console.log('[DEBUG] Admin verify - adminRecord after link:', adminRecord ? 'SUCCESS' : 'FAILED');
|
|
||||||
} else {
|
|
||||||
console.log('[DEBUG] Admin verify - Using emailMatch as adminRecord');
|
|
||||||
adminRecord = emailMatch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adminRecord && !adminRecord.revokedAt) {
|
if (adminRecord && !adminRecord.revokedAt) {
|
||||||
if (request.userContext) {
|
if (request.userContext) {
|
||||||
@@ -97,12 +69,11 @@ export class AdminController {
|
|||||||
request.userContext.adminRecord = adminRecord;
|
request.userContext.adminRecord = adminRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[DEBUG] Admin verify - Returning isAdmin: true');
|
|
||||||
// User is an active admin
|
|
||||||
return reply.code(200).send({
|
return reply.code(200).send({
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
adminRecord: {
|
adminRecord: {
|
||||||
auth0Sub: adminRecord.auth0Sub,
|
id: adminRecord.id,
|
||||||
|
userProfileId: adminRecord.userProfileId,
|
||||||
email: adminRecord.email,
|
email: adminRecord.email,
|
||||||
role: adminRecord.role
|
role: adminRecord.role
|
||||||
}
|
}
|
||||||
@@ -114,14 +85,11 @@ export class AdminController {
|
|||||||
request.userContext.adminRecord = undefined;
|
request.userContext.adminRecord = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[DEBUG] Admin verify - Returning isAdmin: false');
|
|
||||||
// User is not an admin
|
|
||||||
return reply.code(200).send({
|
return reply.code(200).send({
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
adminRecord: null
|
adminRecord: null
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('[DEBUG] Admin verify - Error caught:', error instanceof Error ? error.message : 'Unknown error');
|
|
||||||
logger.error('Error verifying admin access', {
|
logger.error('Error verifying admin access', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
userId: request.userContext?.userId?.substring(0, 8) + '...'
|
userId: request.userContext?.userId?.substring(0, 8) + '...'
|
||||||
@@ -139,9 +107,9 @@ export class AdminController {
|
|||||||
*/
|
*/
|
||||||
async listAdmins(request: FastifyRequest, reply: FastifyReply) {
|
async listAdmins(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const actorId = request.userContext?.userId;
|
const actorUserProfileId = request.userContext?.userId;
|
||||||
|
|
||||||
if (!actorId) {
|
if (!actorUserProfileId) {
|
||||||
return reply.code(401).send({
|
return reply.code(401).send({
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
message: 'User context missing'
|
message: 'User context missing'
|
||||||
@@ -150,11 +118,6 @@ export class AdminController {
|
|||||||
|
|
||||||
const admins = await this.adminService.getAllAdmins();
|
const admins = await this.adminService.getAllAdmins();
|
||||||
|
|
||||||
// Log VIEW action
|
|
||||||
await this.adminService.getAdminByAuth0Sub(actorId);
|
|
||||||
// Note: Not logging VIEW as it would create excessive audit entries
|
|
||||||
// VIEW logging can be enabled if needed for compliance
|
|
||||||
|
|
||||||
return reply.code(200).send({
|
return reply.code(200).send({
|
||||||
total: admins.length,
|
total: admins.length,
|
||||||
admins
|
admins
|
||||||
@@ -162,7 +125,7 @@ export class AdminController {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error listing admins', {
|
logger.error('Error listing admins', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
actorId: request.userContext?.userId
|
actorUserProfileId: request.userContext?.userId
|
||||||
});
|
});
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
@@ -179,15 +142,24 @@ export class AdminController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const actorId = request.userContext?.userId;
|
const actorUserProfileId = request.userContext?.userId;
|
||||||
|
|
||||||
if (!actorId) {
|
if (!actorUserProfileId) {
|
||||||
return reply.code(401).send({
|
return reply.code(401).send({
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
message: 'User context missing'
|
message: 'User context missing'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get actor's admin record to get admin ID
|
||||||
|
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
|
||||||
|
if (!actorAdmin) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'Actor is not an admin'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Validate request body
|
// Validate request body
|
||||||
const validation = createAdminSchema.safeParse(request.body);
|
const validation = createAdminSchema.safeParse(request.body);
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
@@ -200,23 +172,27 @@ export class AdminController {
|
|||||||
|
|
||||||
const { email, role } = validation.data;
|
const { email, role } = validation.data;
|
||||||
|
|
||||||
// Generate auth0Sub for the new admin
|
// Look up user profile by email to get UUID
|
||||||
// In production, this should be the actual Auth0 user ID
|
const userProfile = await this.userProfileRepository.getByEmail(email);
|
||||||
// For now, we'll use email-based identifier
|
if (!userProfile) {
|
||||||
const auth0Sub = `auth0|${email.replace('@', '_at_')}`;
|
return reply.code(404).send({
|
||||||
|
error: 'Not Found',
|
||||||
|
message: `No user profile found with email ${email}. User must sign up first.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const admin = await this.adminService.createAdmin(
|
const admin = await this.adminService.createAdmin(
|
||||||
email,
|
email,
|
||||||
role,
|
role,
|
||||||
auth0Sub,
|
userProfile.id,
|
||||||
actorId
|
actorAdmin.id
|
||||||
);
|
);
|
||||||
|
|
||||||
return reply.code(201).send(admin);
|
return reply.code(201).send(admin);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error creating admin', {
|
logger.error('Error creating admin', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
actorId: request.userContext?.userId
|
actorUserProfileId: request.userContext?.userId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error.message.includes('already exists')) {
|
if (error.message.includes('already exists')) {
|
||||||
@@ -234,36 +210,45 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/admins/:auth0Sub/revoke - Revoke admin access
|
* PATCH /api/admin/admins/:id/revoke - Revoke admin access
|
||||||
*/
|
*/
|
||||||
async revokeAdmin(
|
async revokeAdmin(
|
||||||
request: FastifyRequest<{ Params: AdminAuth0SubInput }>,
|
request: FastifyRequest<{ Params: AdminIdInput }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const actorId = request.userContext?.userId;
|
const actorUserProfileId = request.userContext?.userId;
|
||||||
|
|
||||||
if (!actorId) {
|
if (!actorUserProfileId) {
|
||||||
return reply.code(401).send({
|
return reply.code(401).send({
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
message: 'User context missing'
|
message: 'User context missing'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get actor's admin record
|
||||||
|
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
|
||||||
|
if (!actorAdmin) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'Actor is not an admin'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Validate params
|
// Validate params
|
||||||
const validation = adminAuth0SubSchema.safeParse(request.params);
|
const validation = adminIdSchema.safeParse(request.params);
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: 'Invalid auth0Sub parameter',
|
message: 'Invalid admin ID parameter',
|
||||||
details: validation.error.errors
|
details: validation.error.errors
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { auth0Sub } = validation.data;
|
const { id } = validation.data;
|
||||||
|
|
||||||
// Check if admin exists
|
// Check if admin exists
|
||||||
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
|
const targetAdmin = await this.adminService.getAdminById(id);
|
||||||
if (!targetAdmin) {
|
if (!targetAdmin) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
error: 'Not Found',
|
error: 'Not Found',
|
||||||
@@ -272,14 +257,14 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Revoke the admin (service handles last admin check)
|
// Revoke the admin (service handles last admin check)
|
||||||
const admin = await this.adminService.revokeAdmin(auth0Sub, actorId);
|
const admin = await this.adminService.revokeAdmin(id, actorAdmin.id);
|
||||||
|
|
||||||
return reply.code(200).send(admin);
|
return reply.code(200).send(admin);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error revoking admin', {
|
logger.error('Error revoking admin', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
actorId: request.userContext?.userId,
|
actorUserProfileId: request.userContext?.userId,
|
||||||
targetAuth0Sub: request.params.auth0Sub
|
targetAdminId: (request.params as any).id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error.message.includes('Cannot revoke the last active admin')) {
|
if (error.message.includes('Cannot revoke the last active admin')) {
|
||||||
@@ -304,36 +289,45 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/admins/:auth0Sub/reinstate - Restore revoked admin
|
* PATCH /api/admin/admins/:id/reinstate - Restore revoked admin
|
||||||
*/
|
*/
|
||||||
async reinstateAdmin(
|
async reinstateAdmin(
|
||||||
request: FastifyRequest<{ Params: AdminAuth0SubInput }>,
|
request: FastifyRequest<{ Params: AdminIdInput }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const actorId = request.userContext?.userId;
|
const actorUserProfileId = request.userContext?.userId;
|
||||||
|
|
||||||
if (!actorId) {
|
if (!actorUserProfileId) {
|
||||||
return reply.code(401).send({
|
return reply.code(401).send({
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
message: 'User context missing'
|
message: 'User context missing'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get actor's admin record
|
||||||
|
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
|
||||||
|
if (!actorAdmin) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'Actor is not an admin'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Validate params
|
// Validate params
|
||||||
const validation = adminAuth0SubSchema.safeParse(request.params);
|
const validation = adminIdSchema.safeParse(request.params);
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: 'Invalid auth0Sub parameter',
|
message: 'Invalid admin ID parameter',
|
||||||
details: validation.error.errors
|
details: validation.error.errors
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { auth0Sub } = validation.data;
|
const { id } = validation.data;
|
||||||
|
|
||||||
// Check if admin exists
|
// Check if admin exists
|
||||||
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
|
const targetAdmin = await this.adminService.getAdminById(id);
|
||||||
if (!targetAdmin) {
|
if (!targetAdmin) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
error: 'Not Found',
|
error: 'Not Found',
|
||||||
@@ -342,14 +336,14 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reinstate the admin
|
// Reinstate the admin
|
||||||
const admin = await this.adminService.reinstateAdmin(auth0Sub, actorId);
|
const admin = await this.adminService.reinstateAdmin(id, actorAdmin.id);
|
||||||
|
|
||||||
return reply.code(200).send(admin);
|
return reply.code(200).send(admin);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error reinstating admin', {
|
logger.error('Error reinstating admin', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
actorId: request.userContext?.userId,
|
actorUserProfileId: request.userContext?.userId,
|
||||||
targetAuth0Sub: request.params.auth0Sub
|
targetAdminId: (request.params as any).id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
@@ -418,15 +412,24 @@ export class AdminController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const actorId = request.userContext?.userId;
|
const actorUserProfileId = request.userContext?.userId;
|
||||||
|
|
||||||
if (!actorId) {
|
if (!actorUserProfileId) {
|
||||||
return reply.code(401).send({
|
return reply.code(401).send({
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
message: 'User context missing'
|
message: 'User context missing'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get actor's admin record
|
||||||
|
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
|
||||||
|
if (!actorAdmin) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'Actor is not an admin'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Validate request body
|
// Validate request body
|
||||||
const validation = bulkCreateAdminSchema.safeParse(request.body);
|
const validation = bulkCreateAdminSchema.safeParse(request.body);
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
@@ -447,15 +450,21 @@ export class AdminController {
|
|||||||
try {
|
try {
|
||||||
const { email, role = 'admin' } = adminInput;
|
const { email, role = 'admin' } = adminInput;
|
||||||
|
|
||||||
// Generate auth0Sub for the new admin
|
// Look up user profile by email to get UUID
|
||||||
// In production, this should be the actual Auth0 user ID
|
const userProfile = await this.userProfileRepository.getByEmail(email);
|
||||||
const auth0Sub = `auth0|${email.replace('@', '_at_')}`;
|
if (!userProfile) {
|
||||||
|
failed.push({
|
||||||
|
email,
|
||||||
|
error: `No user profile found with email ${email}. User must sign up first.`
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const admin = await this.adminService.createAdmin(
|
const admin = await this.adminService.createAdmin(
|
||||||
email,
|
email,
|
||||||
role,
|
role,
|
||||||
auth0Sub,
|
userProfile.id,
|
||||||
actorId
|
actorAdmin.id
|
||||||
);
|
);
|
||||||
|
|
||||||
created.push(admin);
|
created.push(admin);
|
||||||
@@ -463,7 +472,7 @@ export class AdminController {
|
|||||||
logger.error('Error creating admin in bulk operation', {
|
logger.error('Error creating admin in bulk operation', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
email: adminInput.email,
|
email: adminInput.email,
|
||||||
actorId
|
actorAdminId: actorAdmin.id
|
||||||
});
|
});
|
||||||
|
|
||||||
failed.push({
|
failed.push({
|
||||||
@@ -485,7 +494,7 @@ export class AdminController {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error in bulk create admins', {
|
logger.error('Error in bulk create admins', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
actorId: request.userContext?.userId
|
actorUserProfileId: request.userContext?.userId
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -503,15 +512,24 @@ export class AdminController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const actorId = request.userContext?.userId;
|
const actorUserProfileId = request.userContext?.userId;
|
||||||
|
|
||||||
if (!actorId) {
|
if (!actorUserProfileId) {
|
||||||
return reply.code(401).send({
|
return reply.code(401).send({
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
message: 'User context missing'
|
message: 'User context missing'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get actor's admin record
|
||||||
|
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
|
||||||
|
if (!actorAdmin) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'Actor is not an admin'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Validate request body
|
// Validate request body
|
||||||
const validation = bulkRevokeAdminSchema.safeParse(request.body);
|
const validation = bulkRevokeAdminSchema.safeParse(request.body);
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
@@ -522,37 +540,36 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { auth0Subs } = validation.data;
|
const { ids } = validation.data;
|
||||||
|
|
||||||
const revoked: AdminUser[] = [];
|
const revoked: AdminUser[] = [];
|
||||||
const failed: Array<{ auth0Sub: string; error: string }> = [];
|
const failed: Array<{ id: string; error: string }> = [];
|
||||||
|
|
||||||
// Process each revocation sequentially to maintain data consistency
|
// Process each revocation sequentially to maintain data consistency
|
||||||
for (const auth0Sub of auth0Subs) {
|
for (const id of ids) {
|
||||||
try {
|
try {
|
||||||
// Check if admin exists
|
// Check if admin exists
|
||||||
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
|
const targetAdmin = await this.adminService.getAdminById(id);
|
||||||
if (!targetAdmin) {
|
if (!targetAdmin) {
|
||||||
failed.push({
|
failed.push({
|
||||||
auth0Sub,
|
id,
|
||||||
error: 'Admin user not found'
|
error: 'Admin user not found'
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to revoke the admin
|
// Attempt to revoke the admin
|
||||||
const admin = await this.adminService.revokeAdmin(auth0Sub, actorId);
|
const admin = await this.adminService.revokeAdmin(id, actorAdmin.id);
|
||||||
revoked.push(admin);
|
revoked.push(admin);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error revoking admin in bulk operation', {
|
logger.error('Error revoking admin in bulk operation', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
auth0Sub,
|
adminId: id,
|
||||||
actorId
|
actorAdminId: actorAdmin.id
|
||||||
});
|
});
|
||||||
|
|
||||||
// Special handling for "last admin" constraint
|
|
||||||
failed.push({
|
failed.push({
|
||||||
auth0Sub,
|
id,
|
||||||
error: error.message || 'Failed to revoke admin'
|
error: error.message || 'Failed to revoke admin'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -570,7 +587,7 @@ export class AdminController {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error in bulk revoke admins', {
|
logger.error('Error in bulk revoke admins', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
actorId: request.userContext?.userId
|
actorUserProfileId: request.userContext?.userId
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -588,15 +605,24 @@ export class AdminController {
|
|||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const actorId = request.userContext?.userId;
|
const actorUserProfileId = request.userContext?.userId;
|
||||||
|
|
||||||
if (!actorId) {
|
if (!actorUserProfileId) {
|
||||||
return reply.code(401).send({
|
return reply.code(401).send({
|
||||||
error: 'Unauthorized',
|
error: 'Unauthorized',
|
||||||
message: 'User context missing'
|
message: 'User context missing'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get actor's admin record
|
||||||
|
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorUserProfileId);
|
||||||
|
if (!actorAdmin) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'Actor is not an admin'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Validate request body
|
// Validate request body
|
||||||
const validation = bulkReinstateAdminSchema.safeParse(request.body);
|
const validation = bulkReinstateAdminSchema.safeParse(request.body);
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
@@ -607,36 +633,36 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { auth0Subs } = validation.data;
|
const { ids } = validation.data;
|
||||||
|
|
||||||
const reinstated: AdminUser[] = [];
|
const reinstated: AdminUser[] = [];
|
||||||
const failed: Array<{ auth0Sub: string; error: string }> = [];
|
const failed: Array<{ id: string; error: string }> = [];
|
||||||
|
|
||||||
// Process each reinstatement sequentially to maintain data consistency
|
// Process each reinstatement sequentially to maintain data consistency
|
||||||
for (const auth0Sub of auth0Subs) {
|
for (const id of ids) {
|
||||||
try {
|
try {
|
||||||
// Check if admin exists
|
// Check if admin exists
|
||||||
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
|
const targetAdmin = await this.adminService.getAdminById(id);
|
||||||
if (!targetAdmin) {
|
if (!targetAdmin) {
|
||||||
failed.push({
|
failed.push({
|
||||||
auth0Sub,
|
id,
|
||||||
error: 'Admin user not found'
|
error: 'Admin user not found'
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to reinstate the admin
|
// Attempt to reinstate the admin
|
||||||
const admin = await this.adminService.reinstateAdmin(auth0Sub, actorId);
|
const admin = await this.adminService.reinstateAdmin(id, actorAdmin.id);
|
||||||
reinstated.push(admin);
|
reinstated.push(admin);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error reinstating admin in bulk operation', {
|
logger.error('Error reinstating admin in bulk operation', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
auth0Sub,
|
adminId: id,
|
||||||
actorId
|
actorAdminId: actorAdmin.id
|
||||||
});
|
});
|
||||||
|
|
||||||
failed.push({
|
failed.push({
|
||||||
auth0Sub,
|
id,
|
||||||
error: error.message || 'Failed to reinstate admin'
|
error: error.message || 'Failed to reinstate admin'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -654,7 +680,7 @@ export class AdminController {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error in bulk reinstate admins', {
|
logger.error('Error in bulk reinstate admins', {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
actorId: request.userContext?.userId
|
actorUserProfileId: request.userContext?.userId
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -665,9 +691,6 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveUserEmail(request: FastifyRequest): string | undefined {
|
private resolveUserEmail(request: FastifyRequest): string | undefined {
|
||||||
console.log('[DEBUG] resolveUserEmail - request.userContext:', JSON.stringify(request.userContext, null, 2));
|
|
||||||
console.log('[DEBUG] resolveUserEmail - request.user:', JSON.stringify((request as any).user, null, 2));
|
|
||||||
|
|
||||||
const candidates: Array<string | undefined> = [
|
const candidates: Array<string | undefined> = [
|
||||||
request.userContext?.email,
|
request.userContext?.email,
|
||||||
(request as any).user?.email,
|
(request as any).user?.email,
|
||||||
@@ -676,15 +699,11 @@ export class AdminController {
|
|||||||
(request as any).user?.preferred_username,
|
(request as any).user?.preferred_username,
|
||||||
];
|
];
|
||||||
|
|
||||||
console.log('[DEBUG] resolveUserEmail - candidates:', candidates);
|
|
||||||
|
|
||||||
for (const value of candidates) {
|
for (const value of candidates) {
|
||||||
if (typeof value === 'string' && value.includes('@')) {
|
if (typeof value === 'string' && value.includes('@')) {
|
||||||
console.log('[DEBUG] resolveUserEmail - found email:', value);
|
|
||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('[DEBUG] resolveUserEmail - no email found');
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import { AdminController } from './admin.controller';
|
|||||||
import { UsersController } from './users.controller';
|
import { UsersController } from './users.controller';
|
||||||
import {
|
import {
|
||||||
CreateAdminInput,
|
CreateAdminInput,
|
||||||
AdminAuth0SubInput,
|
AdminIdInput,
|
||||||
AuditLogsQueryInput,
|
|
||||||
BulkCreateAdminInput,
|
BulkCreateAdminInput,
|
||||||
BulkRevokeAdminInput,
|
BulkRevokeAdminInput,
|
||||||
BulkReinstateAdminInput,
|
BulkReinstateAdminInput,
|
||||||
@@ -18,7 +17,7 @@ import {
|
|||||||
} from './admin.validation';
|
} from './admin.validation';
|
||||||
import {
|
import {
|
||||||
ListUsersQueryInput,
|
ListUsersQueryInput,
|
||||||
UserAuth0SubInput,
|
UserIdInput,
|
||||||
UpdateTierInput,
|
UpdateTierInput,
|
||||||
DeactivateUserInput,
|
DeactivateUserInput,
|
||||||
UpdateProfileInput,
|
UpdateProfileInput,
|
||||||
@@ -66,23 +65,19 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
handler: adminController.createAdmin.bind(adminController)
|
handler: adminController.createAdmin.bind(adminController)
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/admin/admins/:auth0Sub/revoke - Revoke admin access
|
// PATCH /api/admin/admins/:id/revoke - Revoke admin access
|
||||||
fastify.patch<{ Params: AdminAuth0SubInput }>('/admin/admins/:auth0Sub/revoke', {
|
fastify.patch<{ Params: AdminIdInput }>('/admin/admins/:id/revoke', {
|
||||||
preHandler: [fastify.requireAdmin],
|
preHandler: [fastify.requireAdmin],
|
||||||
handler: adminController.revokeAdmin.bind(adminController)
|
handler: adminController.revokeAdmin.bind(adminController)
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/admin/admins/:auth0Sub/reinstate - Restore revoked admin
|
// PATCH /api/admin/admins/:id/reinstate - Restore revoked admin
|
||||||
fastify.patch<{ Params: AdminAuth0SubInput }>('/admin/admins/:auth0Sub/reinstate', {
|
fastify.patch<{ Params: AdminIdInput }>('/admin/admins/:id/reinstate', {
|
||||||
preHandler: [fastify.requireAdmin],
|
preHandler: [fastify.requireAdmin],
|
||||||
handler: adminController.reinstateAdmin.bind(adminController)
|
handler: adminController.reinstateAdmin.bind(adminController)
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/admin/audit-logs - Fetch audit trail
|
// NOTE: GET /api/admin/audit-logs moved to audit-log feature (centralized audit logging)
|
||||||
fastify.get<{ Querystring: AuditLogsQueryInput }>('/admin/audit-logs', {
|
|
||||||
preHandler: [fastify.requireAdmin],
|
|
||||||
handler: adminController.getAuditLogs.bind(adminController)
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/admin/admins/bulk - Create multiple admins
|
// POST /api/admin/admins/bulk - Create multiple admins
|
||||||
fastify.post<{ Body: BulkCreateAdminInput }>('/admin/admins/bulk', {
|
fastify.post<{ Body: BulkCreateAdminInput }>('/admin/admins/bulk', {
|
||||||
@@ -122,50 +117,50 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
handler: usersController.listUsers.bind(usersController)
|
handler: usersController.listUsers.bind(usersController)
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/admin/users/:auth0Sub - Get single user details
|
// GET /api/admin/users/:userId - Get single user details
|
||||||
fastify.get<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub', {
|
fastify.get<{ Params: UserIdInput }>('/admin/users/:userId', {
|
||||||
preHandler: [fastify.requireAdmin],
|
preHandler: [fastify.requireAdmin],
|
||||||
handler: usersController.getUser.bind(usersController)
|
handler: usersController.getUser.bind(usersController)
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/admin/users/:auth0Sub/vehicles - Get user's vehicles (admin view)
|
// GET /api/admin/users/:userId/vehicles - Get user's vehicles (admin view)
|
||||||
fastify.get<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub/vehicles', {
|
fastify.get<{ Params: UserIdInput }>('/admin/users/:userId/vehicles', {
|
||||||
preHandler: [fastify.requireAdmin],
|
preHandler: [fastify.requireAdmin],
|
||||||
handler: usersController.getUserVehicles.bind(usersController)
|
handler: usersController.getUserVehicles.bind(usersController)
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier
|
// PATCH /api/admin/users/:userId/tier - Update subscription tier
|
||||||
fastify.patch<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>('/admin/users/:auth0Sub/tier', {
|
fastify.patch<{ Params: UserIdInput; Body: UpdateTierInput }>('/admin/users/:userId/tier', {
|
||||||
preHandler: [fastify.requireAdmin],
|
preHandler: [fastify.requireAdmin],
|
||||||
handler: usersController.updateTier.bind(usersController)
|
handler: usersController.updateTier.bind(usersController)
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/admin/users/:auth0Sub/deactivate - Soft delete user
|
// PATCH /api/admin/users/:userId/deactivate - Soft delete user
|
||||||
fastify.patch<{ Params: UserAuth0SubInput; Body: DeactivateUserInput }>('/admin/users/:auth0Sub/deactivate', {
|
fastify.patch<{ Params: UserIdInput; Body: DeactivateUserInput }>('/admin/users/:userId/deactivate', {
|
||||||
preHandler: [fastify.requireAdmin],
|
preHandler: [fastify.requireAdmin],
|
||||||
handler: usersController.deactivateUser.bind(usersController)
|
handler: usersController.deactivateUser.bind(usersController)
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/admin/users/:auth0Sub/reactivate - Restore deactivated user
|
// PATCH /api/admin/users/:userId/reactivate - Restore deactivated user
|
||||||
fastify.patch<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub/reactivate', {
|
fastify.patch<{ Params: UserIdInput }>('/admin/users/:userId/reactivate', {
|
||||||
preHandler: [fastify.requireAdmin],
|
preHandler: [fastify.requireAdmin],
|
||||||
handler: usersController.reactivateUser.bind(usersController)
|
handler: usersController.reactivateUser.bind(usersController)
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/admin/users/:auth0Sub/profile - Update user email/displayName
|
// PATCH /api/admin/users/:userId/profile - Update user email/displayName
|
||||||
fastify.patch<{ Params: UserAuth0SubInput; Body: UpdateProfileInput }>('/admin/users/:auth0Sub/profile', {
|
fastify.patch<{ Params: UserIdInput; Body: UpdateProfileInput }>('/admin/users/:userId/profile', {
|
||||||
preHandler: [fastify.requireAdmin],
|
preHandler: [fastify.requireAdmin],
|
||||||
handler: usersController.updateProfile.bind(usersController)
|
handler: usersController.updateProfile.bind(usersController)
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/admin/users/:auth0Sub/promote - Promote user to admin
|
// PATCH /api/admin/users/:userId/promote - Promote user to admin
|
||||||
fastify.patch<{ Params: UserAuth0SubInput; Body: PromoteToAdminInput }>('/admin/users/:auth0Sub/promote', {
|
fastify.patch<{ Params: UserIdInput; Body: PromoteToAdminInput }>('/admin/users/:userId/promote', {
|
||||||
preHandler: [fastify.requireAdmin],
|
preHandler: [fastify.requireAdmin],
|
||||||
handler: usersController.promoteToAdmin.bind(usersController)
|
handler: usersController.promoteToAdmin.bind(usersController)
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/admin/users/:auth0Sub - Hard delete user (permanent)
|
// DELETE /api/admin/users/:userId - Hard delete user (permanent)
|
||||||
fastify.delete<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub', {
|
fastify.delete<{ Params: UserIdInput }>('/admin/users/:userId', {
|
||||||
preHandler: [fastify.requireAdmin],
|
preHandler: [fastify.requireAdmin],
|
||||||
handler: usersController.hardDeleteUser.bind(usersController)
|
handler: usersController.hardDeleteUser.bind(usersController)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export const createAdminSchema = z.object({
|
|||||||
role: z.enum(['admin', 'super_admin']).default('admin'),
|
role: z.enum(['admin', 'super_admin']).default('admin'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const adminAuth0SubSchema = z.object({
|
export const adminIdSchema = z.object({
|
||||||
auth0Sub: z.string().min(1, 'auth0Sub is required'),
|
id: z.string().uuid('Invalid admin ID format'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const auditLogsQuerySchema = z.object({
|
export const auditLogsQuerySchema = z.object({
|
||||||
@@ -29,14 +29,14 @@ export const bulkCreateAdminSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const bulkRevokeAdminSchema = z.object({
|
export const bulkRevokeAdminSchema = z.object({
|
||||||
auth0Subs: z.array(z.string().min(1, 'auth0Sub cannot be empty'))
|
ids: z.array(z.string().uuid('Invalid admin ID format'))
|
||||||
.min(1, 'At least one auth0Sub must be provided')
|
.min(1, 'At least one admin ID must be provided')
|
||||||
.max(100, 'Maximum 100 admins per batch'),
|
.max(100, 'Maximum 100 admins per batch'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const bulkReinstateAdminSchema = z.object({
|
export const bulkReinstateAdminSchema = z.object({
|
||||||
auth0Subs: z.array(z.string().min(1, 'auth0Sub cannot be empty'))
|
ids: z.array(z.string().uuid('Invalid admin ID format'))
|
||||||
.min(1, 'At least one auth0Sub must be provided')
|
.min(1, 'At least one admin ID must be provided')
|
||||||
.max(100, 'Maximum 100 admins per batch'),
|
.max(100, 'Maximum 100 admins per batch'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export const bulkDeleteCatalogSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type CreateAdminInput = z.infer<typeof createAdminSchema>;
|
export type CreateAdminInput = z.infer<typeof createAdminSchema>;
|
||||||
export type AdminAuth0SubInput = z.infer<typeof adminAuth0SubSchema>;
|
export type AdminIdInput = z.infer<typeof adminIdSchema>;
|
||||||
export type AuditLogsQueryInput = z.infer<typeof auditLogsQuerySchema>;
|
export type AuditLogsQueryInput = z.infer<typeof auditLogsQuerySchema>;
|
||||||
export type BulkCreateAdminInput = z.infer<typeof bulkCreateAdminSchema>;
|
export type BulkCreateAdminInput = z.infer<typeof bulkCreateAdminSchema>;
|
||||||
export type BulkRevokeAdminInput = z.infer<typeof bulkRevokeAdminSchema>;
|
export type BulkRevokeAdminInput = z.infer<typeof bulkRevokeAdminSchema>;
|
||||||
|
|||||||
@@ -7,17 +7,20 @@ import { FastifyRequest, FastifyReply } from 'fastify';
|
|||||||
import { UserProfileService } from '../../user-profile/domain/user-profile.service';
|
import { UserProfileService } from '../../user-profile/domain/user-profile.service';
|
||||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||||
import { AdminRepository } from '../data/admin.repository';
|
import { AdminRepository } from '../data/admin.repository';
|
||||||
|
import { SubscriptionsService } from '../../subscriptions/domain/subscriptions.service';
|
||||||
|
import { SubscriptionsRepository } from '../../subscriptions/data/subscriptions.repository';
|
||||||
|
import { StripeClient } from '../../subscriptions/external/stripe/stripe.client';
|
||||||
import { pool } from '../../../core/config/database';
|
import { pool } from '../../../core/config/database';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
import {
|
import {
|
||||||
listUsersQuerySchema,
|
listUsersQuerySchema,
|
||||||
userAuth0SubSchema,
|
userIdSchema,
|
||||||
updateTierSchema,
|
updateTierSchema,
|
||||||
deactivateUserSchema,
|
deactivateUserSchema,
|
||||||
updateProfileSchema,
|
updateProfileSchema,
|
||||||
promoteToAdminSchema,
|
promoteToAdminSchema,
|
||||||
ListUsersQueryInput,
|
ListUsersQueryInput,
|
||||||
UserAuth0SubInput,
|
UserIdInput,
|
||||||
UpdateTierInput,
|
UpdateTierInput,
|
||||||
DeactivateUserInput,
|
DeactivateUserInput,
|
||||||
UpdateProfileInput,
|
UpdateProfileInput,
|
||||||
@@ -28,15 +31,22 @@ import { AdminService } from '../domain/admin.service';
|
|||||||
export class UsersController {
|
export class UsersController {
|
||||||
private userProfileService: UserProfileService;
|
private userProfileService: UserProfileService;
|
||||||
private adminService: AdminService;
|
private adminService: AdminService;
|
||||||
|
private subscriptionsService: SubscriptionsService;
|
||||||
private userProfileRepository: UserProfileRepository;
|
private userProfileRepository: UserProfileRepository;
|
||||||
|
private adminRepository: AdminRepository;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.userProfileRepository = new UserProfileRepository(pool);
|
this.userProfileRepository = new UserProfileRepository(pool);
|
||||||
const adminRepository = new AdminRepository(pool);
|
this.adminRepository = new AdminRepository(pool);
|
||||||
|
const subscriptionsRepository = new SubscriptionsRepository(pool);
|
||||||
|
const stripeClient = new StripeClient();
|
||||||
|
|
||||||
this.userProfileService = new UserProfileService(this.userProfileRepository);
|
this.userProfileService = new UserProfileService(this.userProfileRepository);
|
||||||
this.userProfileService.setAdminRepository(adminRepository);
|
this.userProfileService.setAdminRepository(this.adminRepository);
|
||||||
this.adminService = new AdminService(adminRepository);
|
this.adminService = new AdminService(this.adminRepository);
|
||||||
|
// Admin feature depends on Subscriptions for tier management
|
||||||
|
// This is intentional - admin has oversight capabilities
|
||||||
|
this.subscriptionsService = new SubscriptionsService(subscriptionsRepository, stripeClient, pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,10 +95,10 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/users/:auth0Sub/vehicles - Get user's vehicles (admin view)
|
* GET /api/admin/users/:userId/vehicles - Get user's vehicles (admin view)
|
||||||
*/
|
*/
|
||||||
async getUserVehicles(
|
async getUserVehicles(
|
||||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
request: FastifyRequest<{ Params: UserIdInput }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -109,7 +119,7 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate path param
|
// Validate path param
|
||||||
const parseResult = userAuth0SubSchema.safeParse(request.params);
|
const parseResult = userIdSchema.safeParse(request.params);
|
||||||
if (!parseResult.success) {
|
if (!parseResult.success) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Validation error',
|
error: 'Validation error',
|
||||||
@@ -117,14 +127,14 @@ export class UsersController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { auth0Sub } = parseResult.data;
|
const { userId } = parseResult.data;
|
||||||
const vehicles = await this.userProfileRepository.getUserVehiclesForAdmin(auth0Sub);
|
const vehicles = await this.userProfileRepository.getUserVehiclesForAdmin(userId);
|
||||||
|
|
||||||
return reply.code(200).send({ vehicles });
|
return reply.code(200).send({ vehicles });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting user vehicles', {
|
logger.error('Error getting user vehicles', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
auth0Sub: request.params?.auth0Sub,
|
userId: (request.params as any)?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -176,10 +186,10 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/users/:auth0Sub - Get single user details
|
* GET /api/admin/users/:userId - Get single user details
|
||||||
*/
|
*/
|
||||||
async getUser(
|
async getUser(
|
||||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
request: FastifyRequest<{ Params: UserIdInput }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -192,7 +202,7 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate path param
|
// Validate path param
|
||||||
const parseResult = userAuth0SubSchema.safeParse(request.params);
|
const parseResult = userIdSchema.safeParse(request.params);
|
||||||
if (!parseResult.success) {
|
if (!parseResult.success) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Validation error',
|
error: 'Validation error',
|
||||||
@@ -200,8 +210,8 @@ export class UsersController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { auth0Sub } = parseResult.data;
|
const { userId } = parseResult.data;
|
||||||
const user = await this.userProfileService.getUserDetails(auth0Sub);
|
const user = await this.userProfileService.getUserDetails(userId);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -214,7 +224,7 @@ export class UsersController {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting user details', {
|
logger.error('Error getting user details', {
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
auth0Sub: request.params?.auth0Sub,
|
userId: (request.params as any)?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -225,10 +235,12 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier
|
* PATCH /api/admin/users/:userId/tier - Update subscription tier
|
||||||
|
* Uses subscriptionsService.adminOverrideTier() to sync both subscriptions.tier
|
||||||
|
* and user_profiles.subscription_tier atomically
|
||||||
*/
|
*/
|
||||||
async updateTier(
|
async updateTier(
|
||||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>,
|
request: FastifyRequest<{ Params: UserIdInput; Body: UpdateTierInput }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -241,7 +253,7 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate path param
|
// Validate path param
|
||||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
const paramsResult = userIdSchema.safeParse(request.params);
|
||||||
if (!paramsResult.success) {
|
if (!paramsResult.success) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Validation error',
|
error: 'Validation error',
|
||||||
@@ -258,22 +270,49 @@ export class UsersController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { auth0Sub } = paramsResult.data;
|
const { userId } = paramsResult.data;
|
||||||
const { subscriptionTier } = bodyResult.data;
|
const { subscriptionTier } = bodyResult.data;
|
||||||
|
|
||||||
const updatedUser = await this.userProfileService.updateSubscriptionTier(
|
// Verify user exists before attempting tier change
|
||||||
auth0Sub,
|
const currentUser = await this.userProfileService.getUserDetails(userId);
|
||||||
subscriptionTier,
|
if (!currentUser) {
|
||||||
actorId
|
return reply.code(404).send({
|
||||||
|
error: 'Not found',
|
||||||
|
message: 'User not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousTier = currentUser.subscriptionTier;
|
||||||
|
|
||||||
|
// Use subscriptionsService to update both tables atomically
|
||||||
|
await this.subscriptionsService.adminOverrideTier(userId, subscriptionTier);
|
||||||
|
|
||||||
|
// Log audit action
|
||||||
|
await this.adminRepository.logAuditAction(
|
||||||
|
actorId,
|
||||||
|
'UPDATE_TIER',
|
||||||
|
userId,
|
||||||
|
'user_profile',
|
||||||
|
currentUser.id,
|
||||||
|
{ previousTier, newTier: subscriptionTier }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.info('User subscription tier updated via admin', {
|
||||||
|
userId,
|
||||||
|
previousTier,
|
||||||
|
newTier: subscriptionTier,
|
||||||
|
actorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return updated user profile
|
||||||
|
const updatedUser = await this.userProfileService.getUserDetails(userId);
|
||||||
return reply.code(200).send(updatedUser);
|
return reply.code(200).send(updatedUser);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
logger.error('Error updating user tier', {
|
logger.error('Error updating user tier', {
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
auth0Sub: request.params?.auth0Sub,
|
userId: (request.params as any)?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (errorMessage === 'User not found') {
|
if (errorMessage === 'User not found') {
|
||||||
@@ -291,10 +330,10 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/users/:auth0Sub/deactivate - Soft delete user
|
* PATCH /api/admin/users/:userId/deactivate - Soft delete user
|
||||||
*/
|
*/
|
||||||
async deactivateUser(
|
async deactivateUser(
|
||||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: DeactivateUserInput }>,
|
request: FastifyRequest<{ Params: UserIdInput; Body: DeactivateUserInput }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -307,7 +346,7 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate path param
|
// Validate path param
|
||||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
const paramsResult = userIdSchema.safeParse(request.params);
|
||||||
if (!paramsResult.success) {
|
if (!paramsResult.success) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Validation error',
|
error: 'Validation error',
|
||||||
@@ -324,11 +363,11 @@ export class UsersController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { auth0Sub } = paramsResult.data;
|
const { userId } = paramsResult.data;
|
||||||
const { reason } = bodyResult.data;
|
const { reason } = bodyResult.data;
|
||||||
|
|
||||||
const deactivatedUser = await this.userProfileService.deactivateUser(
|
const deactivatedUser = await this.userProfileService.deactivateUser(
|
||||||
auth0Sub,
|
userId,
|
||||||
actorId,
|
actorId,
|
||||||
reason
|
reason
|
||||||
);
|
);
|
||||||
@@ -339,7 +378,7 @@ export class UsersController {
|
|||||||
|
|
||||||
logger.error('Error deactivating user', {
|
logger.error('Error deactivating user', {
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
auth0Sub: request.params?.auth0Sub,
|
userId: (request.params as any)?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (errorMessage === 'User not found') {
|
if (errorMessage === 'User not found') {
|
||||||
@@ -371,10 +410,10 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/users/:auth0Sub/reactivate - Restore deactivated user
|
* PATCH /api/admin/users/:userId/reactivate - Restore deactivated user
|
||||||
*/
|
*/
|
||||||
async reactivateUser(
|
async reactivateUser(
|
||||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
request: FastifyRequest<{ Params: UserIdInput }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -387,7 +426,7 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate path param
|
// Validate path param
|
||||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
const paramsResult = userIdSchema.safeParse(request.params);
|
||||||
if (!paramsResult.success) {
|
if (!paramsResult.success) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Validation error',
|
error: 'Validation error',
|
||||||
@@ -395,10 +434,10 @@ export class UsersController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { auth0Sub } = paramsResult.data;
|
const { userId } = paramsResult.data;
|
||||||
|
|
||||||
const reactivatedUser = await this.userProfileService.reactivateUser(
|
const reactivatedUser = await this.userProfileService.reactivateUser(
|
||||||
auth0Sub,
|
userId,
|
||||||
actorId
|
actorId
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -408,7 +447,7 @@ export class UsersController {
|
|||||||
|
|
||||||
logger.error('Error reactivating user', {
|
logger.error('Error reactivating user', {
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
auth0Sub: request.params?.auth0Sub,
|
userId: (request.params as any)?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (errorMessage === 'User not found') {
|
if (errorMessage === 'User not found') {
|
||||||
@@ -433,10 +472,10 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/users/:auth0Sub/profile - Update user email/displayName
|
* PATCH /api/admin/users/:userId/profile - Update user email/displayName
|
||||||
*/
|
*/
|
||||||
async updateProfile(
|
async updateProfile(
|
||||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateProfileInput }>,
|
request: FastifyRequest<{ Params: UserIdInput; Body: UpdateProfileInput }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -449,7 +488,7 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate path param
|
// Validate path param
|
||||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
const paramsResult = userIdSchema.safeParse(request.params);
|
||||||
if (!paramsResult.success) {
|
if (!paramsResult.success) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Validation error',
|
error: 'Validation error',
|
||||||
@@ -466,11 +505,11 @@ export class UsersController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { auth0Sub } = paramsResult.data;
|
const { userId } = paramsResult.data;
|
||||||
const updates = bodyResult.data;
|
const updates = bodyResult.data;
|
||||||
|
|
||||||
const updatedUser = await this.userProfileService.adminUpdateProfile(
|
const updatedUser = await this.userProfileService.adminUpdateProfile(
|
||||||
auth0Sub,
|
userId,
|
||||||
updates,
|
updates,
|
||||||
actorId
|
actorId
|
||||||
);
|
);
|
||||||
@@ -481,7 +520,7 @@ export class UsersController {
|
|||||||
|
|
||||||
logger.error('Error updating user profile', {
|
logger.error('Error updating user profile', {
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
auth0Sub: request.params?.auth0Sub,
|
userId: (request.params as any)?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (errorMessage === 'User not found') {
|
if (errorMessage === 'User not found') {
|
||||||
@@ -499,10 +538,10 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/admin/users/:auth0Sub/promote - Promote user to admin
|
* PATCH /api/admin/users/:userId/promote - Promote user to admin
|
||||||
*/
|
*/
|
||||||
async promoteToAdmin(
|
async promoteToAdmin(
|
||||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: PromoteToAdminInput }>,
|
request: FastifyRequest<{ Params: UserIdInput; Body: PromoteToAdminInput }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -515,7 +554,7 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate path param
|
// Validate path param
|
||||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
const paramsResult = userIdSchema.safeParse(request.params);
|
||||||
if (!paramsResult.success) {
|
if (!paramsResult.success) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Validation error',
|
error: 'Validation error',
|
||||||
@@ -532,11 +571,11 @@ export class UsersController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { auth0Sub } = paramsResult.data;
|
const { userId } = paramsResult.data;
|
||||||
const { role } = bodyResult.data;
|
const { role } = bodyResult.data;
|
||||||
|
|
||||||
// Get the user profile first to verify they exist and get their email
|
// Get the user profile to verify they exist and get their email
|
||||||
const user = await this.userProfileService.getUserDetails(auth0Sub);
|
const user = await this.userProfileService.getUserDetails(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
error: 'Not found',
|
error: 'Not found',
|
||||||
@@ -552,12 +591,15 @@ export class UsersController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the admin record using the user's real auth0Sub
|
// Get actor's admin record for audit trail
|
||||||
|
const actorAdmin = await this.adminService.getAdminByUserProfileId(actorId);
|
||||||
|
|
||||||
|
// Create the admin record using the user's UUID
|
||||||
const adminUser = await this.adminService.createAdmin(
|
const adminUser = await this.adminService.createAdmin(
|
||||||
user.email,
|
user.email,
|
||||||
role,
|
role,
|
||||||
auth0Sub, // Use the real auth0Sub from the user profile
|
userId,
|
||||||
actorId
|
actorAdmin?.id || actorId
|
||||||
);
|
);
|
||||||
|
|
||||||
return reply.code(201).send(adminUser);
|
return reply.code(201).send(adminUser);
|
||||||
@@ -566,7 +608,7 @@ export class UsersController {
|
|||||||
|
|
||||||
logger.error('Error promoting user to admin', {
|
logger.error('Error promoting user to admin', {
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
auth0Sub: request.params?.auth0Sub,
|
userId: (request.params as any)?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (errorMessage.includes('already exists')) {
|
if (errorMessage.includes('already exists')) {
|
||||||
@@ -584,10 +626,10 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/admin/users/:auth0Sub - Hard delete user (permanent)
|
* DELETE /api/admin/users/:userId - Hard delete user (permanent)
|
||||||
*/
|
*/
|
||||||
async hardDeleteUser(
|
async hardDeleteUser(
|
||||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
request: FastifyRequest<{ Params: UserIdInput }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
@@ -600,7 +642,7 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate path param
|
// Validate path param
|
||||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
const paramsResult = userIdSchema.safeParse(request.params);
|
||||||
if (!paramsResult.success) {
|
if (!paramsResult.success) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'Validation error',
|
error: 'Validation error',
|
||||||
@@ -608,14 +650,14 @@ export class UsersController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { auth0Sub } = paramsResult.data;
|
const { userId } = paramsResult.data;
|
||||||
|
|
||||||
// Optional reason from query params
|
// Optional reason from query params
|
||||||
const reason = (request.query as any)?.reason;
|
const reason = (request.query as any)?.reason;
|
||||||
|
|
||||||
// Hard delete user
|
// Hard delete user
|
||||||
await this.userProfileService.adminHardDeleteUser(
|
await this.userProfileService.adminHardDeleteUser(
|
||||||
auth0Sub,
|
userId,
|
||||||
actorId,
|
actorId,
|
||||||
reason
|
reason
|
||||||
);
|
);
|
||||||
@@ -628,7 +670,7 @@ export class UsersController {
|
|||||||
|
|
||||||
logger.error('Error hard deleting user', {
|
logger.error('Error hard deleting user', {
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
auth0Sub: request.params?.auth0Sub,
|
userId: (request.params as any)?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (errorMessage === 'Cannot delete your own account') {
|
if (errorMessage === 'Cannot delete your own account') {
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ export const listUsersQuerySchema = z.object({
|
|||||||
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Path param for user auth0Sub
|
// Path param for user UUID
|
||||||
export const userAuth0SubSchema = z.object({
|
export const userIdSchema = z.object({
|
||||||
auth0Sub: z.string().min(1, 'auth0Sub is required'),
|
userId: z.string().uuid('Invalid user ID format'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Body for updating subscription tier
|
// Body for updating subscription tier
|
||||||
@@ -50,7 +50,7 @@ export const promoteToAdminSchema = z.object({
|
|||||||
|
|
||||||
// Type exports
|
// Type exports
|
||||||
export type ListUsersQueryInput = z.infer<typeof listUsersQuerySchema>;
|
export type ListUsersQueryInput = z.infer<typeof listUsersQuerySchema>;
|
||||||
export type UserAuth0SubInput = z.infer<typeof userAuth0SubSchema>;
|
export type UserIdInput = z.infer<typeof userIdSchema>;
|
||||||
export type UpdateTierInput = z.infer<typeof updateTierSchema>;
|
export type UpdateTierInput = z.infer<typeof updateTierSchema>;
|
||||||
export type DeactivateUserInput = z.infer<typeof deactivateUserSchema>;
|
export type DeactivateUserInput = z.infer<typeof deactivateUserSchema>;
|
||||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
||||||
|
|||||||
@@ -10,29 +10,49 @@ import { logger } from '../../../core/logging/logger';
|
|||||||
export class AdminRepository {
|
export class AdminRepository {
|
||||||
constructor(private pool: Pool) {}
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
async getAdminByAuth0Sub(auth0Sub: string): Promise<AdminUser | null> {
|
async getAdminById(id: string): Promise<AdminUser | null> {
|
||||||
const query = `
|
const query = `
|
||||||
SELECT auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
|
SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
|
||||||
FROM admin_users
|
FROM admin_users
|
||||||
WHERE auth0_sub = $1
|
WHERE id = $1
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.pool.query(query, [auth0Sub]);
|
const result = await this.pool.query(query, [id]);
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return this.mapRowToAdminUser(result.rows[0]);
|
return this.mapRowToAdminUser(result.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching admin by auth0_sub', { error, auth0Sub });
|
logger.error('Error fetching admin by id', { error, id });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdminByUserProfileId(userProfileId: string): Promise<AdminUser | null> {
|
||||||
|
const query = `
|
||||||
|
SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
|
||||||
|
FROM admin_users
|
||||||
|
WHERE user_profile_id = $1
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.pool.query(query, [userProfileId]);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.mapRowToAdminUser(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching admin by user_profile_id', { error, userProfileId });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAdminByEmail(email: string): Promise<AdminUser | null> {
|
async getAdminByEmail(email: string): Promise<AdminUser | null> {
|
||||||
const query = `
|
const query = `
|
||||||
SELECT auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
|
SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
|
||||||
FROM admin_users
|
FROM admin_users
|
||||||
WHERE LOWER(email) = LOWER($1)
|
WHERE LOWER(email) = LOWER($1)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -52,7 +72,7 @@ export class AdminRepository {
|
|||||||
|
|
||||||
async getAllAdmins(): Promise<AdminUser[]> {
|
async getAllAdmins(): Promise<AdminUser[]> {
|
||||||
const query = `
|
const query = `
|
||||||
SELECT auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
|
SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
|
||||||
FROM admin_users
|
FROM admin_users
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`;
|
`;
|
||||||
@@ -68,7 +88,7 @@ export class AdminRepository {
|
|||||||
|
|
||||||
async getActiveAdmins(): Promise<AdminUser[]> {
|
async getActiveAdmins(): Promise<AdminUser[]> {
|
||||||
const query = `
|
const query = `
|
||||||
SELECT auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
|
SELECT id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
|
||||||
FROM admin_users
|
FROM admin_users
|
||||||
WHERE revoked_at IS NULL
|
WHERE revoked_at IS NULL
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -83,61 +103,61 @@ export class AdminRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAdmin(auth0Sub: string, email: string, role: string, createdBy: string): Promise<AdminUser> {
|
async createAdmin(userProfileId: string, email: string, role: string, createdBy: string): Promise<AdminUser> {
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO admin_users (auth0_sub, email, role, created_by)
|
INSERT INTO admin_users (user_profile_id, email, role, created_by)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
|
RETURNING id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.pool.query(query, [auth0Sub, email, role, createdBy]);
|
const result = await this.pool.query(query, [userProfileId, email, role, createdBy]);
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
throw new Error('Failed to create admin user');
|
throw new Error('Failed to create admin user');
|
||||||
}
|
}
|
||||||
return this.mapRowToAdminUser(result.rows[0]);
|
return this.mapRowToAdminUser(result.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error creating admin', { error, auth0Sub, email });
|
logger.error('Error creating admin', { error, userProfileId, email });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeAdmin(auth0Sub: string): Promise<AdminUser> {
|
async revokeAdmin(id: string): Promise<AdminUser> {
|
||||||
const query = `
|
const query = `
|
||||||
UPDATE admin_users
|
UPDATE admin_users
|
||||||
SET revoked_at = CURRENT_TIMESTAMP
|
SET revoked_at = CURRENT_TIMESTAMP
|
||||||
WHERE auth0_sub = $1
|
WHERE id = $1
|
||||||
RETURNING auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
|
RETURNING id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.pool.query(query, [auth0Sub]);
|
const result = await this.pool.query(query, [id]);
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
throw new Error('Admin user not found');
|
throw new Error('Admin user not found');
|
||||||
}
|
}
|
||||||
return this.mapRowToAdminUser(result.rows[0]);
|
return this.mapRowToAdminUser(result.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error revoking admin', { error, auth0Sub });
|
logger.error('Error revoking admin', { error, id });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async reinstateAdmin(auth0Sub: string): Promise<AdminUser> {
|
async reinstateAdmin(id: string): Promise<AdminUser> {
|
||||||
const query = `
|
const query = `
|
||||||
UPDATE admin_users
|
UPDATE admin_users
|
||||||
SET revoked_at = NULL
|
SET revoked_at = NULL
|
||||||
WHERE auth0_sub = $1
|
WHERE id = $1
|
||||||
RETURNING auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
|
RETURNING id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
|
||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.pool.query(query, [auth0Sub]);
|
const result = await this.pool.query(query, [id]);
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
throw new Error('Admin user not found');
|
throw new Error('Admin user not found');
|
||||||
}
|
}
|
||||||
return this.mapRowToAdminUser(result.rows[0]);
|
return this.mapRowToAdminUser(result.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error reinstating admin', { error, auth0Sub });
|
logger.error('Error reinstating admin', { error, id });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,30 +222,11 @@ export class AdminRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAuth0SubByEmail(email: string, auth0Sub: string): Promise<AdminUser> {
|
|
||||||
const query = `
|
|
||||||
UPDATE admin_users
|
|
||||||
SET auth0_sub = $1,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE LOWER(email) = LOWER($2)
|
|
||||||
RETURNING auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await this.pool.query(query, [auth0Sub, email]);
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
throw new Error(`Admin user with email ${email} not found`);
|
|
||||||
}
|
|
||||||
return this.mapRowToAdminUser(result.rows[0]);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error updating admin auth0_sub by email', { error, email, auth0Sub });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapRowToAdminUser(row: any): AdminUser {
|
private mapRowToAdminUser(row: any): AdminUser {
|
||||||
return {
|
return {
|
||||||
auth0Sub: row.auth0_sub,
|
id: row.id,
|
||||||
|
userProfileId: row.user_profile_id,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
role: row.role,
|
role: row.role,
|
||||||
createdAt: new Date(row.created_at),
|
createdAt: new Date(row.created_at),
|
||||||
|
|||||||
@@ -6,15 +6,25 @@
|
|||||||
import { AdminRepository } from '../data/admin.repository';
|
import { AdminRepository } from '../data/admin.repository';
|
||||||
import { AdminUser, AdminAuditLog } from './admin.types';
|
import { AdminUser, AdminAuditLog } from './admin.types';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
|
import { auditLogService } from '../../audit-log';
|
||||||
|
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
constructor(private repository: AdminRepository) {}
|
constructor(private repository: AdminRepository) {}
|
||||||
|
|
||||||
async getAdminByAuth0Sub(auth0Sub: string): Promise<AdminUser | null> {
|
async getAdminById(id: string): Promise<AdminUser | null> {
|
||||||
try {
|
try {
|
||||||
return await this.repository.getAdminByAuth0Sub(auth0Sub);
|
return await this.repository.getAdminById(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting admin by auth0_sub', { error });
|
logger.error('Error getting admin by id', { error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdminByUserProfileId(userProfileId: string): Promise<AdminUser | null> {
|
||||||
|
try {
|
||||||
|
return await this.repository.getAdminByUserProfileId(userProfileId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting admin by user_profile_id', { error });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +56,7 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAdmin(email: string, role: string, auth0Sub: string, createdBy: string): Promise<AdminUser> {
|
async createAdmin(email: string, role: string, userProfileId: string, createdByAdminId: string): Promise<AdminUser> {
|
||||||
try {
|
try {
|
||||||
// Check if admin already exists
|
// Check if admin already exists
|
||||||
const normalizedEmail = email.trim().toLowerCase();
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
@@ -56,14 +66,24 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new admin
|
// Create new admin
|
||||||
const admin = await this.repository.createAdmin(auth0Sub, normalizedEmail, role, createdBy);
|
const admin = await this.repository.createAdmin(userProfileId, normalizedEmail, role, createdByAdminId);
|
||||||
|
|
||||||
// Log audit action
|
// Log audit action (legacy)
|
||||||
await this.repository.logAuditAction(createdBy, 'CREATE', admin.auth0Sub, 'admin_user', admin.email, {
|
await this.repository.logAuditAction(createdByAdminId, 'CREATE', admin.id, 'admin_user', admin.email, {
|
||||||
email,
|
email,
|
||||||
role
|
role
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Log to unified audit log
|
||||||
|
await auditLogService.info(
|
||||||
|
'admin',
|
||||||
|
userProfileId,
|
||||||
|
`Admin user created: ${admin.email}`,
|
||||||
|
'admin_user',
|
||||||
|
admin.id,
|
||||||
|
{ email: admin.email, role }
|
||||||
|
).catch(err => logger.error('Failed to log admin create audit event', { error: err }));
|
||||||
|
|
||||||
logger.info('Admin user created', { email, role });
|
logger.info('Admin user created', { email, role });
|
||||||
return admin;
|
return admin;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -72,7 +92,7 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeAdmin(auth0Sub: string, revokedBy: string): Promise<AdminUser> {
|
async revokeAdmin(id: string, revokedByAdminId: string): Promise<AdminUser> {
|
||||||
try {
|
try {
|
||||||
// Check that at least one active admin will remain
|
// Check that at least one active admin will remain
|
||||||
const activeAdmins = await this.repository.getActiveAdmins();
|
const activeAdmins = await this.repository.getActiveAdmins();
|
||||||
@@ -81,31 +101,51 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Revoke the admin
|
// Revoke the admin
|
||||||
const admin = await this.repository.revokeAdmin(auth0Sub);
|
const admin = await this.repository.revokeAdmin(id);
|
||||||
|
|
||||||
// Log audit action
|
// Log audit action (legacy)
|
||||||
await this.repository.logAuditAction(revokedBy, 'REVOKE', auth0Sub, 'admin_user', admin.email);
|
await this.repository.logAuditAction(revokedByAdminId, 'REVOKE', id, 'admin_user', admin.email);
|
||||||
|
|
||||||
logger.info('Admin user revoked', { auth0Sub, email: admin.email });
|
// Log to unified audit log
|
||||||
|
await auditLogService.info(
|
||||||
|
'admin',
|
||||||
|
admin.userProfileId,
|
||||||
|
`Admin user revoked: ${admin.email}`,
|
||||||
|
'admin_user',
|
||||||
|
id,
|
||||||
|
{ email: admin.email }
|
||||||
|
).catch(err => logger.error('Failed to log admin revoke audit event', { error: err }));
|
||||||
|
|
||||||
|
logger.info('Admin user revoked', { id, email: admin.email });
|
||||||
return admin;
|
return admin;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error revoking admin', { error, auth0Sub });
|
logger.error('Error revoking admin', { error, id });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async reinstateAdmin(auth0Sub: string, reinstatedBy: string): Promise<AdminUser> {
|
async reinstateAdmin(id: string, reinstatedByAdminId: string): Promise<AdminUser> {
|
||||||
try {
|
try {
|
||||||
// Reinstate the admin
|
// Reinstate the admin
|
||||||
const admin = await this.repository.reinstateAdmin(auth0Sub);
|
const admin = await this.repository.reinstateAdmin(id);
|
||||||
|
|
||||||
// Log audit action
|
// Log audit action (legacy)
|
||||||
await this.repository.logAuditAction(reinstatedBy, 'REINSTATE', auth0Sub, 'admin_user', admin.email);
|
await this.repository.logAuditAction(reinstatedByAdminId, 'REINSTATE', id, 'admin_user', admin.email);
|
||||||
|
|
||||||
logger.info('Admin user reinstated', { auth0Sub, email: admin.email });
|
// Log to unified audit log
|
||||||
|
await auditLogService.info(
|
||||||
|
'admin',
|
||||||
|
admin.userProfileId,
|
||||||
|
`Admin user reinstated: ${admin.email}`,
|
||||||
|
'admin_user',
|
||||||
|
id,
|
||||||
|
{ email: admin.email }
|
||||||
|
).catch(err => logger.error('Failed to log admin reinstate audit event', { error: err }));
|
||||||
|
|
||||||
|
logger.info('Admin user reinstated', { id, email: admin.email });
|
||||||
return admin;
|
return admin;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error reinstating admin', { error, auth0Sub });
|
logger.error('Error reinstating admin', { error, id });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,12 +159,4 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async linkAdminAuth0Sub(email: string, auth0Sub: string): Promise<AdminUser> {
|
|
||||||
try {
|
|
||||||
return await this.repository.updateAuth0SubByEmail(email.trim().toLowerCase(), auth0Sub);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error linking admin auth0_sub to email', { error, email, auth0Sub });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
auth0Sub: string;
|
id: string;
|
||||||
|
userProfileId: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: 'admin' | 'super_admin';
|
role: 'admin' | 'super_admin';
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -19,11 +20,11 @@ export interface CreateAdminRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RevokeAdminRequest {
|
export interface RevokeAdminRequest {
|
||||||
auth0Sub: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReinstateAdminRequest {
|
export interface ReinstateAdminRequest {
|
||||||
auth0Sub: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminAuditLog {
|
export interface AdminAuditLog {
|
||||||
@@ -71,25 +72,25 @@ export interface BulkCreateAdminResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BulkRevokeAdminRequest {
|
export interface BulkRevokeAdminRequest {
|
||||||
auth0Subs: string[];
|
ids: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BulkRevokeAdminResponse {
|
export interface BulkRevokeAdminResponse {
|
||||||
revoked: AdminUser[];
|
revoked: AdminUser[];
|
||||||
failed: Array<{
|
failed: Array<{
|
||||||
auth0Sub: string;
|
id: string;
|
||||||
error: string;
|
error: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BulkReinstateAdminRequest {
|
export interface BulkReinstateAdminRequest {
|
||||||
auth0Subs: string[];
|
ids: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BulkReinstateAdminResponse {
|
export interface BulkReinstateAdminResponse {
|
||||||
reinstated: AdminUser[];
|
reinstated: AdminUser[];
|
||||||
failed: Array<{
|
failed: Array<{
|
||||||
auth0Sub: string;
|
id: string;
|
||||||
error: string;
|
error: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { app } from '../../../../app';
|
import { buildApp } from '../../../../app';
|
||||||
import pool from '../../../../core/config/database';
|
import pool from '../../../../core/config/database';
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import fastifyPlugin from 'fastify-plugin';
|
import fastifyPlugin from 'fastify-plugin';
|
||||||
import { setAdminGuardPool } from '../../../../core/plugins/admin-guard.plugin';
|
import { setAdminGuardPool } from '../../../../core/plugins/admin-guard.plugin';
|
||||||
|
|
||||||
const DEFAULT_ADMIN_SUB = 'test-admin-123';
|
const DEFAULT_ADMIN_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||||
const DEFAULT_ADMIN_EMAIL = 'test-admin@motovaultpro.com';
|
const DEFAULT_ADMIN_EMAIL = 'test-admin@motovaultpro.com';
|
||||||
|
|
||||||
let currentUser = {
|
let currentUser = {
|
||||||
sub: DEFAULT_ADMIN_SUB,
|
sub: 'auth0|test-admin-123',
|
||||||
email: DEFAULT_ADMIN_EMAIL,
|
email: DEFAULT_ADMIN_EMAIL,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,11 +26,15 @@ jest.mock('../../../../core/plugins/auth.plugin', () => {
|
|||||||
default: fastifyPlugin(async function(fastify) {
|
default: fastifyPlugin(async function(fastify) {
|
||||||
fastify.decorate('authenticate', async function(request, _reply) {
|
fastify.decorate('authenticate', async function(request, _reply) {
|
||||||
// Inject dynamic test user context
|
// Inject dynamic test user context
|
||||||
|
// JWT sub is still auth0|xxx format
|
||||||
request.user = { sub: currentUser.sub };
|
request.user = { sub: currentUser.sub };
|
||||||
request.userContext = {
|
request.userContext = {
|
||||||
userId: currentUser.sub,
|
userId: DEFAULT_ADMIN_ID,
|
||||||
email: currentUser.email,
|
email: currentUser.email,
|
||||||
|
emailVerified: true,
|
||||||
|
onboardingCompleted: true,
|
||||||
isAdmin: false, // Will be set by admin guard
|
isAdmin: false, // Will be set by admin guard
|
||||||
|
subscriptionTier: 'free',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, { name: 'auth-plugin' })
|
}, { name: 'auth-plugin' })
|
||||||
@@ -37,10 +42,14 @@ jest.mock('../../../../core/plugins/auth.plugin', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Admin Management Integration Tests', () => {
|
describe('Admin Management Integration Tests', () => {
|
||||||
let testAdminAuth0Sub: string;
|
let app: FastifyInstance;
|
||||||
let testNonAdminAuth0Sub: string;
|
let testAdminId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
// Build the app
|
||||||
|
app = await buildApp();
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
// Run the admin migration directly using the migration file
|
// Run the admin migration directly using the migration file
|
||||||
const migrationFile = join(__dirname, '../../migrations/001_create_admin_users.sql');
|
const migrationFile = join(__dirname, '../../migrations/001_create_admin_users.sql');
|
||||||
const migrationSQL = readFileSync(migrationFile, 'utf-8');
|
const migrationSQL = readFileSync(migrationFile, 'utf-8');
|
||||||
@@ -50,33 +59,31 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
setAdminGuardPool(pool);
|
setAdminGuardPool(pool);
|
||||||
|
|
||||||
// Create test admin user
|
// Create test admin user
|
||||||
testAdminAuth0Sub = DEFAULT_ADMIN_SUB;
|
testAdminId = DEFAULT_ADMIN_ID;
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
INSERT INTO admin_users (auth0_sub, email, role, created_by)
|
INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
ON CONFLICT (auth0_sub) DO NOTHING
|
ON CONFLICT (user_profile_id) DO NOTHING
|
||||||
`, [testAdminAuth0Sub, DEFAULT_ADMIN_EMAIL, 'admin', 'system']);
|
`, [testAdminId, testAdminId, DEFAULT_ADMIN_EMAIL, 'admin', 'system']);
|
||||||
|
|
||||||
// Create test non-admin auth0Sub for permission tests
|
|
||||||
testNonAdminAuth0Sub = 'test-non-admin-456';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// Clean up test database
|
// Clean up test database
|
||||||
await pool.query('DROP TABLE IF EXISTS admin_audit_logs CASCADE');
|
await pool.query('DROP TABLE IF EXISTS admin_audit_logs CASCADE');
|
||||||
await pool.query('DROP TABLE IF EXISTS admin_users CASCADE');
|
await pool.query('DROP TABLE IF EXISTS admin_users CASCADE');
|
||||||
|
await app.close();
|
||||||
await pool.end();
|
await pool.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Clean up test data before each test (except the test admin)
|
// Clean up test data before each test (except the test admin)
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'DELETE FROM admin_users WHERE auth0_sub != $1 AND auth0_sub != $2',
|
'DELETE FROM admin_users WHERE user_profile_id != $1',
|
||||||
[testAdminAuth0Sub, 'system|bootstrap']
|
[testAdminId]
|
||||||
);
|
);
|
||||||
await pool.query('DELETE FROM admin_audit_logs');
|
await pool.query('DELETE FROM admin_audit_logs');
|
||||||
currentUser = {
|
currentUser = {
|
||||||
sub: DEFAULT_ADMIN_SUB,
|
sub: 'auth0|test-admin-123',
|
||||||
email: DEFAULT_ADMIN_EMAIL,
|
email: DEFAULT_ADMIN_EMAIL,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -85,11 +92,11 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
it('should reject non-admin user trying to list admins', async () => {
|
it('should reject non-admin user trying to list admins', async () => {
|
||||||
// Create mock for non-admin user
|
// Create mock for non-admin user
|
||||||
currentUser = {
|
currentUser = {
|
||||||
sub: testNonAdminAuth0Sub,
|
sub: 'auth0|test-non-admin-456',
|
||||||
email: 'test-user@example.com',
|
email: 'test-user@example.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.get('/api/admin/admins')
|
.get('/api/admin/admins')
|
||||||
.expect(403);
|
.expect(403);
|
||||||
|
|
||||||
@@ -101,51 +108,51 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
describe('GET /api/admin/verify', () => {
|
describe('GET /api/admin/verify', () => {
|
||||||
it('should confirm admin access for existing admin', async () => {
|
it('should confirm admin access for existing admin', async () => {
|
||||||
currentUser = {
|
currentUser = {
|
||||||
sub: testAdminAuth0Sub,
|
sub: 'auth0|test-admin-123',
|
||||||
email: DEFAULT_ADMIN_EMAIL,
|
email: DEFAULT_ADMIN_EMAIL,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.get('/api/admin/verify')
|
.get('/api/admin/verify')
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.isAdmin).toBe(true);
|
expect(response.body.isAdmin).toBe(true);
|
||||||
expect(response.body.adminRecord).toMatchObject({
|
expect(response.body.adminRecord).toMatchObject({
|
||||||
auth0Sub: testAdminAuth0Sub,
|
id: testAdminId,
|
||||||
email: DEFAULT_ADMIN_EMAIL,
|
email: DEFAULT_ADMIN_EMAIL,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should link admin record by email when auth0_sub differs', async () => {
|
it('should link admin record by email when user_profile_id differs', async () => {
|
||||||
const placeholderSub = 'auth0|placeholder-sub';
|
const placeholderId = '9b9a1234-1234-1234-1234-123456789abc';
|
||||||
const realSub = 'auth0|real-admin-sub';
|
const realId = 'a1b2c3d4-5678-90ab-cdef-123456789def';
|
||||||
const email = 'link-admin@example.com';
|
const email = 'link-admin@example.com';
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
INSERT INTO admin_users (auth0_sub, email, role, created_by)
|
INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
`, [placeholderSub, email, 'admin', testAdminAuth0Sub]);
|
`, [placeholderId, placeholderId, email, 'admin', testAdminId]);
|
||||||
|
|
||||||
currentUser = {
|
currentUser = {
|
||||||
sub: realSub,
|
sub: 'auth0|real-admin-sub',
|
||||||
email,
|
email,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.get('/api/admin/verify')
|
.get('/api/admin/verify')
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.isAdmin).toBe(true);
|
expect(response.body.isAdmin).toBe(true);
|
||||||
expect(response.body.adminRecord).toMatchObject({
|
expect(response.body.adminRecord).toMatchObject({
|
||||||
auth0Sub: realSub,
|
userProfileId: realId,
|
||||||
email,
|
email,
|
||||||
});
|
});
|
||||||
|
|
||||||
const record = await pool.query(
|
const record = await pool.query(
|
||||||
'SELECT auth0_sub FROM admin_users WHERE email = $1',
|
'SELECT user_profile_id FROM admin_users WHERE email = $1',
|
||||||
[email]
|
[email]
|
||||||
);
|
);
|
||||||
expect(record.rows[0].auth0_sub).toBe(realSub);
|
expect(record.rows[0].user_profile_id).toBe(realId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return non-admin response for unknown user', async () => {
|
it('should return non-admin response for unknown user', async () => {
|
||||||
@@ -154,7 +161,7 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
email: 'non-admin@example.com',
|
email: 'non-admin@example.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.get('/api/admin/verify')
|
.get('/api/admin/verify')
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
@@ -166,17 +173,19 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
describe('GET /api/admin/admins', () => {
|
describe('GET /api/admin/admins', () => {
|
||||||
it('should list all admin users', async () => {
|
it('should list all admin users', async () => {
|
||||||
// Create additional test admins
|
// Create additional test admins
|
||||||
|
const admin1Id = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
||||||
|
const admin2Id = '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e';
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
INSERT INTO admin_users (auth0_sub, email, role, created_by)
|
INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
|
||||||
VALUES
|
VALUES
|
||||||
($1, $2, $3, $4),
|
($1, $2, $3, $4, $5),
|
||||||
($5, $6, $7, $8)
|
($6, $7, $8, $9, $10)
|
||||||
`, [
|
`, [
|
||||||
'auth0|admin1', 'admin1@example.com', 'admin', testAdminAuth0Sub,
|
admin1Id, admin1Id, 'admin1@example.com', 'admin', testAdminId,
|
||||||
'auth0|admin2', 'admin2@example.com', 'super_admin', testAdminAuth0Sub
|
admin2Id, admin2Id, 'admin2@example.com', 'super_admin', testAdminId
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.get('/api/admin/admins')
|
.get('/api/admin/admins')
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
@@ -184,7 +193,7 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
expect(response.body).toHaveProperty('admins');
|
expect(response.body).toHaveProperty('admins');
|
||||||
expect(response.body.admins.length).toBeGreaterThanOrEqual(3); // At least test admin + 2 created
|
expect(response.body.admins.length).toBeGreaterThanOrEqual(3); // At least test admin + 2 created
|
||||||
expect(response.body.admins[0]).toMatchObject({
|
expect(response.body.admins[0]).toMatchObject({
|
||||||
auth0Sub: expect.any(String),
|
id: expect.any(String),
|
||||||
email: expect.any(String),
|
email: expect.any(String),
|
||||||
role: expect.stringMatching(/^(admin|super_admin)$/),
|
role: expect.stringMatching(/^(admin|super_admin)$/),
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
@@ -194,12 +203,13 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
|
|
||||||
it('should include revoked admins in the list', async () => {
|
it('should include revoked admins in the list', async () => {
|
||||||
// Create and revoke an admin
|
// Create and revoke an admin
|
||||||
|
const revokedId = 'f1e2d3c4-b5a6-9788-6543-210fedcba987';
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
INSERT INTO admin_users (auth0_sub, email, role, created_by, revoked_at)
|
INSERT INTO admin_users (id, user_profile_id, email, role, created_by, revoked_at)
|
||||||
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||||
`, ['auth0|revoked', 'revoked@example.com', 'admin', testAdminAuth0Sub]);
|
`, [revokedId, revokedId, 'revoked@example.com', 'admin', testAdminId]);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.get('/api/admin/admins')
|
.get('/api/admin/admins')
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
@@ -218,17 +228,17 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
role: 'admin'
|
role: 'admin'
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.post('/api/admin/admins')
|
.post('/api/admin/admins')
|
||||||
.send(newAdminData)
|
.send(newAdminData)
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
expect(response.body).toMatchObject({
|
expect(response.body).toMatchObject({
|
||||||
auth0Sub: expect.any(String),
|
id: expect.any(String),
|
||||||
email: 'newadmin@example.com',
|
email: 'newadmin@example.com',
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
createdBy: testAdminAuth0Sub,
|
createdBy: testAdminId,
|
||||||
revokedAt: null
|
revokedAt: null
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -238,7 +248,7 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
['CREATE', 'newadmin@example.com']
|
['CREATE', 'newadmin@example.com']
|
||||||
);
|
);
|
||||||
expect(auditResult.rows.length).toBe(1);
|
expect(auditResult.rows.length).toBe(1);
|
||||||
expect(auditResult.rows[0].actor_admin_id).toBe(testAdminAuth0Sub);
|
expect(auditResult.rows[0].actor_admin_id).toBe(testAdminId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject invalid email', async () => {
|
it('should reject invalid email', async () => {
|
||||||
@@ -247,7 +257,7 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
role: 'admin'
|
role: 'admin'
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.post('/api/admin/admins')
|
.post('/api/admin/admins')
|
||||||
.send(invalidData)
|
.send(invalidData)
|
||||||
.expect(400);
|
.expect(400);
|
||||||
@@ -263,13 +273,13 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create first admin
|
// Create first admin
|
||||||
await request(app)
|
await request(app.server)
|
||||||
.post('/api/admin/admins')
|
.post('/api/admin/admins')
|
||||||
.send(adminData)
|
.send(adminData)
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
// Try to create duplicate
|
// Try to create duplicate
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.post('/api/admin/admins')
|
.post('/api/admin/admins')
|
||||||
.send(adminData)
|
.send(adminData)
|
||||||
.expect(400);
|
.expect(400);
|
||||||
@@ -284,7 +294,7 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
role: 'super_admin'
|
role: 'super_admin'
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.post('/api/admin/admins')
|
.post('/api/admin/admins')
|
||||||
.send(superAdminData)
|
.send(superAdminData)
|
||||||
.expect(201);
|
.expect(201);
|
||||||
@@ -297,7 +307,7 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
email: 'defaultrole@example.com'
|
email: 'defaultrole@example.com'
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.post('/api/admin/admins')
|
.post('/api/admin/admins')
|
||||||
.send(adminData)
|
.send(adminData)
|
||||||
.expect(201);
|
.expect(201);
|
||||||
@@ -306,23 +316,24 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PATCH /api/admin/admins/:auth0Sub/revoke', () => {
|
describe('PATCH /api/admin/admins/:id/revoke', () => {
|
||||||
it('should revoke admin access', async () => {
|
it('should revoke admin access', async () => {
|
||||||
// Create admin to revoke
|
// Create admin to revoke
|
||||||
|
const toRevokeId = 'b1c2d3e4-f5a6-7890-1234-567890abcdef';
|
||||||
const createResult = await pool.query(`
|
const createResult = await pool.query(`
|
||||||
INSERT INTO admin_users (auth0_sub, email, role, created_by)
|
INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING auth0_sub
|
RETURNING id
|
||||||
`, ['auth0|to-revoke', 'torevoke@example.com', 'admin', testAdminAuth0Sub]);
|
`, [toRevokeId, toRevokeId, 'torevoke@example.com', 'admin', testAdminId]);
|
||||||
|
|
||||||
const auth0Sub = createResult.rows[0].auth0_sub;
|
const adminId = createResult.rows[0].id;
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.patch(`/api/admin/admins/${auth0Sub}/revoke`)
|
.patch(`/api/admin/admins/${adminId}/revoke`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body).toMatchObject({
|
expect(response.body).toMatchObject({
|
||||||
auth0Sub,
|
id: adminId,
|
||||||
email: 'torevoke@example.com',
|
email: 'torevoke@example.com',
|
||||||
revokedAt: expect.any(String)
|
revokedAt: expect.any(String)
|
||||||
});
|
});
|
||||||
@@ -330,7 +341,7 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
// Verify audit log
|
// Verify audit log
|
||||||
const auditResult = await pool.query(
|
const auditResult = await pool.query(
|
||||||
'SELECT * FROM admin_audit_logs WHERE action = $1 AND target_admin_id = $2',
|
'SELECT * FROM admin_audit_logs WHERE action = $1 AND target_admin_id = $2',
|
||||||
['REVOKE', auth0Sub]
|
['REVOKE', adminId]
|
||||||
);
|
);
|
||||||
expect(auditResult.rows.length).toBe(1);
|
expect(auditResult.rows.length).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -338,12 +349,12 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
it('should prevent revoking last active admin', async () => {
|
it('should prevent revoking last active admin', async () => {
|
||||||
// First, ensure only one active admin exists
|
// First, ensure only one active admin exists
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'UPDATE admin_users SET revoked_at = CURRENT_TIMESTAMP WHERE auth0_sub != $1',
|
'UPDATE admin_users SET revoked_at = CURRENT_TIMESTAMP WHERE user_profile_id != $1',
|
||||||
[testAdminAuth0Sub]
|
[testAdminId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.patch(`/api/admin/admins/${testAdminAuth0Sub}/revoke`)
|
.patch(`/api/admin/admins/${testAdminId}/revoke`)
|
||||||
.expect(400);
|
.expect(400);
|
||||||
|
|
||||||
expect(response.body.error).toBe('Bad Request');
|
expect(response.body.error).toBe('Bad Request');
|
||||||
@@ -351,8 +362,8 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 for non-existent admin', async () => {
|
it('should return 404 for non-existent admin', async () => {
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.patch('/api/admin/admins/auth0|nonexistent/revoke')
|
.patch('/api/admin/admins/00000000-0000-0000-0000-000000000000/revoke')
|
||||||
.expect(404);
|
.expect(404);
|
||||||
|
|
||||||
expect(response.body.error).toBe('Not Found');
|
expect(response.body.error).toBe('Not Found');
|
||||||
@@ -360,23 +371,24 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PATCH /api/admin/admins/:auth0Sub/reinstate', () => {
|
describe('PATCH /api/admin/admins/:id/reinstate', () => {
|
||||||
it('should reinstate revoked admin', async () => {
|
it('should reinstate revoked admin', async () => {
|
||||||
// Create revoked admin
|
// Create revoked admin
|
||||||
|
const reinstateId = 'c2d3e4f5-a6b7-8901-2345-678901bcdef0';
|
||||||
const createResult = await pool.query(`
|
const createResult = await pool.query(`
|
||||||
INSERT INTO admin_users (auth0_sub, email, role, created_by, revoked_at)
|
INSERT INTO admin_users (id, user_profile_id, email, role, created_by, revoked_at)
|
||||||
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||||
RETURNING auth0_sub
|
RETURNING id
|
||||||
`, ['auth0|to-reinstate', 'toreinstate@example.com', 'admin', testAdminAuth0Sub]);
|
`, [reinstateId, reinstateId, 'toreinstate@example.com', 'admin', testAdminId]);
|
||||||
|
|
||||||
const auth0Sub = createResult.rows[0].auth0_sub;
|
const adminId = createResult.rows[0].id;
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.patch(`/api/admin/admins/${auth0Sub}/reinstate`)
|
.patch(`/api/admin/admins/${adminId}/reinstate`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body).toMatchObject({
|
expect(response.body).toMatchObject({
|
||||||
auth0Sub,
|
id: adminId,
|
||||||
email: 'toreinstate@example.com',
|
email: 'toreinstate@example.com',
|
||||||
revokedAt: null
|
revokedAt: null
|
||||||
});
|
});
|
||||||
@@ -384,14 +396,14 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
// Verify audit log
|
// Verify audit log
|
||||||
const auditResult = await pool.query(
|
const auditResult = await pool.query(
|
||||||
'SELECT * FROM admin_audit_logs WHERE action = $1 AND target_admin_id = $2',
|
'SELECT * FROM admin_audit_logs WHERE action = $1 AND target_admin_id = $2',
|
||||||
['REINSTATE', auth0Sub]
|
['REINSTATE', adminId]
|
||||||
);
|
);
|
||||||
expect(auditResult.rows.length).toBe(1);
|
expect(auditResult.rows.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 for non-existent admin', async () => {
|
it('should return 404 for non-existent admin', async () => {
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.patch('/api/admin/admins/auth0|nonexistent/reinstate')
|
.patch('/api/admin/admins/00000000-0000-0000-0000-000000000000/reinstate')
|
||||||
.expect(404);
|
.expect(404);
|
||||||
|
|
||||||
expect(response.body.error).toBe('Not Found');
|
expect(response.body.error).toBe('Not Found');
|
||||||
@@ -400,16 +412,17 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
|
|
||||||
it('should handle reinstating already active admin', async () => {
|
it('should handle reinstating already active admin', async () => {
|
||||||
// Create active admin
|
// Create active admin
|
||||||
|
const activeId = 'd3e4f5a6-b7c8-9012-3456-789012cdef01';
|
||||||
const createResult = await pool.query(`
|
const createResult = await pool.query(`
|
||||||
INSERT INTO admin_users (auth0_sub, email, role, created_by)
|
INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING auth0_sub
|
RETURNING id
|
||||||
`, ['auth0|already-active', 'active@example.com', 'admin', testAdminAuth0Sub]);
|
`, [activeId, activeId, 'active@example.com', 'admin', testAdminId]);
|
||||||
|
|
||||||
const auth0Sub = createResult.rows[0].auth0_sub;
|
const adminId = createResult.rows[0].id;
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.patch(`/api/admin/admins/${auth0Sub}/reinstate`)
|
.patch(`/api/admin/admins/${adminId}/reinstate`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.revokedAt).toBeNull();
|
expect(response.body.revokedAt).toBeNull();
|
||||||
@@ -426,12 +439,12 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
($5, $6, $7, $8),
|
($5, $6, $7, $8),
|
||||||
($9, $10, $11, $12)
|
($9, $10, $11, $12)
|
||||||
`, [
|
`, [
|
||||||
testAdminAuth0Sub, 'CREATE', 'admin_user', 'test1@example.com',
|
testAdminId, 'CREATE', 'admin_user', 'test1@example.com',
|
||||||
testAdminAuth0Sub, 'REVOKE', 'admin_user', 'test2@example.com',
|
testAdminId, 'REVOKE', 'admin_user', 'test2@example.com',
|
||||||
testAdminAuth0Sub, 'REINSTATE', 'admin_user', 'test3@example.com'
|
testAdminId, 'REINSTATE', 'admin_user', 'test3@example.com'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.get('/api/admin/audit-logs')
|
.get('/api/admin/audit-logs')
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
@@ -440,7 +453,7 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
expect(response.body.logs.length).toBeGreaterThanOrEqual(3);
|
expect(response.body.logs.length).toBeGreaterThanOrEqual(3);
|
||||||
expect(response.body.logs[0]).toMatchObject({
|
expect(response.body.logs[0]).toMatchObject({
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
actorAdminId: testAdminAuth0Sub,
|
actorAdminId: testAdminId,
|
||||||
action: expect.any(String),
|
action: expect.any(String),
|
||||||
resourceType: expect.any(String),
|
resourceType: expect.any(String),
|
||||||
createdAt: expect.any(String)
|
createdAt: expect.any(String)
|
||||||
@@ -453,10 +466,10 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
await pool.query(`
|
await pool.query(`
|
||||||
INSERT INTO admin_audit_logs (actor_admin_id, action, resource_type, resource_id)
|
INSERT INTO admin_audit_logs (actor_admin_id, action, resource_type, resource_id)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
`, [testAdminAuth0Sub, 'CREATE', 'admin_user', `test${i}@example.com`]);
|
`, [testAdminId, 'CREATE', 'admin_user', `test${i}@example.com`]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.get('/api/admin/audit-logs?limit=5&offset=0')
|
.get('/api/admin/audit-logs?limit=5&offset=0')
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
@@ -473,12 +486,12 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
($3, $4, CURRENT_TIMESTAMP - INTERVAL '1 minute'),
|
($3, $4, CURRENT_TIMESTAMP - INTERVAL '1 minute'),
|
||||||
($5, $6, CURRENT_TIMESTAMP)
|
($5, $6, CURRENT_TIMESTAMP)
|
||||||
`, [
|
`, [
|
||||||
testAdminAuth0Sub, 'FIRST',
|
testAdminId, 'FIRST',
|
||||||
testAdminAuth0Sub, 'SECOND',
|
testAdminId, 'SECOND',
|
||||||
testAdminAuth0Sub, 'THIRD'
|
testAdminId, 'THIRD'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app.server)
|
||||||
.get('/api/admin/audit-logs?limit=3')
|
.get('/api/admin/audit-logs?limit=3')
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
@@ -491,45 +504,45 @@ describe('Admin Management Integration Tests', () => {
|
|||||||
describe('End-to-end workflow', () => {
|
describe('End-to-end workflow', () => {
|
||||||
it('should create, revoke, and reinstate admin with full audit trail', async () => {
|
it('should create, revoke, and reinstate admin with full audit trail', async () => {
|
||||||
// 1. Create new admin
|
// 1. Create new admin
|
||||||
const createResponse = await request(app)
|
const createResponse = await request(app.server)
|
||||||
.post('/api/admin/admins')
|
.post('/api/admin/admins')
|
||||||
.send({ email: 'workflow@example.com', role: 'admin' })
|
.send({ email: 'workflow@example.com', role: 'admin' })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
const auth0Sub = createResponse.body.auth0Sub;
|
const adminId = createResponse.body.id;
|
||||||
|
|
||||||
// 2. Verify admin appears in list
|
// 2. Verify admin appears in list
|
||||||
const listResponse = await request(app)
|
const listResponse = await request(app.server)
|
||||||
.get('/api/admin/admins')
|
.get('/api/admin/admins')
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
const createdAdmin = listResponse.body.admins.find(
|
const createdAdmin = listResponse.body.admins.find(
|
||||||
(admin: any) => admin.auth0Sub === auth0Sub
|
(admin: any) => admin.id === adminId
|
||||||
);
|
);
|
||||||
expect(createdAdmin).toBeDefined();
|
expect(createdAdmin).toBeDefined();
|
||||||
expect(createdAdmin.revokedAt).toBeNull();
|
expect(createdAdmin.revokedAt).toBeNull();
|
||||||
|
|
||||||
// 3. Revoke admin
|
// 3. Revoke admin
|
||||||
const revokeResponse = await request(app)
|
const revokeResponse = await request(app.server)
|
||||||
.patch(`/api/admin/admins/${auth0Sub}/revoke`)
|
.patch(`/api/admin/admins/${adminId}/revoke`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(revokeResponse.body.revokedAt).toBeTruthy();
|
expect(revokeResponse.body.revokedAt).toBeTruthy();
|
||||||
|
|
||||||
// 4. Reinstate admin
|
// 4. Reinstate admin
|
||||||
const reinstateResponse = await request(app)
|
const reinstateResponse = await request(app.server)
|
||||||
.patch(`/api/admin/admins/${auth0Sub}/reinstate`)
|
.patch(`/api/admin/admins/${adminId}/reinstate`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(reinstateResponse.body.revokedAt).toBeNull();
|
expect(reinstateResponse.body.revokedAt).toBeNull();
|
||||||
|
|
||||||
// 5. Verify complete audit trail
|
// 5. Verify complete audit trail
|
||||||
const auditResponse = await request(app)
|
const auditResponse = await request(app.server)
|
||||||
.get('/api/admin/audit-logs')
|
.get('/api/admin/audit-logs')
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
const workflowLogs = auditResponse.body.logs.filter(
|
const workflowLogs = auditResponse.body.logs.filter(
|
||||||
(log: any) => log.targetAdminId === auth0Sub || log.resourceId === 'workflow@example.com'
|
(log: any) => log.targetAdminId === adminId || log.resourceId === 'workflow@example.com'
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(workflowLogs.length).toBeGreaterThanOrEqual(3);
|
expect(workflowLogs.length).toBeGreaterThanOrEqual(3);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe('admin guard plugin', () => {
|
|||||||
fastify = Fastify();
|
fastify = Fastify();
|
||||||
authenticateMock = jest.fn(async (request: FastifyRequest) => {
|
authenticateMock = jest.fn(async (request: FastifyRequest) => {
|
||||||
request.userContext = {
|
request.userContext = {
|
||||||
userId: 'auth0|admin',
|
userId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
|
||||||
email: 'admin@motovaultpro.com',
|
email: 'admin@motovaultpro.com',
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
onboardingCompleted: true,
|
onboardingCompleted: true,
|
||||||
@@ -41,7 +41,7 @@ describe('admin guard plugin', () => {
|
|||||||
mockPool = {
|
mockPool = {
|
||||||
query: jest.fn().mockResolvedValue({
|
query: jest.fn().mockResolvedValue({
|
||||||
rows: [{
|
rows: [{
|
||||||
auth0_sub: 'auth0|admin',
|
user_profile_id: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
|
||||||
email: 'admin@motovaultpro.com',
|
email: 'admin@motovaultpro.com',
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
revoked_at: null,
|
revoked_at: null,
|
||||||
|
|||||||
@@ -6,13 +6,23 @@
|
|||||||
import { AdminService } from '../../domain/admin.service';
|
import { AdminService } from '../../domain/admin.service';
|
||||||
import { AdminRepository } from '../../data/admin.repository';
|
import { AdminRepository } from '../../data/admin.repository';
|
||||||
|
|
||||||
|
// Mock the audit log service
|
||||||
|
jest.mock('../../../audit-log', () => ({
|
||||||
|
auditLogService: {
|
||||||
|
info: jest.fn().mockResolvedValue(undefined),
|
||||||
|
warn: jest.fn().mockResolvedValue(undefined),
|
||||||
|
error: jest.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe('AdminService', () => {
|
describe('AdminService', () => {
|
||||||
let adminService: AdminService;
|
let adminService: AdminService;
|
||||||
let mockRepository: jest.Mocked<AdminRepository>;
|
let mockRepository: jest.Mocked<AdminRepository>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockRepository = {
|
mockRepository = {
|
||||||
getAdminByAuth0Sub: jest.fn(),
|
getAdminById: jest.fn(),
|
||||||
|
getAdminByUserProfileId: jest.fn(),
|
||||||
getAdminByEmail: jest.fn(),
|
getAdminByEmail: jest.fn(),
|
||||||
getAllAdmins: jest.fn(),
|
getAllAdmins: jest.fn(),
|
||||||
getActiveAdmins: jest.fn(),
|
getActiveAdmins: jest.fn(),
|
||||||
@@ -26,30 +36,31 @@ describe('AdminService', () => {
|
|||||||
adminService = new AdminService(mockRepository);
|
adminService = new AdminService(mockRepository);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAdminByAuth0Sub', () => {
|
describe('getAdminById', () => {
|
||||||
it('should return admin when found', async () => {
|
it('should return admin when found', async () => {
|
||||||
const mockAdmin = {
|
const mockAdmin = {
|
||||||
auth0Sub: 'auth0|123456',
|
id: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
|
||||||
|
userProfileId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
|
||||||
email: 'admin@motovaultpro.com',
|
email: 'admin@motovaultpro.com',
|
||||||
role: 'admin',
|
role: 'admin' as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
createdBy: 'system',
|
createdBy: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
mockRepository.getAdminByAuth0Sub.mockResolvedValue(mockAdmin);
|
mockRepository.getAdminById.mockResolvedValue(mockAdmin);
|
||||||
|
|
||||||
const result = await adminService.getAdminByAuth0Sub('auth0|123456');
|
const result = await adminService.getAdminById('7c9e6679-7425-40de-944b-e07fc1f90ae7');
|
||||||
|
|
||||||
expect(result).toEqual(mockAdmin);
|
expect(result).toEqual(mockAdmin);
|
||||||
expect(mockRepository.getAdminByAuth0Sub).toHaveBeenCalledWith('auth0|123456');
|
expect(mockRepository.getAdminById).toHaveBeenCalledWith('7c9e6679-7425-40de-944b-e07fc1f90ae7');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null when admin not found', async () => {
|
it('should return null when admin not found', async () => {
|
||||||
mockRepository.getAdminByAuth0Sub.mockResolvedValue(null);
|
mockRepository.getAdminById.mockResolvedValue(null);
|
||||||
|
|
||||||
const result = await adminService.getAdminByAuth0Sub('auth0|unknown');
|
const result = await adminService.getAdminById('00000000-0000-0000-0000-000000000000');
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -57,12 +68,15 @@ describe('AdminService', () => {
|
|||||||
|
|
||||||
describe('createAdmin', () => {
|
describe('createAdmin', () => {
|
||||||
it('should create new admin and log audit', async () => {
|
it('should create new admin and log audit', async () => {
|
||||||
|
const newAdminId = '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e';
|
||||||
|
const creatorId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||||
const mockAdmin = {
|
const mockAdmin = {
|
||||||
auth0Sub: 'auth0|newadmin',
|
id: newAdminId,
|
||||||
|
userProfileId: newAdminId,
|
||||||
email: 'newadmin@motovaultpro.com',
|
email: 'newadmin@motovaultpro.com',
|
||||||
role: 'admin',
|
role: 'admin' as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
createdBy: 'auth0|existing',
|
createdBy: creatorId,
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -74,16 +88,16 @@ describe('AdminService', () => {
|
|||||||
const result = await adminService.createAdmin(
|
const result = await adminService.createAdmin(
|
||||||
'newadmin@motovaultpro.com',
|
'newadmin@motovaultpro.com',
|
||||||
'admin',
|
'admin',
|
||||||
'auth0|newadmin',
|
newAdminId,
|
||||||
'auth0|existing'
|
creatorId
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual(mockAdmin);
|
expect(result).toEqual(mockAdmin);
|
||||||
expect(mockRepository.createAdmin).toHaveBeenCalled();
|
expect(mockRepository.createAdmin).toHaveBeenCalled();
|
||||||
expect(mockRepository.logAuditAction).toHaveBeenCalledWith(
|
expect(mockRepository.logAuditAction).toHaveBeenCalledWith(
|
||||||
'auth0|existing',
|
creatorId,
|
||||||
'CREATE',
|
'CREATE',
|
||||||
mockAdmin.auth0Sub,
|
mockAdmin.id,
|
||||||
'admin_user',
|
'admin_user',
|
||||||
mockAdmin.email,
|
mockAdmin.email,
|
||||||
expect.any(Object)
|
expect.any(Object)
|
||||||
@@ -91,12 +105,14 @@ describe('AdminService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should reject if admin already exists', async () => {
|
it('should reject if admin already exists', async () => {
|
||||||
|
const existingId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||||
const existingAdmin = {
|
const existingAdmin = {
|
||||||
auth0Sub: 'auth0|existing',
|
id: existingId,
|
||||||
|
userProfileId: existingId,
|
||||||
email: 'admin@motovaultpro.com',
|
email: 'admin@motovaultpro.com',
|
||||||
role: 'admin',
|
role: 'admin' as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
createdBy: 'system',
|
createdBy: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -104,39 +120,46 @@ describe('AdminService', () => {
|
|||||||
mockRepository.getAdminByEmail.mockResolvedValue(existingAdmin);
|
mockRepository.getAdminByEmail.mockResolvedValue(existingAdmin);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
adminService.createAdmin('admin@motovaultpro.com', 'admin', 'auth0|new', 'auth0|existing')
|
adminService.createAdmin('admin@motovaultpro.com', 'admin', '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e', existingId)
|
||||||
).rejects.toThrow('already exists');
|
).rejects.toThrow('already exists');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('revokeAdmin', () => {
|
describe('revokeAdmin', () => {
|
||||||
it('should revoke admin when multiple active admins exist', async () => {
|
it('should revoke admin when multiple active admins exist', async () => {
|
||||||
|
const toRevokeId = 'a1b2c3d4-e5f6-7890-1234-567890abcdef';
|
||||||
|
const admin1Id = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
||||||
|
const admin2Id = '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e';
|
||||||
|
|
||||||
const revokedAdmin = {
|
const revokedAdmin = {
|
||||||
auth0Sub: 'auth0|toadmin',
|
id: toRevokeId,
|
||||||
|
userProfileId: toRevokeId,
|
||||||
email: 'toadmin@motovaultpro.com',
|
email: 'toadmin@motovaultpro.com',
|
||||||
role: 'admin',
|
role: 'admin' as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
createdBy: 'system',
|
createdBy: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
revokedAt: new Date(),
|
revokedAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeAdmins = [
|
const activeAdmins = [
|
||||||
{
|
{
|
||||||
auth0Sub: 'auth0|admin1',
|
id: admin1Id,
|
||||||
|
userProfileId: admin1Id,
|
||||||
email: 'admin1@motovaultpro.com',
|
email: 'admin1@motovaultpro.com',
|
||||||
role: 'admin',
|
role: 'admin' as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
createdBy: 'system',
|
createdBy: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
auth0Sub: 'auth0|admin2',
|
id: admin2Id,
|
||||||
|
userProfileId: admin2Id,
|
||||||
email: 'admin2@motovaultpro.com',
|
email: 'admin2@motovaultpro.com',
|
||||||
role: 'admin',
|
role: 'admin' as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
createdBy: 'system',
|
createdBy: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
@@ -146,20 +169,22 @@ describe('AdminService', () => {
|
|||||||
mockRepository.revokeAdmin.mockResolvedValue(revokedAdmin);
|
mockRepository.revokeAdmin.mockResolvedValue(revokedAdmin);
|
||||||
mockRepository.logAuditAction.mockResolvedValue({} as any);
|
mockRepository.logAuditAction.mockResolvedValue({} as any);
|
||||||
|
|
||||||
const result = await adminService.revokeAdmin('auth0|toadmin', 'auth0|admin1');
|
const result = await adminService.revokeAdmin(toRevokeId, admin1Id);
|
||||||
|
|
||||||
expect(result).toEqual(revokedAdmin);
|
expect(result).toEqual(revokedAdmin);
|
||||||
expect(mockRepository.revokeAdmin).toHaveBeenCalledWith('auth0|toadmin');
|
expect(mockRepository.revokeAdmin).toHaveBeenCalledWith(toRevokeId);
|
||||||
expect(mockRepository.logAuditAction).toHaveBeenCalled();
|
expect(mockRepository.logAuditAction).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should prevent revoking last active admin', async () => {
|
it('should prevent revoking last active admin', async () => {
|
||||||
|
const lastAdminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||||
const lastAdmin = {
|
const lastAdmin = {
|
||||||
auth0Sub: 'auth0|lastadmin',
|
id: lastAdminId,
|
||||||
|
userProfileId: lastAdminId,
|
||||||
email: 'last@motovaultpro.com',
|
email: 'last@motovaultpro.com',
|
||||||
role: 'admin',
|
role: 'admin' as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
createdBy: 'system',
|
createdBy: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -167,19 +192,22 @@ describe('AdminService', () => {
|
|||||||
mockRepository.getActiveAdmins.mockResolvedValue([lastAdmin]);
|
mockRepository.getActiveAdmins.mockResolvedValue([lastAdmin]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
adminService.revokeAdmin('auth0|lastadmin', 'auth0|lastadmin')
|
adminService.revokeAdmin(lastAdminId, lastAdminId)
|
||||||
).rejects.toThrow('Cannot revoke the last active admin');
|
).rejects.toThrow('Cannot revoke the last active admin');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('reinstateAdmin', () => {
|
describe('reinstateAdmin', () => {
|
||||||
it('should reinstate revoked admin and log audit', async () => {
|
it('should reinstate revoked admin and log audit', async () => {
|
||||||
|
const reinstateId = 'b2c3d4e5-f6a7-8901-2345-678901bcdef0';
|
||||||
|
const adminActorId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||||
const reinstatedAdmin = {
|
const reinstatedAdmin = {
|
||||||
auth0Sub: 'auth0|reinstate',
|
id: reinstateId,
|
||||||
|
userProfileId: reinstateId,
|
||||||
email: 'reinstate@motovaultpro.com',
|
email: 'reinstate@motovaultpro.com',
|
||||||
role: 'admin',
|
role: 'admin' as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
createdBy: 'system',
|
createdBy: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
revokedAt: null,
|
revokedAt: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -187,14 +215,14 @@ describe('AdminService', () => {
|
|||||||
mockRepository.reinstateAdmin.mockResolvedValue(reinstatedAdmin);
|
mockRepository.reinstateAdmin.mockResolvedValue(reinstatedAdmin);
|
||||||
mockRepository.logAuditAction.mockResolvedValue({} as any);
|
mockRepository.logAuditAction.mockResolvedValue({} as any);
|
||||||
|
|
||||||
const result = await adminService.reinstateAdmin('auth0|reinstate', 'auth0|admin');
|
const result = await adminService.reinstateAdmin(reinstateId, adminActorId);
|
||||||
|
|
||||||
expect(result).toEqual(reinstatedAdmin);
|
expect(result).toEqual(reinstatedAdmin);
|
||||||
expect(mockRepository.reinstateAdmin).toHaveBeenCalledWith('auth0|reinstate');
|
expect(mockRepository.reinstateAdmin).toHaveBeenCalledWith(reinstateId);
|
||||||
expect(mockRepository.logAuditAction).toHaveBeenCalledWith(
|
expect(mockRepository.logAuditAction).toHaveBeenCalledWith(
|
||||||
'auth0|admin',
|
adminActorId,
|
||||||
'REINSTATE',
|
'REINSTATE',
|
||||||
'auth0|reinstate',
|
reinstateId,
|
||||||
'admin_user',
|
'admin_user',
|
||||||
reinstatedAdmin.email
|
reinstatedAdmin.email
|
||||||
);
|
);
|
||||||
|
|||||||
19
backend/src/features/audit-log/CLAUDE.md
Normal file
19
backend/src/features/audit-log/CLAUDE.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# audit-log/
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What | When to read |
|
||||||
|
| ---- | ---- | ------------ |
|
||||||
|
| `README.md` | Architecture, usage patterns, categories | Understanding audit log system |
|
||||||
|
| `audit-log.instance.ts` | Singleton service instance | Cross-feature logging integration |
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
|
||||||
|
| Directory | What | When to read |
|
||||||
|
| --------- | ---- | ------------ |
|
||||||
|
| `api/` | HTTP endpoints for log viewing/export | API route changes |
|
||||||
|
| `domain/` | Business logic, types, service | Core audit logging logic |
|
||||||
|
| `data/` | Repository, database queries | Database operations |
|
||||||
|
| `jobs/` | Scheduled cleanup job | Retention policy |
|
||||||
|
| `migrations/` | Database schema | Schema changes |
|
||||||
|
| `__tests__/` | Integration tests | Adding or modifying tests |
|
||||||
168
backend/src/features/audit-log/README.md
Normal file
168
backend/src/features/audit-log/README.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Audit Log Feature
|
||||||
|
|
||||||
|
Centralized audit logging system for tracking all user and system actions across MotoVaultPro.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend
|
||||||
|
+--------------+ +-------------------+
|
||||||
|
| AdminLogsPage| | AdminLogsMobile |
|
||||||
|
| (desktop) | | Screen (mobile) |
|
||||||
|
+------+-------+ +--------+----------+
|
||||||
|
| |
|
||||||
|
+-------------------+
|
||||||
|
|
|
||||||
|
| useAuditLogs hook
|
||||||
|
v
|
||||||
|
adminApi.unifiedAuditLogs
|
||||||
|
|
|
||||||
|
| HTTP
|
||||||
|
v
|
||||||
|
GET /api/admin/audit-logs?search=X&category=Y&...
|
||||||
|
GET /api/admin/audit-logs/export
|
||||||
|
|
|
||||||
|
+--------v--------+
|
||||||
|
| AuditLogController |
|
||||||
|
+--------+--------+
|
||||||
|
|
|
||||||
|
+--------v--------+
|
||||||
|
| AuditLogService |<----- Other services call
|
||||||
|
| log(category,...)| auditLogService.info()
|
||||||
|
+--------+--------+
|
||||||
|
|
|
||||||
|
+--------v--------+
|
||||||
|
| AuditLogRepository |
|
||||||
|
+--------+--------+
|
||||||
|
v
|
||||||
|
+-------------+
|
||||||
|
| audit_logs | (PostgreSQL)
|
||||||
|
+-------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Feature Service (vehicles, auth, etc.)
|
||||||
|
|
|
||||||
|
| auditLogService.info(category, userId, action, resourceType?, resourceId?, details?)
|
||||||
|
v
|
||||||
|
AuditLogService
|
||||||
|
|
|
||||||
|
| INSERT INTO audit_logs
|
||||||
|
v
|
||||||
|
PostgreSQL audit_logs table
|
||||||
|
|
|
||||||
|
| GET /api/admin/audit-logs (with filters)
|
||||||
|
v
|
||||||
|
AdminLogsPage/Mobile displays filtered, paginated results
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
category VARCHAR(20) NOT NULL CHECK (category IN ('auth', 'vehicle', 'user', 'system', 'admin')),
|
||||||
|
severity VARCHAR(10) NOT NULL CHECK (severity IN ('info', 'warning', 'error')),
|
||||||
|
user_id VARCHAR(255), -- NULL for system-initiated actions
|
||||||
|
action VARCHAR(500) NOT NULL,
|
||||||
|
resource_type VARCHAR(100),
|
||||||
|
resource_id VARCHAR(255),
|
||||||
|
details JSONB,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Indexes
|
||||||
|
|
||||||
|
- `idx_audit_logs_category_created` - B-tree for category filtering
|
||||||
|
- `idx_audit_logs_severity_created` - B-tree for severity filtering
|
||||||
|
- `idx_audit_logs_user_created` - B-tree for user filtering
|
||||||
|
- `idx_audit_logs_created` - B-tree for date ordering
|
||||||
|
- `idx_audit_logs_action_gin` - GIN trigram for text search
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### GET /api/admin/audit-logs
|
||||||
|
|
||||||
|
Returns paginated audit logs with optional filters.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `search` - Text search on action field (ILIKE)
|
||||||
|
- `category` - Filter by category (auth, vehicle, user, system, admin)
|
||||||
|
- `severity` - Filter by severity (info, warning, error)
|
||||||
|
- `startDate` - ISO date string for date range start
|
||||||
|
- `endDate` - ISO date string for date range end
|
||||||
|
- `limit` - Page size (default 25, max 100)
|
||||||
|
- `offset` - Pagination offset
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"logs": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"category": "vehicle",
|
||||||
|
"severity": "info",
|
||||||
|
"userId": "auth0|...",
|
||||||
|
"action": "Vehicle created: 2024 Toyota Camry",
|
||||||
|
"resourceType": "vehicle",
|
||||||
|
"resourceId": "vehicle-uuid",
|
||||||
|
"details": { "vin": "...", "make": "Toyota" },
|
||||||
|
"createdAt": "2024-01-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 150,
|
||||||
|
"limit": 25,
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/admin/audit-logs/export
|
||||||
|
|
||||||
|
Returns CSV file with filtered audit logs.
|
||||||
|
|
||||||
|
**Query Parameters:** Same as list endpoint (except pagination)
|
||||||
|
|
||||||
|
**Response:** CSV file download
|
||||||
|
|
||||||
|
## Usage in Features
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { auditLogService } from '../../audit-log';
|
||||||
|
|
||||||
|
// In vehicles.service.ts
|
||||||
|
await auditLogService.info(
|
||||||
|
'vehicle',
|
||||||
|
userId,
|
||||||
|
`Vehicle created: ${vehicleDesc}`,
|
||||||
|
'vehicle',
|
||||||
|
vehicleId,
|
||||||
|
{ vin, make, model, year }
|
||||||
|
).catch(err => logger.error('Failed to log audit event', { error: err }));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Retention Policy
|
||||||
|
|
||||||
|
- Logs older than 90 days are automatically deleted
|
||||||
|
- Cleanup job runs daily at 3 AM
|
||||||
|
- Implemented in `jobs/cleanup.job.ts`
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
|
||||||
|
| Category | Description | Examples |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| `auth` | Authentication events | Signup, password reset |
|
||||||
|
| `vehicle` | Vehicle CRUD | Create, update, delete |
|
||||||
|
| `user` | User management | Profile updates |
|
||||||
|
| `system` | System operations | Backup, restore |
|
||||||
|
| `admin` | Admin actions | Grant/revoke admin |
|
||||||
|
|
||||||
|
## Severity Levels
|
||||||
|
|
||||||
|
| Level | Color (UI) | Description |
|
||||||
|
|-------|------------|-------------|
|
||||||
|
| `info` | Blue | Normal operations |
|
||||||
|
| `warning` | Yellow | Potential issues |
|
||||||
|
| `error` | Red | Failed operations |
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Integration tests for audit log wiring across features
|
||||||
|
* @ai-context Verifies audit logging is properly integrated into auth, vehicle, admin, and backup features
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { appConfig } from '../../../core/config/config-loader';
|
||||||
|
import { AuditLogService } from '../domain/audit-log.service';
|
||||||
|
import { AuditLogRepository } from '../data/audit-log.repository';
|
||||||
|
|
||||||
|
describe('AuditLog Feature Integration', () => {
|
||||||
|
let pool: Pool;
|
||||||
|
let repository: AuditLogRepository;
|
||||||
|
let service: AuditLogService;
|
||||||
|
const createdIds: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
pool = new Pool({
|
||||||
|
connectionString: appConfig.getDatabaseUrl(),
|
||||||
|
});
|
||||||
|
repository = new AuditLogRepository(pool);
|
||||||
|
service = new AuditLogService(repository);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Cleanup test data
|
||||||
|
if (createdIds.length > 0) {
|
||||||
|
await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::uuid[])`, [createdIds]);
|
||||||
|
}
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Vehicle logging integration', () => {
|
||||||
|
it('should create audit log with vehicle category and correct resource', async () => {
|
||||||
|
const userId = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
const vehicleId = 'vehicle-uuid-123';
|
||||||
|
const entry = await service.info(
|
||||||
|
'vehicle',
|
||||||
|
userId,
|
||||||
|
'Vehicle created: 2024 Toyota Camry',
|
||||||
|
'vehicle',
|
||||||
|
vehicleId,
|
||||||
|
{ vin: '1HGBH41JXMN109186', make: 'Toyota', model: 'Camry', year: 2024 }
|
||||||
|
);
|
||||||
|
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.category).toBe('vehicle');
|
||||||
|
expect(entry.severity).toBe('info');
|
||||||
|
expect(entry.userId).toBe(userId);
|
||||||
|
expect(entry.action).toContain('Vehicle created');
|
||||||
|
expect(entry.resourceType).toBe('vehicle');
|
||||||
|
expect(entry.resourceId).toBe(vehicleId);
|
||||||
|
expect(entry.details).toHaveProperty('vin');
|
||||||
|
expect(entry.details).toHaveProperty('make', 'Toyota');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log vehicle update with correct fields', async () => {
|
||||||
|
const userId = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
||||||
|
const vehicleId = 'vehicle-uuid-456';
|
||||||
|
const entry = await service.info(
|
||||||
|
'vehicle',
|
||||||
|
userId,
|
||||||
|
'Vehicle updated: 2024 Toyota Camry',
|
||||||
|
'vehicle',
|
||||||
|
vehicleId,
|
||||||
|
{ updatedFields: ['color', 'licensePlate'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.category).toBe('vehicle');
|
||||||
|
expect(entry.action).toContain('Vehicle updated');
|
||||||
|
expect(entry.details).toHaveProperty('updatedFields');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log vehicle deletion with vehicle info', async () => {
|
||||||
|
const userId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||||
|
const vehicleId = 'vehicle-uuid-789';
|
||||||
|
const entry = await service.info(
|
||||||
|
'vehicle',
|
||||||
|
userId,
|
||||||
|
'Vehicle deleted: 2024 Toyota Camry',
|
||||||
|
'vehicle',
|
||||||
|
vehicleId,
|
||||||
|
{ vin: '1HGBH41JXMN109186', make: 'Toyota', model: 'Camry', year: 2024 }
|
||||||
|
);
|
||||||
|
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.category).toBe('vehicle');
|
||||||
|
expect(entry.action).toContain('Vehicle deleted');
|
||||||
|
expect(entry.resourceId).toBe(vehicleId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Auth logging integration', () => {
|
||||||
|
it('should create audit log with auth category for signup', async () => {
|
||||||
|
const userId = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
const entry = await service.info(
|
||||||
|
'auth',
|
||||||
|
userId,
|
||||||
|
'User signup: test@example.com',
|
||||||
|
'user',
|
||||||
|
userId,
|
||||||
|
{ email: 'test@example.com', ipAddress: '192.168.1.1' }
|
||||||
|
);
|
||||||
|
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.category).toBe('auth');
|
||||||
|
expect(entry.severity).toBe('info');
|
||||||
|
expect(entry.userId).toBe(userId);
|
||||||
|
expect(entry.action).toContain('signup');
|
||||||
|
expect(entry.resourceType).toBe('user');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create audit log for password reset request', async () => {
|
||||||
|
const userId = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
||||||
|
const entry = await service.info(
|
||||||
|
'auth',
|
||||||
|
userId,
|
||||||
|
'Password reset requested',
|
||||||
|
'user',
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.category).toBe('auth');
|
||||||
|
expect(entry.action).toBe('Password reset requested');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Admin logging integration', () => {
|
||||||
|
it('should create audit log for admin user creation', async () => {
|
||||||
|
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||||
|
const targetAdminId = '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e';
|
||||||
|
const entry = await service.info(
|
||||||
|
'admin',
|
||||||
|
adminId,
|
||||||
|
'Admin user created: newadmin@example.com',
|
||||||
|
'admin_user',
|
||||||
|
targetAdminId,
|
||||||
|
{ email: 'newadmin@example.com', role: 'admin' }
|
||||||
|
);
|
||||||
|
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.category).toBe('admin');
|
||||||
|
expect(entry.severity).toBe('info');
|
||||||
|
expect(entry.userId).toBe(adminId);
|
||||||
|
expect(entry.action).toContain('Admin user created');
|
||||||
|
expect(entry.resourceType).toBe('admin_user');
|
||||||
|
expect(entry.details).toHaveProperty('role', 'admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create audit log for admin revocation', async () => {
|
||||||
|
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||||
|
const targetAdminId = 'a1b2c3d4-e5f6-7890-1234-567890abcdef';
|
||||||
|
const entry = await service.info(
|
||||||
|
'admin',
|
||||||
|
adminId,
|
||||||
|
'Admin user revoked: revoked@example.com',
|
||||||
|
'admin_user',
|
||||||
|
targetAdminId,
|
||||||
|
{ email: 'revoked@example.com' }
|
||||||
|
);
|
||||||
|
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.category).toBe('admin');
|
||||||
|
expect(entry.action).toContain('Admin user revoked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create audit log for admin reinstatement', async () => {
|
||||||
|
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||||
|
const targetAdminId = 'b2c3d4e5-f6a7-8901-2345-678901bcdef0';
|
||||||
|
const entry = await service.info(
|
||||||
|
'admin',
|
||||||
|
adminId,
|
||||||
|
'Admin user reinstated: reinstated@example.com',
|
||||||
|
'admin_user',
|
||||||
|
targetAdminId,
|
||||||
|
{ email: 'reinstated@example.com' }
|
||||||
|
);
|
||||||
|
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.category).toBe('admin');
|
||||||
|
expect(entry.action).toContain('Admin user reinstated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Backup/System logging integration', () => {
|
||||||
|
it('should create audit log for backup creation', async () => {
|
||||||
|
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||||
|
const backupId = 'backup-uuid-123';
|
||||||
|
const entry = await service.info(
|
||||||
|
'system',
|
||||||
|
adminId,
|
||||||
|
'Backup created: Manual backup',
|
||||||
|
'backup',
|
||||||
|
backupId,
|
||||||
|
{ name: 'Manual backup', includeDocuments: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.category).toBe('system');
|
||||||
|
expect(entry.severity).toBe('info');
|
||||||
|
expect(entry.action).toContain('Backup created');
|
||||||
|
expect(entry.resourceType).toBe('backup');
|
||||||
|
expect(entry.resourceId).toBe(backupId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create audit log for backup restore', async () => {
|
||||||
|
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||||
|
const backupId = 'backup-uuid-456';
|
||||||
|
const entry = await service.info(
|
||||||
|
'system',
|
||||||
|
adminId,
|
||||||
|
'Backup restored: backup-uuid-456',
|
||||||
|
'backup',
|
||||||
|
backupId,
|
||||||
|
{ safetyBackupId: 'safety-backup-uuid' }
|
||||||
|
);
|
||||||
|
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.category).toBe('system');
|
||||||
|
expect(entry.action).toContain('Backup restored');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create error-level audit log for backup failure', async () => {
|
||||||
|
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||||
|
const backupId = 'backup-uuid-789';
|
||||||
|
const entry = await service.error(
|
||||||
|
'system',
|
||||||
|
adminId,
|
||||||
|
'Backup failed: Daily backup',
|
||||||
|
'backup',
|
||||||
|
backupId,
|
||||||
|
{ error: 'Disk full' }
|
||||||
|
);
|
||||||
|
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.category).toBe('system');
|
||||||
|
expect(entry.severity).toBe('error');
|
||||||
|
expect(entry.action).toContain('Backup failed');
|
||||||
|
expect(entry.details).toHaveProperty('error', 'Disk full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create error-level audit log for restore failure', async () => {
|
||||||
|
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||||
|
const backupId = 'backup-uuid-restore-fail';
|
||||||
|
const entry = await service.error(
|
||||||
|
'system',
|
||||||
|
adminId,
|
||||||
|
'Backup restore failed: backup-uuid-restore-fail',
|
||||||
|
'backup',
|
||||||
|
backupId,
|
||||||
|
{ error: 'Corrupted archive', safetyBackupId: 'safety-uuid' }
|
||||||
|
);
|
||||||
|
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.category).toBe('system');
|
||||||
|
expect(entry.severity).toBe('error');
|
||||||
|
expect(entry.action).toContain('restore failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cross-feature audit log queries', () => {
|
||||||
|
it('should be able to filter logs by category', async () => {
|
||||||
|
// Search for vehicle logs
|
||||||
|
const vehicleResult = await service.search(
|
||||||
|
{ category: 'vehicle' },
|
||||||
|
{ limit: 100, offset: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(vehicleResult.logs.length).toBeGreaterThan(0);
|
||||||
|
expect(vehicleResult.logs.every((log) => log.category === 'vehicle')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to search across all categories', async () => {
|
||||||
|
const result = await service.search(
|
||||||
|
{ search: 'created' },
|
||||||
|
{ limit: 100, offset: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.logs.length).toBeGreaterThan(0);
|
||||||
|
// Should find logs from vehicle and admin categories
|
||||||
|
const categories = new Set(result.logs.map((log) => log.category));
|
||||||
|
expect(categories.size).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to filter by severity across categories', async () => {
|
||||||
|
const errorResult = await service.search(
|
||||||
|
{ severity: 'error' },
|
||||||
|
{ limit: 100, offset: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errorResult.logs.every((log) => log.severity === 'error')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Integration tests for audit log API routes
|
||||||
|
* @ai-context Tests endpoints with authentication, filtering, and export
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { appConfig } from '../../../core/config/config-loader';
|
||||||
|
|
||||||
|
// Mock the authentication for testing
|
||||||
|
const mockAdminUser = {
|
||||||
|
userId: 'admin-test-user',
|
||||||
|
email: 'admin@test.com',
|
||||||
|
isAdmin: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Audit Log Routes', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let pool: Pool;
|
||||||
|
const createdIds: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Import and build app
|
||||||
|
const { default: buildApp } = await import('../../../app');
|
||||||
|
app = await buildApp();
|
||||||
|
|
||||||
|
pool = new Pool({
|
||||||
|
connectionString: appConfig.getDatabaseUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test data
|
||||||
|
const testLogs = [
|
||||||
|
{ category: 'auth', severity: 'info', action: 'User logged in', user_id: 'user-1' },
|
||||||
|
{ category: 'auth', severity: 'warning', action: 'Failed login attempt', user_id: 'user-2' },
|
||||||
|
{ category: 'vehicle', severity: 'info', action: 'Vehicle created', user_id: 'user-1' },
|
||||||
|
{ category: 'admin', severity: 'error', action: 'Admin action failed', user_id: 'admin-1' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const log of testLogs) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO audit_logs (category, severity, action, user_id)
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||||
|
[log.category, log.severity, log.action, log.user_id]
|
||||||
|
);
|
||||||
|
createdIds.push(result.rows[0].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Cleanup test data
|
||||||
|
if (createdIds.length > 0) {
|
||||||
|
await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::uuid[])`, [createdIds]);
|
||||||
|
}
|
||||||
|
await pool.end();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/admin/audit-logs', () => {
|
||||||
|
it('should return 403 for non-admin users', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/audit-logs',
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer non-admin-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return paginated results for admin', async () => {
|
||||||
|
// This test requires proper auth mocking which depends on the app setup
|
||||||
|
// In a real test environment, you'd mock the auth middleware
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/audit-logs',
|
||||||
|
// Would need proper auth headers
|
||||||
|
});
|
||||||
|
|
||||||
|
// Without proper auth, expect 401
|
||||||
|
expect([200, 401]).toContain(response.statusCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate category parameter', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/audit-logs?category=invalid',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Either 400 for invalid category or 401 for no auth
|
||||||
|
expect([400, 401]).toContain(response.statusCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate severity parameter', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/audit-logs?severity=invalid',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Either 400 for invalid severity or 401 for no auth
|
||||||
|
expect([400, 401]).toContain(response.statusCode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/admin/audit-logs/export', () => {
|
||||||
|
it('should return 401 for non-admin users', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/admin/audit-logs/export',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AuditLogController direct tests', () => {
|
||||||
|
// Test the controller directly without auth
|
||||||
|
it('should build valid CSV output', async () => {
|
||||||
|
const { AuditLogController } = await import('../api/audit-log.controller');
|
||||||
|
const controller = new AuditLogController();
|
||||||
|
|
||||||
|
// Controller is instantiated correctly
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Integration tests for AuditLogService
|
||||||
|
* @ai-context Tests log creation, search, filtering, and cleanup
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { appConfig } from '../../../core/config/config-loader';
|
||||||
|
import { AuditLogService } from '../domain/audit-log.service';
|
||||||
|
import { AuditLogRepository } from '../data/audit-log.repository';
|
||||||
|
|
||||||
|
describe('AuditLogService', () => {
|
||||||
|
let pool: Pool;
|
||||||
|
let repository: AuditLogRepository;
|
||||||
|
let service: AuditLogService;
|
||||||
|
const createdIds: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
pool = new Pool({
|
||||||
|
connectionString: appConfig.getDatabaseUrl(),
|
||||||
|
});
|
||||||
|
repository = new AuditLogRepository(pool);
|
||||||
|
service = new AuditLogService(repository);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Cleanup test data
|
||||||
|
if (createdIds.length > 0) {
|
||||||
|
await pool.query(`DELETE FROM audit_logs WHERE id = ANY($1::uuid[])`, [createdIds]);
|
||||||
|
}
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('log()', () => {
|
||||||
|
it('should create log entry with all fields', async () => {
|
||||||
|
const entry = await service.log(
|
||||||
|
'auth',
|
||||||
|
'info',
|
||||||
|
'user-123',
|
||||||
|
'User logged in',
|
||||||
|
'session',
|
||||||
|
'session-456',
|
||||||
|
{ ip: '192.168.1.1', browser: 'Chrome' }
|
||||||
|
);
|
||||||
|
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.id).toBeDefined();
|
||||||
|
expect(entry.category).toBe('auth');
|
||||||
|
expect(entry.severity).toBe('info');
|
||||||
|
expect(entry.userId).toBe('user-123');
|
||||||
|
expect(entry.action).toBe('User logged in');
|
||||||
|
expect(entry.resourceType).toBe('session');
|
||||||
|
expect(entry.resourceId).toBe('session-456');
|
||||||
|
expect(entry.details).toEqual({ ip: '192.168.1.1', browser: 'Chrome' });
|
||||||
|
expect(entry.createdAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create log entry with null userId for system actions', async () => {
|
||||||
|
const entry = await service.log(
|
||||||
|
'system',
|
||||||
|
'info',
|
||||||
|
null,
|
||||||
|
'Scheduled backup started'
|
||||||
|
);
|
||||||
|
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.id).toBeDefined();
|
||||||
|
expect(entry.category).toBe('system');
|
||||||
|
expect(entry.userId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid category', async () => {
|
||||||
|
await expect(
|
||||||
|
service.log(
|
||||||
|
'invalid' as any,
|
||||||
|
'info',
|
||||||
|
'user-123',
|
||||||
|
'Test action'
|
||||||
|
)
|
||||||
|
).rejects.toThrow('Invalid audit log category');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid severity', async () => {
|
||||||
|
await expect(
|
||||||
|
service.log(
|
||||||
|
'auth',
|
||||||
|
'invalid' as any,
|
||||||
|
'user-123',
|
||||||
|
'Test action'
|
||||||
|
)
|
||||||
|
).rejects.toThrow('Invalid audit log severity');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('convenience methods', () => {
|
||||||
|
it('info() should create info-level log', async () => {
|
||||||
|
const entry = await service.info('vehicle', 'user-123', 'Vehicle created');
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.severity).toBe('info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warning() should create warning-level log', async () => {
|
||||||
|
const entry = await service.warning('user', 'user-123', 'Password reset requested');
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.severity).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error() should create error-level log', async () => {
|
||||||
|
const entry = await service.error('admin', 'admin-123', 'Failed to revoke user');
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
expect(entry.severity).toBe('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('search()', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create test data for search
|
||||||
|
const testLogs = [
|
||||||
|
{ category: 'auth', severity: 'info', action: 'Login successful' },
|
||||||
|
{ category: 'auth', severity: 'warning', action: 'Login failed' },
|
||||||
|
{ category: 'vehicle', severity: 'info', action: 'Vehicle created' },
|
||||||
|
{ category: 'vehicle', severity: 'info', action: 'Vehicle updated' },
|
||||||
|
{ category: 'admin', severity: 'error', action: 'Admin action failed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const log of testLogs) {
|
||||||
|
const entry = await service.log(
|
||||||
|
log.category as any,
|
||||||
|
log.severity as any,
|
||||||
|
'test-user',
|
||||||
|
log.action
|
||||||
|
);
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return paginated results', async () => {
|
||||||
|
const result = await service.search({}, { limit: 10, offset: 0 });
|
||||||
|
|
||||||
|
expect(result.logs).toBeInstanceOf(Array);
|
||||||
|
expect(result.total).toBeGreaterThan(0);
|
||||||
|
expect(result.limit).toBe(10);
|
||||||
|
expect(result.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by category', async () => {
|
||||||
|
const result = await service.search(
|
||||||
|
{ category: 'auth' },
|
||||||
|
{ limit: 100, offset: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.logs.length).toBeGreaterThan(0);
|
||||||
|
expect(result.logs.every((log) => log.category === 'auth')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by severity', async () => {
|
||||||
|
const result = await service.search(
|
||||||
|
{ severity: 'error' },
|
||||||
|
{ limit: 100, offset: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.logs.every((log) => log.severity === 'error')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should search by action text', async () => {
|
||||||
|
const result = await service.search(
|
||||||
|
{ search: 'Login' },
|
||||||
|
{ limit: 100, offset: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.logs.length).toBeGreaterThan(0);
|
||||||
|
expect(result.logs.every((log) => log.action.includes('Login'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanup()', () => {
|
||||||
|
it('should delete entries older than specified days', async () => {
|
||||||
|
// Create an old entry by directly inserting
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO audit_logs (category, severity, action, created_at)
|
||||||
|
VALUES ('system', 'info', 'Old test entry', NOW() - INTERVAL '100 days')
|
||||||
|
`);
|
||||||
|
|
||||||
|
const deletedCount = await service.cleanup(90);
|
||||||
|
|
||||||
|
expect(deletedCount).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not delete recent entries', async () => {
|
||||||
|
const entry = await service.log('system', 'info', null, 'Recent entry');
|
||||||
|
createdIds.push(entry.id);
|
||||||
|
|
||||||
|
await service.cleanup(90);
|
||||||
|
|
||||||
|
// Verify entry still exists
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT id FROM audit_logs WHERE id = $1',
|
||||||
|
[entry.id]
|
||||||
|
);
|
||||||
|
expect(result.rows.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
130
backend/src/features/audit-log/__tests__/migrations.test.ts
Normal file
130
backend/src/features/audit-log/__tests__/migrations.test.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Integration tests for audit_logs table migration
|
||||||
|
* @ai-context Tests table creation, constraints, and indexes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { appConfig } from '../../../core/config/config-loader';
|
||||||
|
|
||||||
|
describe('Audit Logs Migration', () => {
|
||||||
|
let pool: Pool;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
pool = new Pool({
|
||||||
|
connectionString: appConfig.getDatabaseUrl(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Table Structure', () => {
|
||||||
|
it('should have audit_logs table with correct columns', async () => {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT column_name, data_type, is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'audit_logs'
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
`);
|
||||||
|
|
||||||
|
const columns = result.rows.map((row) => row.column_name);
|
||||||
|
expect(columns).toContain('id');
|
||||||
|
expect(columns).toContain('category');
|
||||||
|
expect(columns).toContain('severity');
|
||||||
|
expect(columns).toContain('user_id');
|
||||||
|
expect(columns).toContain('action');
|
||||||
|
expect(columns).toContain('resource_type');
|
||||||
|
expect(columns).toContain('resource_id');
|
||||||
|
expect(columns).toContain('details');
|
||||||
|
expect(columns).toContain('created_at');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CHECK Constraints', () => {
|
||||||
|
it('should accept valid category values', async () => {
|
||||||
|
const validCategories = ['auth', 'vehicle', 'user', 'system', 'admin'];
|
||||||
|
|
||||||
|
for (const category of validCategories) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO audit_logs (category, severity, action)
|
||||||
|
VALUES ($1, 'info', 'test action')
|
||||||
|
RETURNING id`,
|
||||||
|
[category]
|
||||||
|
);
|
||||||
|
expect(result.rows[0].id).toBeDefined();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await pool.query('DELETE FROM audit_logs WHERE id = $1', [result.rows[0].id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid category values', async () => {
|
||||||
|
await expect(
|
||||||
|
pool.query(
|
||||||
|
`INSERT INTO audit_logs (category, severity, action)
|
||||||
|
VALUES ('invalid', 'info', 'test action')`
|
||||||
|
)
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept valid severity values', async () => {
|
||||||
|
const validSeverities = ['info', 'warning', 'error'];
|
||||||
|
|
||||||
|
for (const severity of validSeverities) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO audit_logs (category, severity, action)
|
||||||
|
VALUES ('auth', $1, 'test action')
|
||||||
|
RETURNING id`,
|
||||||
|
[severity]
|
||||||
|
);
|
||||||
|
expect(result.rows[0].id).toBeDefined();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await pool.query('DELETE FROM audit_logs WHERE id = $1', [result.rows[0].id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid severity values', async () => {
|
||||||
|
await expect(
|
||||||
|
pool.query(
|
||||||
|
`INSERT INTO audit_logs (category, severity, action)
|
||||||
|
VALUES ('auth', 'invalid', 'test action')`
|
||||||
|
)
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Nullable Columns', () => {
|
||||||
|
it('should allow NULL user_id for system actions', async () => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO audit_logs (category, severity, user_id, action)
|
||||||
|
VALUES ('system', 'info', NULL, 'system startup')
|
||||||
|
RETURNING id, user_id`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.rows[0].id).toBeDefined();
|
||||||
|
expect(result.rows[0].user_id).toBeNull();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await pool.query('DELETE FROM audit_logs WHERE id = $1', [result.rows[0].id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Indexes', () => {
|
||||||
|
it('should have required indexes', async () => {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT indexname
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE tablename = 'audit_logs'
|
||||||
|
`);
|
||||||
|
|
||||||
|
const indexNames = result.rows.map((row) => row.indexname);
|
||||||
|
expect(indexNames).toContain('idx_audit_logs_category_created');
|
||||||
|
expect(indexNames).toContain('idx_audit_logs_severity_created');
|
||||||
|
expect(indexNames).toContain('idx_audit_logs_user_created');
|
||||||
|
expect(indexNames).toContain('idx_audit_logs_created');
|
||||||
|
expect(indexNames).toContain('idx_audit_logs_action_gin');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
154
backend/src/features/audit-log/api/audit-log.controller.ts
Normal file
154
backend/src/features/audit-log/api/audit-log.controller.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Fastify route handlers for audit log API
|
||||||
|
* @ai-context HTTP request/response handling for audit log search and export
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { AuditLogService } from '../domain/audit-log.service';
|
||||||
|
import { AuditLogRepository } from '../data/audit-log.repository';
|
||||||
|
import { AuditLogFilters, isValidCategory, isValidSeverity } from '../domain/audit-log.types';
|
||||||
|
import { pool } from '../../../core/config/database';
|
||||||
|
import { logger } from '../../../core/logging/logger';
|
||||||
|
|
||||||
|
interface AuditLogsQuery {
|
||||||
|
search?: string;
|
||||||
|
category?: string;
|
||||||
|
severity?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
limit?: string;
|
||||||
|
offset?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuditLogController {
|
||||||
|
private service: AuditLogService;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const repository = new AuditLogRepository(pool);
|
||||||
|
this.service = new AuditLogService(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/audit-logs - Search audit logs with filters
|
||||||
|
*/
|
||||||
|
async getAuditLogs(
|
||||||
|
request: FastifyRequest<{ Querystring: AuditLogsQuery }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { search, category, severity, startDate, endDate, limit, offset } = request.query;
|
||||||
|
|
||||||
|
// Validate category if provided
|
||||||
|
if (category && !isValidCategory(category)) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: `Invalid category: ${category}. Valid values: auth, vehicle, user, system, admin`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate severity if provided
|
||||||
|
if (severity && !isValidSeverity(severity)) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: `Invalid severity: ${severity}. Valid values: info, warning, error`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: AuditLogFilters = {
|
||||||
|
search,
|
||||||
|
category: category as AuditLogFilters['category'],
|
||||||
|
severity: severity as AuditLogFilters['severity'],
|
||||||
|
startDate: startDate ? new Date(startDate) : undefined,
|
||||||
|
endDate: endDate ? new Date(endDate) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pagination = {
|
||||||
|
limit: Math.min(parseInt(limit || '50', 10), 100),
|
||||||
|
offset: parseInt(offset || '0', 10),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await this.service.search(filters, pagination);
|
||||||
|
|
||||||
|
return reply.send(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching audit logs', { error });
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Failed to fetch audit logs',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/audit-logs/export - Export audit logs as CSV
|
||||||
|
*/
|
||||||
|
async exportAuditLogs(
|
||||||
|
request: FastifyRequest<{ Querystring: AuditLogsQuery }>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { search, category, severity, startDate, endDate } = request.query;
|
||||||
|
|
||||||
|
// Validate category if provided
|
||||||
|
if (category && !isValidCategory(category)) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: `Invalid category: ${category}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate severity if provided
|
||||||
|
if (severity && !isValidSeverity(severity)) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: `Invalid severity: ${severity}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: AuditLogFilters = {
|
||||||
|
search,
|
||||||
|
category: category as AuditLogFilters['category'],
|
||||||
|
severity: severity as AuditLogFilters['severity'],
|
||||||
|
startDate: startDate ? new Date(startDate) : undefined,
|
||||||
|
endDate: endDate ? new Date(endDate) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { logs, truncated } = await this.service.getForExport(filters);
|
||||||
|
|
||||||
|
// Generate CSV
|
||||||
|
const headers = ['ID', 'Timestamp', 'Category', 'Severity', 'User ID', 'Action', 'Resource Type', 'Resource ID'];
|
||||||
|
const rows = logs.map((log) => [
|
||||||
|
log.id,
|
||||||
|
log.createdAt.toISOString(),
|
||||||
|
log.category,
|
||||||
|
log.severity,
|
||||||
|
log.userId || '',
|
||||||
|
`"${log.action.replace(/"/g, '""')}"`, // Escape quotes in CSV
|
||||||
|
log.resourceType || '',
|
||||||
|
log.resourceId || '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csv = [headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
|
||||||
|
|
||||||
|
// Set headers for file download
|
||||||
|
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
reply.header('Content-Type', 'text/csv');
|
||||||
|
reply.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
|
||||||
|
// Warn if results were truncated
|
||||||
|
if (truncated) {
|
||||||
|
reply.header('X-Export-Truncated', 'true');
|
||||||
|
reply.header('X-Export-Limit', '5000');
|
||||||
|
logger.warn('Audit log export was truncated', { exportedCount: logs.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send(csv);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error exporting audit logs', { error });
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Failed to export audit logs',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
backend/src/features/audit-log/api/audit-log.routes.ts
Normal file
50
backend/src/features/audit-log/api/audit-log.routes.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Audit log feature routes
|
||||||
|
* @ai-context Registers audit log API endpoints with admin authorization
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { AuditLogController } from './audit-log.controller';
|
||||||
|
|
||||||
|
interface AuditLogsQuery {
|
||||||
|
search?: string;
|
||||||
|
category?: string;
|
||||||
|
severity?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
limit?: string;
|
||||||
|
offset?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auditLogRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
|
const controller = new AuditLogController();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/audit-logs
|
||||||
|
* Search audit logs with filters and pagination
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - search: Text search on action field
|
||||||
|
* - category: Filter by category (auth, vehicle, user, system, admin)
|
||||||
|
* - severity: Filter by severity (info, warning, error)
|
||||||
|
* - startDate: Filter by start date (ISO string)
|
||||||
|
* - endDate: Filter by end date (ISO string)
|
||||||
|
* - limit: Number of results (default 50, max 100)
|
||||||
|
* - offset: Pagination offset
|
||||||
|
*/
|
||||||
|
fastify.get<{ Querystring: AuditLogsQuery }>('/admin/audit-logs', {
|
||||||
|
preHandler: [fastify.requireAdmin],
|
||||||
|
handler: controller.getAuditLogs.bind(controller),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/audit-logs/export
|
||||||
|
* Export filtered audit logs as CSV file
|
||||||
|
*
|
||||||
|
* Query params: same as /admin/audit-logs
|
||||||
|
*/
|
||||||
|
fastify.get<{ Querystring: AuditLogsQuery }>('/admin/audit-logs/export', {
|
||||||
|
preHandler: [fastify.requireAdmin],
|
||||||
|
handler: controller.exportAuditLogs.bind(controller),
|
||||||
|
});
|
||||||
|
};
|
||||||
14
backend/src/features/audit-log/audit-log.instance.ts
Normal file
14
backend/src/features/audit-log/audit-log.instance.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Singleton audit log service instance
|
||||||
|
* @ai-context Provides centralized audit logging across all features
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pool } from '../../core/config/database';
|
||||||
|
import { AuditLogRepository } from './data/audit-log.repository';
|
||||||
|
import { AuditLogService } from './domain/audit-log.service';
|
||||||
|
|
||||||
|
// Create singleton repository and service instances
|
||||||
|
const repository = new AuditLogRepository(pool);
|
||||||
|
export const auditLogService = new AuditLogService(repository);
|
||||||
|
|
||||||
|
export default auditLogService;
|
||||||
240
backend/src/features/audit-log/data/audit-log.repository.ts
Normal file
240
backend/src/features/audit-log/data/audit-log.repository.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Audit log data access layer
|
||||||
|
* @ai-context Provides parameterized SQL queries for audit log operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import {
|
||||||
|
AuditLogEntry,
|
||||||
|
CreateAuditLogInput,
|
||||||
|
AuditLogFilters,
|
||||||
|
AuditLogPagination,
|
||||||
|
AuditLogSearchResult,
|
||||||
|
} from '../domain/audit-log.types';
|
||||||
|
import { logger } from '../../../core/logging/logger';
|
||||||
|
|
||||||
|
// Maximum records for CSV export to prevent memory exhaustion
|
||||||
|
const MAX_EXPORT_RECORDS = 5000;
|
||||||
|
|
||||||
|
export class AuditLogRepository {
|
||||||
|
constructor(private pool: Pool) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape LIKE special characters to prevent pattern injection
|
||||||
|
*/
|
||||||
|
private escapeLikePattern(pattern: string): string {
|
||||||
|
return pattern.replace(/[%_\\]/g, (match) => `\\${match}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build WHERE clause from filters (shared logic for search and export)
|
||||||
|
*/
|
||||||
|
private buildWhereClause(filters: AuditLogFilters): {
|
||||||
|
whereClause: string;
|
||||||
|
params: unknown[];
|
||||||
|
nextParamIndex: number;
|
||||||
|
} {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
conditions.push(`al.action ILIKE $${paramIndex}`);
|
||||||
|
params.push(`%${this.escapeLikePattern(filters.search)}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.category) {
|
||||||
|
conditions.push(`al.category = $${paramIndex}`);
|
||||||
|
params.push(filters.category);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.severity) {
|
||||||
|
conditions.push(`al.severity = $${paramIndex}`);
|
||||||
|
params.push(filters.severity);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.userId) {
|
||||||
|
conditions.push(`al.user_id = $${paramIndex}`);
|
||||||
|
params.push(filters.userId);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.startDate) {
|
||||||
|
conditions.push(`al.created_at >= $${paramIndex}`);
|
||||||
|
params.push(filters.startDate);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
conditions.push(`al.created_at <= $${paramIndex}`);
|
||||||
|
params.push(filters.endDate);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
return { whereClause, params, nextParamIndex: paramIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new audit log entry
|
||||||
|
*/
|
||||||
|
async create(input: CreateAuditLogInput): Promise<AuditLogEntry> {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO audit_logs (category, severity, user_id, action, resource_type, resource_id, details)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, category, severity, user_id, action, resource_type, resource_id, details, created_at,
|
||||||
|
NULL::text as user_email
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.pool.query(query, [
|
||||||
|
input.category,
|
||||||
|
input.severity,
|
||||||
|
input.userId || null,
|
||||||
|
input.action,
|
||||||
|
input.resourceType || null,
|
||||||
|
input.resourceId || null,
|
||||||
|
input.details ? JSON.stringify(input.details) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return this.mapRow(result.rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating audit log', { error, input });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search audit logs with filters and pagination
|
||||||
|
*/
|
||||||
|
async search(
|
||||||
|
filters: AuditLogFilters,
|
||||||
|
pagination: AuditLogPagination
|
||||||
|
): Promise<AuditLogSearchResult> {
|
||||||
|
const { whereClause, params, nextParamIndex } = this.buildWhereClause(filters);
|
||||||
|
|
||||||
|
// Count query
|
||||||
|
const countQuery = `SELECT COUNT(*) as total FROM audit_logs al ${whereClause}`;
|
||||||
|
|
||||||
|
// Data query with pagination - LEFT JOIN to get user email
|
||||||
|
const dataQuery = `
|
||||||
|
SELECT al.id, al.category, al.severity, al.user_id, al.action,
|
||||||
|
al.resource_type, al.resource_id, al.details, al.created_at,
|
||||||
|
up.email as user_email
|
||||||
|
FROM audit_logs al
|
||||||
|
LEFT JOIN user_profiles up ON al.user_id = up.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT $${nextParamIndex} OFFSET $${nextParamIndex + 1}
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [countResult, dataResult] = await Promise.all([
|
||||||
|
this.pool.query(countQuery, params),
|
||||||
|
this.pool.query(dataQuery, [...params, pagination.limit, pagination.offset]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(countResult.rows[0].total, 10);
|
||||||
|
const logs = dataResult.rows.map((row) => this.mapRow(row));
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs,
|
||||||
|
total,
|
||||||
|
limit: pagination.limit,
|
||||||
|
offset: pagination.offset,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error searching audit logs', { error, filters, pagination });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all logs matching filters for CSV export (limited to prevent memory exhaustion)
|
||||||
|
*/
|
||||||
|
async getForExport(filters: AuditLogFilters): Promise<{ logs: AuditLogEntry[]; truncated: boolean }> {
|
||||||
|
const { whereClause, params } = this.buildWhereClause(filters);
|
||||||
|
|
||||||
|
// First, count total matching records
|
||||||
|
const countQuery = `SELECT COUNT(*) as total FROM audit_logs al ${whereClause}`;
|
||||||
|
const countResult = await this.pool.query(countQuery, params);
|
||||||
|
const totalCount = parseInt(countResult.rows[0].total, 10);
|
||||||
|
const truncated = totalCount > MAX_EXPORT_RECORDS;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT al.id, al.category, al.severity, al.user_id, al.action,
|
||||||
|
al.resource_type, al.resource_id, al.details, al.created_at,
|
||||||
|
up.email as user_email
|
||||||
|
FROM audit_logs al
|
||||||
|
LEFT JOIN user_profiles up ON al.user_id = up.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT ${MAX_EXPORT_RECORDS}
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.pool.query(query, params);
|
||||||
|
const logs = result.rows.map((row) => this.mapRow(row));
|
||||||
|
|
||||||
|
if (truncated) {
|
||||||
|
logger.warn('Audit log export truncated', {
|
||||||
|
totalCount,
|
||||||
|
exportedCount: logs.length,
|
||||||
|
limit: MAX_EXPORT_RECORDS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { logs, truncated };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error exporting audit logs', { error, filters });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete logs older than specified days (retention cleanup)
|
||||||
|
*/
|
||||||
|
async cleanup(olderThanDays: number): Promise<number> {
|
||||||
|
const query = `
|
||||||
|
DELETE FROM audit_logs
|
||||||
|
WHERE created_at < NOW() - INTERVAL '1 day' * $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.pool.query(query, [olderThanDays]);
|
||||||
|
const deletedCount = result.rowCount || 0;
|
||||||
|
|
||||||
|
logger.info('Audit log cleanup completed', {
|
||||||
|
olderThanDays,
|
||||||
|
deletedCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error cleaning up audit logs', { error, olderThanDays });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map database row to AuditLogEntry (snake_case to camelCase)
|
||||||
|
*/
|
||||||
|
private mapRow(row: Record<string, unknown>): AuditLogEntry {
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
category: row.category as AuditLogEntry['category'],
|
||||||
|
severity: row.severity as AuditLogEntry['severity'],
|
||||||
|
userId: row.user_id as string | null,
|
||||||
|
userEmail: (row.user_email as string | null) || null,
|
||||||
|
action: row.action as string,
|
||||||
|
resourceType: row.resource_type as string | null,
|
||||||
|
resourceId: row.resource_id as string | null,
|
||||||
|
details: row.details as Record<string, unknown> | null,
|
||||||
|
createdAt: new Date(row.created_at as string),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
163
backend/src/features/audit-log/domain/audit-log.service.ts
Normal file
163
backend/src/features/audit-log/domain/audit-log.service.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Centralized audit logging service
|
||||||
|
* @ai-context Provides simple API for all features to log audit events
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AuditLogRepository } from '../data/audit-log.repository';
|
||||||
|
import {
|
||||||
|
AuditLogCategory,
|
||||||
|
AuditLogSeverity,
|
||||||
|
AuditLogEntry,
|
||||||
|
AuditLogFilters,
|
||||||
|
AuditLogPagination,
|
||||||
|
AuditLogSearchResult,
|
||||||
|
isValidCategory,
|
||||||
|
isValidSeverity,
|
||||||
|
} from './audit-log.types';
|
||||||
|
import { logger } from '../../../core/logging/logger';
|
||||||
|
|
||||||
|
export class AuditLogService {
|
||||||
|
constructor(private repository: AuditLogRepository) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an audit event
|
||||||
|
*
|
||||||
|
* @param category - Event category (auth, vehicle, user, system, admin)
|
||||||
|
* @param severity - Event severity (info, warning, error)
|
||||||
|
* @param userId - User who performed the action (null for system actions)
|
||||||
|
* @param action - Human-readable description of the action
|
||||||
|
* @param resourceType - Type of resource affected (optional)
|
||||||
|
* @param resourceId - ID of affected resource (optional)
|
||||||
|
* @param details - Additional structured data (optional)
|
||||||
|
*/
|
||||||
|
async log(
|
||||||
|
category: AuditLogCategory,
|
||||||
|
severity: AuditLogSeverity,
|
||||||
|
userId: string | null,
|
||||||
|
action: string,
|
||||||
|
resourceType?: string | null,
|
||||||
|
resourceId?: string | null,
|
||||||
|
details?: Record<string, unknown> | null
|
||||||
|
): Promise<AuditLogEntry> {
|
||||||
|
// Validate category
|
||||||
|
if (!isValidCategory(category)) {
|
||||||
|
const error = new Error(`Invalid audit log category: ${category}`);
|
||||||
|
logger.error('Invalid audit log category', { category });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate severity
|
||||||
|
if (!isValidSeverity(severity)) {
|
||||||
|
const error = new Error(`Invalid audit log severity: ${severity}`);
|
||||||
|
logger.error('Invalid audit log severity', { severity });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entry = await this.repository.create({
|
||||||
|
category,
|
||||||
|
severity,
|
||||||
|
userId,
|
||||||
|
action,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('Audit log created', {
|
||||||
|
id: entry.id,
|
||||||
|
category,
|
||||||
|
severity,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating audit log', { error, category, action });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for info-level logs
|
||||||
|
*/
|
||||||
|
async info(
|
||||||
|
category: AuditLogCategory,
|
||||||
|
userId: string | null,
|
||||||
|
action: string,
|
||||||
|
resourceType?: string | null,
|
||||||
|
resourceId?: string | null,
|
||||||
|
details?: Record<string, unknown> | null
|
||||||
|
): Promise<AuditLogEntry> {
|
||||||
|
return this.log(category, 'info', userId, action, resourceType, resourceId, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for warning-level logs
|
||||||
|
*/
|
||||||
|
async warning(
|
||||||
|
category: AuditLogCategory,
|
||||||
|
userId: string | null,
|
||||||
|
action: string,
|
||||||
|
resourceType?: string | null,
|
||||||
|
resourceId?: string | null,
|
||||||
|
details?: Record<string, unknown> | null
|
||||||
|
): Promise<AuditLogEntry> {
|
||||||
|
return this.log(category, 'warning', userId, action, resourceType, resourceId, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for error-level logs
|
||||||
|
*/
|
||||||
|
async error(
|
||||||
|
category: AuditLogCategory,
|
||||||
|
userId: string | null,
|
||||||
|
action: string,
|
||||||
|
resourceType?: string | null,
|
||||||
|
resourceId?: string | null,
|
||||||
|
details?: Record<string, unknown> | null
|
||||||
|
): Promise<AuditLogEntry> {
|
||||||
|
return this.log(category, 'error', userId, action, resourceType, resourceId, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search audit logs with filters and pagination
|
||||||
|
*/
|
||||||
|
async search(
|
||||||
|
filters: AuditLogFilters,
|
||||||
|
pagination: AuditLogPagination
|
||||||
|
): Promise<AuditLogSearchResult> {
|
||||||
|
try {
|
||||||
|
return await this.repository.search(filters, pagination);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error searching audit logs', { error, filters });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get logs for CSV export (limited to 5000 records)
|
||||||
|
*/
|
||||||
|
async getForExport(filters: AuditLogFilters): Promise<{ logs: AuditLogEntry[]; truncated: boolean }> {
|
||||||
|
try {
|
||||||
|
return await this.repository.getForExport(filters);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting audit logs for export', { error, filters });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run retention cleanup (delete logs older than specified days)
|
||||||
|
*/
|
||||||
|
async cleanup(olderThanDays: number = 90): Promise<number> {
|
||||||
|
try {
|
||||||
|
const deletedCount = await this.repository.cleanup(olderThanDays);
|
||||||
|
logger.info('Audit log cleanup completed', { olderThanDays, deletedCount });
|
||||||
|
return deletedCount;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error running audit log cleanup', { error, olderThanDays });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
backend/src/features/audit-log/domain/audit-log.types.ts
Normal file
107
backend/src/features/audit-log/domain/audit-log.types.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Type definitions for centralized audit logging
|
||||||
|
* @ai-context Categories, severity levels, log entries, and filter options
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit log categories - maps to system domains
|
||||||
|
*/
|
||||||
|
export type AuditLogCategory = 'auth' | 'vehicle' | 'user' | 'system' | 'admin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit log severity levels
|
||||||
|
*/
|
||||||
|
export type AuditLogSeverity = 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit log entry as stored in database
|
||||||
|
*/
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: string;
|
||||||
|
category: AuditLogCategory;
|
||||||
|
severity: AuditLogSeverity;
|
||||||
|
userId: string | null;
|
||||||
|
userEmail: string | null;
|
||||||
|
action: string;
|
||||||
|
resourceType: string | null;
|
||||||
|
resourceId: string | null;
|
||||||
|
details: Record<string, unknown> | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input for creating a new audit log entry
|
||||||
|
*/
|
||||||
|
export interface CreateAuditLogInput {
|
||||||
|
category: AuditLogCategory;
|
||||||
|
severity: AuditLogSeverity;
|
||||||
|
userId?: string | null;
|
||||||
|
action: string;
|
||||||
|
resourceType?: string | null;
|
||||||
|
resourceId?: string | null;
|
||||||
|
details?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters for querying audit logs
|
||||||
|
*/
|
||||||
|
export interface AuditLogFilters {
|
||||||
|
search?: string;
|
||||||
|
category?: AuditLogCategory;
|
||||||
|
severity?: AuditLogSeverity;
|
||||||
|
userId?: string;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination options for audit log queries
|
||||||
|
*/
|
||||||
|
export interface AuditLogPagination {
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated result set for audit logs
|
||||||
|
*/
|
||||||
|
export interface AuditLogSearchResult {
|
||||||
|
logs: AuditLogEntry[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid category values for validation
|
||||||
|
*/
|
||||||
|
export const AUDIT_LOG_CATEGORIES: readonly AuditLogCategory[] = [
|
||||||
|
'auth',
|
||||||
|
'vehicle',
|
||||||
|
'user',
|
||||||
|
'system',
|
||||||
|
'admin',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid severity values for validation
|
||||||
|
*/
|
||||||
|
export const AUDIT_LOG_SEVERITIES: readonly AuditLogSeverity[] = [
|
||||||
|
'info',
|
||||||
|
'warning',
|
||||||
|
'error',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard for category validation
|
||||||
|
*/
|
||||||
|
export function isValidCategory(value: string): value is AuditLogCategory {
|
||||||
|
return AUDIT_LOG_CATEGORIES.includes(value as AuditLogCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard for severity validation
|
||||||
|
*/
|
||||||
|
export function isValidSeverity(value: string): value is AuditLogSeverity {
|
||||||
|
return AUDIT_LOG_SEVERITIES.includes(value as AuditLogSeverity);
|
||||||
|
}
|
||||||
28
backend/src/features/audit-log/index.ts
Normal file
28
backend/src/features/audit-log/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Audit log feature exports
|
||||||
|
* @ai-context Re-exports types, service, and repository for external use
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export {
|
||||||
|
AuditLogCategory,
|
||||||
|
AuditLogSeverity,
|
||||||
|
AuditLogEntry,
|
||||||
|
CreateAuditLogInput,
|
||||||
|
AuditLogFilters,
|
||||||
|
AuditLogPagination,
|
||||||
|
AuditLogSearchResult,
|
||||||
|
AUDIT_LOG_CATEGORIES,
|
||||||
|
AUDIT_LOG_SEVERITIES,
|
||||||
|
isValidCategory,
|
||||||
|
isValidSeverity,
|
||||||
|
} from './domain/audit-log.types';
|
||||||
|
|
||||||
|
// Service
|
||||||
|
export { AuditLogService } from './domain/audit-log.service';
|
||||||
|
|
||||||
|
// Repository
|
||||||
|
export { AuditLogRepository } from './data/audit-log.repository';
|
||||||
|
|
||||||
|
// Singleton instance for cross-feature use
|
||||||
|
export { auditLogService } from './audit-log.instance';
|
||||||
74
backend/src/features/audit-log/jobs/cleanup.job.ts
Normal file
74
backend/src/features/audit-log/jobs/cleanup.job.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Job for audit log retention cleanup
|
||||||
|
* @ai-context Runs daily at 3 AM to delete logs older than 90 days
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import { logger } from '../../../core/logging/logger';
|
||||||
|
import { AuditLogService } from '../domain/audit-log.service';
|
||||||
|
import { AuditLogRepository } from '../data/audit-log.repository';
|
||||||
|
|
||||||
|
let pool: Pool | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the database pool for the job
|
||||||
|
*/
|
||||||
|
export function setAuditLogCleanupJobPool(dbPool: Pool): void {
|
||||||
|
pool = dbPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retention period in days for audit logs
|
||||||
|
*/
|
||||||
|
const AUDIT_LOG_RETENTION_DAYS = 90;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of cleanup job
|
||||||
|
*/
|
||||||
|
export interface AuditLogCleanupResult {
|
||||||
|
deletedCount: number;
|
||||||
|
retentionDays: number;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes audit log retention cleanup
|
||||||
|
*/
|
||||||
|
export async function processAuditLogCleanup(): Promise<AuditLogCleanupResult> {
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not initialized for audit log cleanup job');
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = new AuditLogRepository(pool);
|
||||||
|
const service = new AuditLogService(repository);
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info('Starting audit log cleanup job', {
|
||||||
|
retentionDays: AUDIT_LOG_RETENTION_DAYS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletedCount = await service.cleanup(AUDIT_LOG_RETENTION_DAYS);
|
||||||
|
|
||||||
|
logger.info('Audit log cleanup job completed', {
|
||||||
|
deletedCount,
|
||||||
|
retentionDays: AUDIT_LOG_RETENTION_DAYS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletedCount,
|
||||||
|
retentionDays: AUDIT_LOG_RETENTION_DAYS,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('Audit log cleanup job failed', { error: errorMessage });
|
||||||
|
|
||||||
|
return {
|
||||||
|
deletedCount: 0,
|
||||||
|
retentionDays: AUDIT_LOG_RETENTION_DAYS,
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Migration: Create audit_logs table for centralized audit logging
|
||||||
|
-- Categories: auth, vehicle, user, system, admin
|
||||||
|
-- Severity levels: info, warning, error
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
category VARCHAR(20) NOT NULL CHECK (category IN ('auth', 'vehicle', 'user', 'system', 'admin')),
|
||||||
|
severity VARCHAR(10) NOT NULL CHECK (severity IN ('info', 'warning', 'error')),
|
||||||
|
user_id VARCHAR(255),
|
||||||
|
action VARCHAR(500) NOT NULL,
|
||||||
|
resource_type VARCHAR(100),
|
||||||
|
resource_id VARCHAR(255),
|
||||||
|
details JSONB,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- B-tree indexes for filtered queries
|
||||||
|
CREATE INDEX idx_audit_logs_category_created ON audit_logs(category, created_at DESC);
|
||||||
|
CREATE INDEX idx_audit_logs_severity_created ON audit_logs(severity, created_at DESC);
|
||||||
|
CREATE INDEX idx_audit_logs_user_created ON audit_logs(user_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_audit_logs_created ON audit_logs(created_at DESC);
|
||||||
|
|
||||||
|
-- GIN index for text search on action column (requires pg_trgm extension)
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
CREATE INDEX idx_audit_logs_action_gin ON audit_logs USING gin (action gin_trgm_ops);
|
||||||
|
|
||||||
|
-- Comment for documentation
|
||||||
|
COMMENT ON TABLE audit_logs IS 'Centralized audit log for all system events across categories';
|
||||||
|
COMMENT ON COLUMN audit_logs.category IS 'Event category: auth, vehicle, user, system, admin';
|
||||||
|
COMMENT ON COLUMN audit_logs.severity IS 'Event severity: info, warning, error';
|
||||||
|
COMMENT ON COLUMN audit_logs.user_id IS 'User who performed the action (null for system actions)';
|
||||||
|
COMMENT ON COLUMN audit_logs.action IS 'Human-readable description of the action';
|
||||||
|
COMMENT ON COLUMN audit_logs.resource_type IS 'Type of resource affected (e.g., vehicle, backup)';
|
||||||
|
COMMENT ON COLUMN audit_logs.resource_id IS 'ID of the affected resource';
|
||||||
|
COMMENT ON COLUMN audit_logs.details IS 'Additional structured data about the event';
|
||||||
16
backend/src/features/auth/CLAUDE.md
Normal file
16
backend/src/features/auth/CLAUDE.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# auth/
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What | When to read |
|
||||||
|
| ---- | ---- | ------------ |
|
||||||
|
| `README.md` | Feature documentation | Understanding auth flow |
|
||||||
|
| `index.ts` | Feature barrel export | Importing auth services |
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
|
||||||
|
| Directory | What | When to read |
|
||||||
|
| --------- | ---- | ------------ |
|
||||||
|
| `api/` | HTTP endpoints and routes | API changes |
|
||||||
|
| `domain/` | Business logic, services, types | Core auth logic |
|
||||||
|
| `tests/` | Unit and integration tests | Adding or modifying tests |
|
||||||
@@ -11,6 +11,7 @@ import { termsConfig } from '../../terms-agreement/domain/terms-config';
|
|||||||
import { pool } from '../../../core/config/database';
|
import { pool } from '../../../core/config/database';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
import { signupSchema, resendVerificationPublicSchema } from './auth.validation';
|
import { signupSchema, resendVerificationPublicSchema } from './auth.validation';
|
||||||
|
import { auditLogService } from '../../audit-log';
|
||||||
|
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
private authService: AuthService;
|
private authService: AuthService;
|
||||||
@@ -66,6 +67,16 @@ export class AuthController {
|
|||||||
|
|
||||||
logger.info('User signup successful', { email, userId: result.userId });
|
logger.info('User signup successful', { email, userId: result.userId });
|
||||||
|
|
||||||
|
// Log signup to unified audit log
|
||||||
|
await auditLogService.info(
|
||||||
|
'auth',
|
||||||
|
result.userId,
|
||||||
|
`User signup: ${email}`,
|
||||||
|
'user',
|
||||||
|
result.userId,
|
||||||
|
{ email, ipAddress: termsData.ipAddress }
|
||||||
|
).catch(err => logger.error('Failed to log signup audit event', { error: err }));
|
||||||
|
|
||||||
return reply.code(201).send(result);
|
return reply.code(201).send(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Signup failed', { error, email: (request.body as any)?.email });
|
logger.error('Signup failed', { error, email: (request.body as any)?.email });
|
||||||
@@ -99,17 +110,17 @@ export class AuthController {
|
|||||||
*/
|
*/
|
||||||
async getVerifyStatus(request: FastifyRequest, reply: FastifyReply) {
|
async getVerifyStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const auth0Sub = (request as any).user.sub;
|
||||||
|
|
||||||
const result = await this.authService.getVerifyStatus(userId);
|
const result = await this.authService.getVerifyStatus(auth0Sub);
|
||||||
|
|
||||||
logger.info('Verification status checked', { userId, emailVerified: result.emailVerified });
|
logger.info('Verification status checked', { userId: request.userContext?.userId, emailVerified: result.emailVerified });
|
||||||
|
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get verification status', {
|
logger.error('Failed to get verification status', {
|
||||||
error,
|
error,
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -126,17 +137,17 @@ export class AuthController {
|
|||||||
*/
|
*/
|
||||||
async resendVerification(request: FastifyRequest, reply: FastifyReply) {
|
async resendVerification(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const auth0Sub = (request as any).user.sub;
|
||||||
|
|
||||||
const result = await this.authService.resendVerification(userId);
|
const result = await this.authService.resendVerification(auth0Sub);
|
||||||
|
|
||||||
logger.info('Verification email resent', { userId });
|
logger.info('Verification email resent', { userId: request.userContext?.userId });
|
||||||
|
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to resend verification email', {
|
logger.error('Failed to resend verification email', {
|
||||||
error,
|
error,
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -182,15 +193,32 @@ export class AuthController {
|
|||||||
* GET /api/auth/user-status
|
* GET /api/auth/user-status
|
||||||
* Get user status for routing decisions
|
* Get user status for routing decisions
|
||||||
* Protected endpoint - requires JWT
|
* Protected endpoint - requires JWT
|
||||||
|
*
|
||||||
|
* Note: This endpoint is called once per Auth0 callback (from CallbackPage/CallbackMobileScreen).
|
||||||
|
* We log the login event here since it's the first authenticated request after Auth0 redirect.
|
||||||
*/
|
*/
|
||||||
async getUserStatus(request: FastifyRequest, reply: FastifyReply) {
|
async getUserStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const auth0Sub = (request as any).user.sub;
|
||||||
|
const userId = request.userContext?.userId;
|
||||||
|
|
||||||
const result = await this.authService.getUserStatus(userId);
|
const result = await this.authService.getUserStatus(auth0Sub);
|
||||||
|
|
||||||
|
// Log login event to audit trail (called once per Auth0 callback)
|
||||||
|
const ipAddress = this.getClientIp(request);
|
||||||
|
if (userId) {
|
||||||
|
await auditLogService.info(
|
||||||
|
'auth',
|
||||||
|
userId,
|
||||||
|
'User login',
|
||||||
|
'user',
|
||||||
|
userId,
|
||||||
|
{ ipAddress }
|
||||||
|
).catch(err => logger.error('Failed to log login audit event', { error: err }));
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('User status retrieved', {
|
logger.info('User status retrieved', {
|
||||||
userId: userId.substring(0, 8) + '...',
|
userId: userId?.substring(0, 8) + '...',
|
||||||
emailVerified: result.emailVerified,
|
emailVerified: result.emailVerified,
|
||||||
onboardingCompleted: result.onboardingCompleted,
|
onboardingCompleted: result.onboardingCompleted,
|
||||||
});
|
});
|
||||||
@@ -199,7 +227,7 @@ export class AuthController {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get user status', {
|
logger.error('Failed to get user status', {
|
||||||
error,
|
error,
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -216,12 +244,12 @@ export class AuthController {
|
|||||||
*/
|
*/
|
||||||
async getSecurityStatus(request: FastifyRequest, reply: FastifyReply) {
|
async getSecurityStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const auth0Sub = (request as any).user.sub;
|
||||||
|
|
||||||
const result = await this.authService.getSecurityStatus(userId);
|
const result = await this.authService.getSecurityStatus(auth0Sub);
|
||||||
|
|
||||||
logger.info('Security status retrieved', {
|
logger.info('Security status retrieved', {
|
||||||
userId: userId.substring(0, 8) + '...',
|
userId: request.userContext?.userId,
|
||||||
emailVerified: result.emailVerified,
|
emailVerified: result.emailVerified,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,7 +257,7 @@ export class AuthController {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to get security status', {
|
logger.error('Failed to get security status', {
|
||||||
error,
|
error,
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -246,19 +274,31 @@ export class AuthController {
|
|||||||
*/
|
*/
|
||||||
async requestPasswordReset(request: FastifyRequest, reply: FastifyReply) {
|
async requestPasswordReset(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const auth0Sub = (request as any).user.sub;
|
||||||
|
const userId = request.userContext?.userId;
|
||||||
|
|
||||||
const result = await this.authService.requestPasswordReset(userId);
|
const result = await this.authService.requestPasswordReset(auth0Sub);
|
||||||
|
|
||||||
logger.info('Password reset email requested', {
|
logger.info('Password reset email requested', {
|
||||||
userId: userId.substring(0, 8) + '...',
|
userId: userId?.substring(0, 8) + '...',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Log password reset request to unified audit log
|
||||||
|
if (userId) {
|
||||||
|
await auditLogService.info(
|
||||||
|
'auth',
|
||||||
|
userId,
|
||||||
|
'Password reset requested',
|
||||||
|
'user',
|
||||||
|
userId
|
||||||
|
).catch(err => logger.error('Failed to log password reset audit event', { error: err }));
|
||||||
|
}
|
||||||
|
|
||||||
return reply.code(200).send(result);
|
return reply.code(200).send(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Failed to request password reset', {
|
logger.error('Failed to request password reset', {
|
||||||
error,
|
error,
|
||||||
userId: (request as any).user?.sub,
|
userId: request.userContext?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
@@ -267,4 +307,45 @@ export class AuthController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/track-logout
|
||||||
|
* Track user logout event for audit logging
|
||||||
|
* Protected endpoint - requires JWT
|
||||||
|
*
|
||||||
|
* Called by frontend before Auth0 logout to capture the logout event.
|
||||||
|
* Returns success even if audit logging fails (non-blocking).
|
||||||
|
*/
|
||||||
|
async trackLogout(request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const userId = request.userContext?.userId;
|
||||||
|
const ipAddress = this.getClientIp(request);
|
||||||
|
|
||||||
|
// Log logout event to audit trail
|
||||||
|
if (userId) {
|
||||||
|
await auditLogService.info(
|
||||||
|
'auth',
|
||||||
|
userId,
|
||||||
|
'User logout',
|
||||||
|
'user',
|
||||||
|
userId,
|
||||||
|
{ ipAddress }
|
||||||
|
).catch(err => logger.error('Failed to log logout audit event', { error: err }));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('User logout tracked', {
|
||||||
|
userId: userId?.substring(0, 8) + '...',
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
// Don't block logout on audit failure - always return success
|
||||||
|
logger.error('Failed to track logout', {
|
||||||
|
error,
|
||||||
|
userId: request.userContext?.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,4 +48,10 @@ export const authRoutes: FastifyPluginAsync = async (
|
|||||||
preHandler: [fastify.authenticate],
|
preHandler: [fastify.authenticate],
|
||||||
handler: authController.requestPasswordReset.bind(authController),
|
handler: authController.requestPasswordReset.bind(authController),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/track-logout - Track logout event for audit (requires JWT)
|
||||||
|
fastify.post('/auth/track-logout', {
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
handler: authController.trackLogout.bind(authController),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ jest.mock('../../../../core/plugins/auth.plugin', () => {
|
|||||||
return {
|
return {
|
||||||
default: fastifyPlugin(async function (fastify) {
|
default: fastifyPlugin(async function (fastify) {
|
||||||
fastify.decorate('authenticate', async function (request, _reply) {
|
fastify.decorate('authenticate', async function (request, _reply) {
|
||||||
|
// JWT sub is still auth0|xxx format
|
||||||
request.user = { sub: 'auth0|test-user-123' };
|
request.user = { sub: 'auth0|test-user-123' };
|
||||||
});
|
});
|
||||||
}, { name: 'auth-plugin' }),
|
}, { name: 'auth-plugin' }),
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ describe('AuthService', () => {
|
|||||||
onboardingCompletedAt: null,
|
onboardingCompletedAt: null,
|
||||||
deactivatedAt: null,
|
deactivatedAt: null,
|
||||||
deactivatedBy: null,
|
deactivatedBy: null,
|
||||||
|
deletionRequestedAt: null,
|
||||||
|
deletionScheduledFor: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
@@ -116,6 +118,8 @@ describe('AuthService', () => {
|
|||||||
onboardingCompletedAt: null,
|
onboardingCompletedAt: null,
|
||||||
deactivatedAt: null,
|
deactivatedAt: null,
|
||||||
deactivatedBy: null,
|
deactivatedBy: null,
|
||||||
|
deletionRequestedAt: null,
|
||||||
|
deletionScheduledFor: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
@@ -149,6 +153,8 @@ describe('AuthService', () => {
|
|||||||
onboardingCompletedAt: null,
|
onboardingCompletedAt: null,
|
||||||
deactivatedAt: null,
|
deactivatedAt: null,
|
||||||
deactivatedBy: null,
|
deactivatedBy: null,
|
||||||
|
deletionRequestedAt: null,
|
||||||
|
deletionScheduledFor: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
# backup/
|
# backup/
|
||||||
|
|
||||||
Complete backup and restore system with tiered retention.
|
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
| File | What | When to read |
|
| File | What | When to read |
|
||||||
@@ -18,13 +16,3 @@ Complete backup and restore system with tiered retention.
|
|||||||
| `jobs/` | Scheduled job handlers | Cron job modifications |
|
| `jobs/` | Scheduled job handlers | Cron job modifications |
|
||||||
| `migrations/` | Database schema | Schema changes |
|
| `migrations/` | Database schema | Schema changes |
|
||||||
| `tests/` | Unit and integration tests | Adding or modifying tests |
|
| `tests/` | Unit and integration tests | Adding or modifying tests |
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
| File | What | When to read |
|
|
||||||
| ---- | ---- | ------------ |
|
|
||||||
| `domain/backup.types.ts` | Types, constants, TIERED_RETENTION | Type definitions |
|
|
||||||
| `domain/backup.service.ts` | Core backup operations | Creating/managing backups |
|
|
||||||
| `domain/backup-classification.service.ts` | Tiered retention classification | Category/expiration logic |
|
|
||||||
| `domain/backup-retention.service.ts` | Retention enforcement | Deletion logic |
|
|
||||||
| `data/backup.repository.ts` | Database queries | Data access patterns |
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ScheduleIdParam,
|
ScheduleIdParam,
|
||||||
UpdateSettingsBody,
|
UpdateSettingsBody,
|
||||||
} from './backup.validation';
|
} from './backup.validation';
|
||||||
|
import { auditLogService } from '../../audit-log';
|
||||||
|
|
||||||
export class BackupController {
|
export class BackupController {
|
||||||
private backupService: BackupService;
|
private backupService: BackupService;
|
||||||
@@ -44,22 +45,42 @@ export class BackupController {
|
|||||||
request: FastifyRequest<{ Body: CreateBackupBody }>,
|
request: FastifyRequest<{ Body: CreateBackupBody }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const adminSub = (request as any).userContext?.auth0Sub;
|
const adminUserId = request.userContext?.userId;
|
||||||
|
|
||||||
const result = await this.backupService.createBackup({
|
const result = await this.backupService.createBackup({
|
||||||
name: request.body.name,
|
name: request.body.name,
|
||||||
backupType: 'manual',
|
backupType: 'manual',
|
||||||
createdBy: adminSub,
|
createdBy: adminUserId,
|
||||||
includeDocuments: request.body.includeDocuments,
|
includeDocuments: request.body.includeDocuments,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// Log backup creation to unified audit log
|
||||||
|
await auditLogService.info(
|
||||||
|
'system',
|
||||||
|
adminUserId || null,
|
||||||
|
`Backup created: ${request.body.name || 'Manual backup'}`,
|
||||||
|
'backup',
|
||||||
|
result.backupId,
|
||||||
|
{ name: request.body.name, includeDocuments: request.body.includeDocuments }
|
||||||
|
).catch(err => logger.error('Failed to log backup create audit event', { error: err }));
|
||||||
|
|
||||||
reply.status(201).send({
|
reply.status(201).send({
|
||||||
backupId: result.backupId,
|
backupId: result.backupId,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
message: 'Backup created successfully',
|
message: 'Backup created successfully',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// Log backup failure
|
||||||
|
await auditLogService.error(
|
||||||
|
'system',
|
||||||
|
adminUserId || null,
|
||||||
|
`Backup failed: ${request.body.name || 'Manual backup'}`,
|
||||||
|
'backup',
|
||||||
|
result.backupId,
|
||||||
|
{ error: result.error }
|
||||||
|
).catch(err => logger.error('Failed to log backup failure audit event', { error: err }));
|
||||||
|
|
||||||
reply.status(500).send({
|
reply.status(500).send({
|
||||||
backupId: result.backupId,
|
backupId: result.backupId,
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
@@ -118,7 +139,7 @@ export class BackupController {
|
|||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const adminSub = (request as any).userContext?.auth0Sub;
|
const adminUserId = request.userContext?.userId;
|
||||||
|
|
||||||
// Handle multipart file upload
|
// Handle multipart file upload
|
||||||
const data = await request.file();
|
const data = await request.file();
|
||||||
@@ -152,7 +173,7 @@ export class BackupController {
|
|||||||
const backup = await this.backupService.importUploadedBackup(
|
const backup = await this.backupService.importUploadedBackup(
|
||||||
tempPath,
|
tempPath,
|
||||||
filename,
|
filename,
|
||||||
adminSub
|
adminUserId
|
||||||
);
|
);
|
||||||
|
|
||||||
reply.status(201).send({
|
reply.status(201).send({
|
||||||
@@ -196,6 +217,8 @@ export class BackupController {
|
|||||||
request: FastifyRequest<{ Params: BackupIdParam; Body: RestoreBody }>,
|
request: FastifyRequest<{ Params: BackupIdParam; Body: RestoreBody }>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const adminUserId = request.userContext?.userId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.restoreService.executeRestore({
|
const result = await this.restoreService.executeRestore({
|
||||||
backupId: request.params.id,
|
backupId: request.params.id,
|
||||||
@@ -203,6 +226,16 @@ export class BackupController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// Log successful restore to unified audit log
|
||||||
|
await auditLogService.info(
|
||||||
|
'system',
|
||||||
|
adminUserId || null,
|
||||||
|
`Backup restored: ${request.params.id}`,
|
||||||
|
'backup',
|
||||||
|
request.params.id,
|
||||||
|
{ safetyBackupId: result.safetyBackupId }
|
||||||
|
).catch(err => logger.error('Failed to log restore success audit event', { error: err }));
|
||||||
|
|
||||||
reply.send({
|
reply.send({
|
||||||
success: true,
|
success: true,
|
||||||
safetyBackupId: result.safetyBackupId,
|
safetyBackupId: result.safetyBackupId,
|
||||||
@@ -210,6 +243,16 @@ export class BackupController {
|
|||||||
message: 'Restore completed successfully',
|
message: 'Restore completed successfully',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// Log restore failure
|
||||||
|
await auditLogService.error(
|
||||||
|
'system',
|
||||||
|
adminUserId || null,
|
||||||
|
`Backup restore failed: ${request.params.id}`,
|
||||||
|
'backup',
|
||||||
|
request.params.id,
|
||||||
|
{ error: result.error, safetyBackupId: result.safetyBackupId }
|
||||||
|
).catch(err => logger.error('Failed to log restore failure audit event', { error: err }));
|
||||||
|
|
||||||
reply.status(500).send({
|
reply.status(500).send({
|
||||||
success: false,
|
success: false,
|
||||||
safetyBackupId: result.safetyBackupId,
|
safetyBackupId: result.safetyBackupId,
|
||||||
|
|||||||
18
backend/src/features/documents/CLAUDE.md
Normal file
18
backend/src/features/documents/CLAUDE.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# documents/
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What | When to read |
|
||||||
|
| ---- | ---- | ------------ |
|
||||||
|
| `README.md` | Feature documentation | Understanding document management |
|
||||||
|
| `index.ts` | Feature barrel export | Importing document services |
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
|
||||||
|
| Directory | What | When to read |
|
||||||
|
| --------- | ---- | ------------ |
|
||||||
|
| `api/` | HTTP endpoints and routes | API changes |
|
||||||
|
| `domain/` | Business logic, services, types | Core document logic |
|
||||||
|
| `data/` | Repository, database queries | Database operations |
|
||||||
|
| `migrations/` | Database schema | Schema changes |
|
||||||
|
| `tests/` | Unit and integration tests | Adding or modifying tests |
|
||||||
@@ -15,7 +15,7 @@ export class DocumentsController {
|
|||||||
private readonly service = new DocumentsService();
|
private readonly service = new DocumentsService();
|
||||||
|
|
||||||
async list(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
|
async list(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
|
|
||||||
logger.info('Documents list requested', {
|
logger.info('Documents list requested', {
|
||||||
operation: 'documents.list',
|
operation: 'documents.list',
|
||||||
@@ -43,7 +43,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
async get(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const documentId = request.params.id;
|
const documentId = request.params.id;
|
||||||
|
|
||||||
logger.info('Document get requested', {
|
logger.info('Document get requested', {
|
||||||
@@ -74,7 +74,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) {
|
async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free';
|
const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free';
|
||||||
|
|
||||||
logger.info('Document create requested', {
|
logger.info('Document create requested', {
|
||||||
@@ -120,7 +120,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) {
|
async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free';
|
const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free';
|
||||||
const documentId = request.params.id;
|
const documentId = request.params.id;
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
async remove(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const documentId = request.params.id;
|
const documentId = request.params.id;
|
||||||
|
|
||||||
logger.info('Document delete requested', {
|
logger.info('Document delete requested', {
|
||||||
@@ -221,7 +221,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async upload(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
async upload(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const documentId = request.params.id;
|
const documentId = request.params.id;
|
||||||
|
|
||||||
logger.info('Document upload requested', {
|
logger.info('Document upload requested', {
|
||||||
@@ -272,20 +272,15 @@ export class DocumentsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read first 4100 bytes to detect file type via magic bytes
|
// Collect ALL file chunks first (breaking early from async iterator corrupts stream state)
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
let totalBytes = 0;
|
|
||||||
const targetBytes = 4100;
|
|
||||||
|
|
||||||
for await (const chunk of mp.file) {
|
for await (const chunk of mp.file) {
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
totalBytes += chunk.length;
|
|
||||||
if (totalBytes >= targetBytes) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const fullBuffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
const headerBuffer = Buffer.concat(chunks);
|
// Use first 4100 bytes for file type detection via magic bytes
|
||||||
|
const headerBuffer = fullBuffer.subarray(0, Math.min(4100, fullBuffer.length));
|
||||||
|
|
||||||
// Validate actual file content using magic bytes
|
// Validate actual file content using magic bytes
|
||||||
const detectedType = await FileType.fromBuffer(headerBuffer);
|
const detectedType = await FileType.fromBuffer(headerBuffer);
|
||||||
@@ -341,15 +336,9 @@ export class DocumentsController {
|
|||||||
|
|
||||||
const counter = new CountingStream();
|
const counter = new CountingStream();
|
||||||
|
|
||||||
// Create a new readable stream from the header buffer + remaining file chunks
|
// Create readable stream from the complete buffer and pipe through counter
|
||||||
const headerStream = Readable.from([headerBuffer]);
|
const fileStream = Readable.from([fullBuffer]);
|
||||||
const remainingStream = mp.file;
|
fileStream.pipe(counter);
|
||||||
|
|
||||||
// Pipe header first, then remaining content through counter
|
|
||||||
headerStream.pipe(counter, { end: false });
|
|
||||||
headerStream.on('end', () => {
|
|
||||||
remainingStream.pipe(counter);
|
|
||||||
});
|
|
||||||
|
|
||||||
const storage = getStorageService();
|
const storage = getStorageService();
|
||||||
const bucket = 'documents';
|
const bucket = 'documents';
|
||||||
@@ -384,7 +373,7 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async download(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
async download(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user?.sub as string;
|
const userId = request.userContext!.userId;
|
||||||
const documentId = request.params.id;
|
const documentId = request.params.id;
|
||||||
|
|
||||||
logger.info('Document download requested', {
|
logger.info('Document download requested', {
|
||||||
@@ -432,6 +421,165 @@ export class DocumentsController {
|
|||||||
const stream = await storage.getObjectStream(doc.storageBucket, doc.storageKey);
|
const stream = await storage.getObjectStream(doc.storageBucket, doc.storageKey);
|
||||||
return reply.send(stream);
|
return reply.send(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listByVehicle(request: FastifyRequest<{ Params: { vehicleId: string } }>, reply: FastifyReply) {
|
||||||
|
const userId = request.userContext!.userId;
|
||||||
|
const vehicleId = request.params.vehicleId;
|
||||||
|
|
||||||
|
logger.info('Documents by vehicle requested', {
|
||||||
|
operation: 'documents.listByVehicle',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const docs = await this.service.getDocumentsByVehicle(userId, vehicleId);
|
||||||
|
|
||||||
|
logger.info('Documents by vehicle retrieved', {
|
||||||
|
operation: 'documents.listByVehicle.success',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
documentCount: docs.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send(docs);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.statusCode === 403) {
|
||||||
|
logger.warn('Vehicle not found or not owned', {
|
||||||
|
operation: 'documents.listByVehicle.forbidden',
|
||||||
|
userId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
return reply.code(403).send({ error: 'Forbidden', message: e.message });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) {
|
||||||
|
const userId = request.userContext!.userId;
|
||||||
|
const { id: documentId, vehicleId } = request.params;
|
||||||
|
|
||||||
|
logger.info('Add vehicle to document requested', {
|
||||||
|
operation: 'documents.addVehicle',
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await this.service.addVehicleToDocument(userId, documentId, vehicleId);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
logger.warn('Document not updated (possibly duplicate vehicle)', {
|
||||||
|
operation: 'documents.addVehicle.not_updated',
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
return reply.code(400).send({ error: 'Bad Request', message: 'Vehicle could not be added' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Vehicle added to document', {
|
||||||
|
operation: 'documents.addVehicle.success',
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
vehicleId,
|
||||||
|
sharedVehicleCount: updated.sharedVehicleIds.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send(updated);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.statusCode === 404) {
|
||||||
|
logger.warn('Document not found for adding vehicle', {
|
||||||
|
operation: 'documents.addVehicle.not_found',
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
return reply.code(404).send({ error: 'Not Found', message: e.message });
|
||||||
|
}
|
||||||
|
if (e.statusCode === 400) {
|
||||||
|
logger.warn('Bad request for adding vehicle', {
|
||||||
|
operation: 'documents.addVehicle.bad_request',
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
vehicleId,
|
||||||
|
reason: e.message,
|
||||||
|
});
|
||||||
|
return reply.code(400).send({ error: 'Bad Request', message: e.message });
|
||||||
|
}
|
||||||
|
if (e.statusCode === 403) {
|
||||||
|
logger.warn('Forbidden - vehicle not owned', {
|
||||||
|
operation: 'documents.addVehicle.forbidden',
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
return reply.code(403).send({ error: 'Forbidden', message: e.message });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) {
|
||||||
|
const userId = request.userContext!.userId;
|
||||||
|
const { id: documentId, vehicleId } = request.params;
|
||||||
|
|
||||||
|
logger.info('Remove vehicle from document requested', {
|
||||||
|
operation: 'documents.removeVehicle',
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await this.service.removeVehicleFromDocument(userId, documentId, vehicleId);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
// Document was soft deleted
|
||||||
|
logger.info('Document soft deleted (primary vehicle removed, no shared vehicles)', {
|
||||||
|
operation: 'documents.removeVehicle.deleted',
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
return reply.code(204).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Vehicle removed from document', {
|
||||||
|
operation: 'documents.removeVehicle.success',
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
vehicleId,
|
||||||
|
sharedVehicleCount: updated.sharedVehicleIds.length,
|
||||||
|
primaryVehicleId: updated.vehicleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send(updated);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.statusCode === 404) {
|
||||||
|
logger.warn('Document not found for removing vehicle', {
|
||||||
|
operation: 'documents.removeVehicle.not_found',
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
return reply.code(404).send({ error: 'Not Found', message: e.message });
|
||||||
|
}
|
||||||
|
if (e.statusCode === 400) {
|
||||||
|
logger.warn('Bad request for removing vehicle', {
|
||||||
|
operation: 'documents.removeVehicle.bad_request',
|
||||||
|
userId,
|
||||||
|
documentId,
|
||||||
|
vehicleId,
|
||||||
|
reason: e.message,
|
||||||
|
});
|
||||||
|
return reply.code(400).send({ error: 'Bad Request', message: e.message });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cryptoRandom(): string {
|
function cryptoRandom(): string {
|
||||||
|
|||||||
@@ -22,16 +22,6 @@ export const documentsRoutes: FastifyPluginAsync = async (
|
|||||||
handler: ctrl.get.bind(ctrl)
|
handler: ctrl.get.bind(ctrl)
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get<{ Params: any }>('/documents/vehicle/:vehicleId', {
|
|
||||||
preHandler: [requireAuth],
|
|
||||||
handler: async (req, reply) => {
|
|
||||||
const userId = (req as any).user?.sub as string;
|
|
||||||
const query = { vehicleId: (req.params as any).vehicleId };
|
|
||||||
const docs = await ctrl['service'].listDocuments(userId, query);
|
|
||||||
return reply.code(200).send(docs);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.post<{ Body: any }>('/documents', {
|
fastify.post<{ Body: any }>('/documents', {
|
||||||
preHandler: [requireAuth],
|
preHandler: [requireAuth],
|
||||||
handler: ctrl.create.bind(ctrl)
|
handler: ctrl.create.bind(ctrl)
|
||||||
@@ -56,4 +46,20 @@ export const documentsRoutes: FastifyPluginAsync = async (
|
|||||||
preHandler: [requireAuth],
|
preHandler: [requireAuth],
|
||||||
handler: ctrl.download.bind(ctrl)
|
handler: ctrl.download.bind(ctrl)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Vehicle management routes
|
||||||
|
fastify.get<{ Params: any }>('/documents/by-vehicle/:vehicleId', {
|
||||||
|
preHandler: [requireAuth],
|
||||||
|
handler: ctrl.listByVehicle.bind(ctrl)
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post<{ Params: any }>('/documents/:id/vehicles/:vehicleId', {
|
||||||
|
preHandler: [requireAuth],
|
||||||
|
handler: ctrl.addVehicle.bind(ctrl)
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.delete<{ Params: any }>('/documents/:id/vehicles/:vehicleId', {
|
||||||
|
preHandler: [requireAuth],
|
||||||
|
handler: ctrl.removeVehicle.bind(ctrl)
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export const ListQuerySchema = z.object({
|
|||||||
|
|
||||||
export const IdParamsSchema = z.object({ id: z.string().uuid() });
|
export const IdParamsSchema = z.object({ id: z.string().uuid() });
|
||||||
export const VehicleParamsSchema = z.object({ vehicleId: z.string().uuid() });
|
export const VehicleParamsSchema = z.object({ vehicleId: z.string().uuid() });
|
||||||
|
export const DocumentVehicleParamsSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
vehicleId: z.string().uuid()
|
||||||
|
});
|
||||||
|
|
||||||
export const CreateBodySchema = CreateDocumentBodySchema;
|
export const CreateBodySchema = CreateDocumentBodySchema;
|
||||||
export const UpdateBodySchema = UpdateDocumentBodySchema;
|
export const UpdateBodySchema = UpdateDocumentBodySchema;
|
||||||
@@ -16,6 +20,7 @@ export const UpdateBodySchema = UpdateDocumentBodySchema;
|
|||||||
export type ListQuery = z.infer<typeof ListQuerySchema>;
|
export type ListQuery = z.infer<typeof ListQuerySchema>;
|
||||||
export type IdParams = z.infer<typeof IdParamsSchema>;
|
export type IdParams = z.infer<typeof IdParamsSchema>;
|
||||||
export type VehicleParams = z.infer<typeof VehicleParamsSchema>;
|
export type VehicleParams = z.infer<typeof VehicleParamsSchema>;
|
||||||
|
export type DocumentVehicleParams = z.infer<typeof DocumentVehicleParamsSchema>;
|
||||||
export type CreateBody = z.infer<typeof CreateBodySchema>;
|
export type CreateBody = z.infer<typeof CreateBodySchema>;
|
||||||
export type UpdateBody = z.infer<typeof UpdateBodySchema>;
|
export type UpdateBody = z.infer<typeof UpdateBodySchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class DocumentsRepository {
|
|||||||
expirationDate: row.expiration_date,
|
expirationDate: row.expiration_date,
|
||||||
emailNotifications: row.email_notifications,
|
emailNotifications: row.email_notifications,
|
||||||
scanForMaintenance: row.scan_for_maintenance,
|
scanForMaintenance: row.scan_for_maintenance,
|
||||||
|
sharedVehicleIds: row.shared_vehicle_ids || [],
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
deletedAt: row.deleted_at
|
deletedAt: row.deleted_at
|
||||||
@@ -50,11 +51,12 @@ export class DocumentsRepository {
|
|||||||
expirationDate?: string | null;
|
expirationDate?: string | null;
|
||||||
emailNotifications?: boolean;
|
emailNotifications?: boolean;
|
||||||
scanForMaintenance?: boolean;
|
scanForMaintenance?: boolean;
|
||||||
|
sharedVehicleIds?: string[];
|
||||||
}): Promise<DocumentRecord> {
|
}): Promise<DocumentRecord> {
|
||||||
const res = await this.db.query(
|
const res = await this.db.query(
|
||||||
`INSERT INTO documents (
|
`INSERT INTO documents (
|
||||||
id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date, email_notifications, scan_for_maintenance
|
id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date, email_notifications, scan_for_maintenance, shared_vehicle_ids
|
||||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
doc.id,
|
doc.id,
|
||||||
@@ -68,6 +70,7 @@ export class DocumentsRepository {
|
|||||||
doc.expirationDate ?? null,
|
doc.expirationDate ?? null,
|
||||||
doc.emailNotifications ?? false,
|
doc.emailNotifications ?? false,
|
||||||
doc.scanForMaintenance ?? false,
|
doc.scanForMaintenance ?? false,
|
||||||
|
doc.sharedVehicleIds ?? [],
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
return this.mapDocumentRecord(res.rows[0]);
|
return this.mapDocumentRecord(res.rows[0]);
|
||||||
@@ -90,11 +93,71 @@ export class DocumentsRepository {
|
|||||||
return res.rows.map(row => this.mapDocumentRecord(row));
|
return res.rows.map(row => this.mapDocumentRecord(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchInsert(
|
||||||
|
documents: Array<{
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
vehicleId: string;
|
||||||
|
documentType: DocumentType;
|
||||||
|
title: string;
|
||||||
|
notes?: string | null;
|
||||||
|
details?: any;
|
||||||
|
issuedDate?: string | null;
|
||||||
|
expirationDate?: string | null;
|
||||||
|
emailNotifications?: boolean;
|
||||||
|
scanForMaintenance?: boolean;
|
||||||
|
sharedVehicleIds?: string[];
|
||||||
|
}>,
|
||||||
|
client?: any
|
||||||
|
): Promise<DocumentRecord[]> {
|
||||||
|
if (documents.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-value INSERT for performance (avoids N round-trips)
|
||||||
|
const queryClient = client || this.db;
|
||||||
|
const placeholders: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramCount = 1;
|
||||||
|
|
||||||
|
documents.forEach((doc) => {
|
||||||
|
const docParams = [
|
||||||
|
doc.id,
|
||||||
|
doc.userId,
|
||||||
|
doc.vehicleId,
|
||||||
|
doc.documentType,
|
||||||
|
doc.title,
|
||||||
|
doc.notes ?? null,
|
||||||
|
doc.details ?? null,
|
||||||
|
doc.issuedDate ?? null,
|
||||||
|
doc.expirationDate ?? null,
|
||||||
|
doc.emailNotifications ?? false,
|
||||||
|
doc.scanForMaintenance ?? false,
|
||||||
|
doc.sharedVehicleIds ?? []
|
||||||
|
];
|
||||||
|
|
||||||
|
const placeholder = `($${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++})`;
|
||||||
|
placeholders.push(placeholder);
|
||||||
|
values.push(...docParams);
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO documents (
|
||||||
|
id, user_id, vehicle_id, document_type, title, notes, details, issued_date, expiration_date, email_notifications, scan_for_maintenance, shared_vehicle_ids
|
||||||
|
)
|
||||||
|
VALUES ${placeholders.join(', ')}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryClient.query(query, values);
|
||||||
|
return result.rows.map((row: any) => this.mapDocumentRecord(row));
|
||||||
|
}
|
||||||
|
|
||||||
async softDelete(id: string, userId: string): Promise<void> {
|
async softDelete(id: string, userId: string): Promise<void> {
|
||||||
await this.db.query(`UPDATE documents SET deleted_at = NOW() WHERE id = $1 AND user_id = $2`, [id, userId]);
|
await this.db.query(`UPDATE documents SET deleted_at = NOW() WHERE id = $1 AND user_id = $2`, [id, userId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMetadata(id: string, userId: string, patch: Partial<Pick<DocumentRecord, 'title'|'notes'|'details'|'issuedDate'|'expirationDate'|'emailNotifications'|'scanForMaintenance'>>): Promise<DocumentRecord | null> {
|
async updateMetadata(id: string, userId: string, patch: Partial<Pick<DocumentRecord, 'title'|'notes'|'details'|'issuedDate'|'expirationDate'|'emailNotifications'|'scanForMaintenance'|'sharedVehicleIds'>>): Promise<DocumentRecord | null> {
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
let i = 1;
|
let i = 1;
|
||||||
@@ -105,6 +168,7 @@ export class DocumentsRepository {
|
|||||||
if (patch.expirationDate !== undefined) { fields.push(`expiration_date = $${i++}`); params.push(patch.expirationDate); }
|
if (patch.expirationDate !== undefined) { fields.push(`expiration_date = $${i++}`); params.push(patch.expirationDate); }
|
||||||
if (patch.emailNotifications !== undefined) { fields.push(`email_notifications = $${i++}`); params.push(patch.emailNotifications); }
|
if (patch.emailNotifications !== undefined) { fields.push(`email_notifications = $${i++}`); params.push(patch.emailNotifications); }
|
||||||
if (patch.scanForMaintenance !== undefined) { fields.push(`scan_for_maintenance = $${i++}`); params.push(patch.scanForMaintenance); }
|
if (patch.scanForMaintenance !== undefined) { fields.push(`scan_for_maintenance = $${i++}`); params.push(patch.scanForMaintenance); }
|
||||||
|
if (patch.sharedVehicleIds !== undefined) { fields.push(`shared_vehicle_ids = $${i++}`); params.push(patch.sharedVehicleIds); }
|
||||||
if (!fields.length) return this.findById(id, userId);
|
if (!fields.length) return this.findById(id, userId);
|
||||||
params.push(id, userId);
|
params.push(id, userId);
|
||||||
const sql = `UPDATE documents SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} AND deleted_at IS NULL RETURNING *`;
|
const sql = `UPDATE documents SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} AND deleted_at IS NULL RETURNING *`;
|
||||||
@@ -129,5 +193,56 @@ export class DocumentsRepository {
|
|||||||
);
|
);
|
||||||
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
|
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Shared Vehicle Operations (Atomic)
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically add a vehicle to the shared_vehicle_ids array.
|
||||||
|
* Uses PostgreSQL array_append() to avoid race conditions.
|
||||||
|
*/
|
||||||
|
async addSharedVehicle(docId: string, userId: string, vehicleId: string): Promise<DocumentRecord | null> {
|
||||||
|
const res = await this.db.query(
|
||||||
|
`UPDATE documents
|
||||||
|
SET shared_vehicle_ids = array_append(shared_vehicle_ids, $1::uuid)
|
||||||
|
WHERE id = $2 AND user_id = $3 AND deleted_at IS NULL
|
||||||
|
AND NOT ($1::uuid = ANY(shared_vehicle_ids))
|
||||||
|
RETURNING *`,
|
||||||
|
[vehicleId, docId, userId]
|
||||||
|
);
|
||||||
|
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically remove a vehicle from the shared_vehicle_ids array.
|
||||||
|
* Uses PostgreSQL array_remove() to avoid race conditions.
|
||||||
|
*/
|
||||||
|
async removeSharedVehicle(docId: string, userId: string, vehicleId: string): Promise<DocumentRecord | null> {
|
||||||
|
const res = await this.db.query(
|
||||||
|
`UPDATE documents
|
||||||
|
SET shared_vehicle_ids = array_remove(shared_vehicle_ids, $1::uuid)
|
||||||
|
WHERE id = $2 AND user_id = $3 AND deleted_at IS NULL
|
||||||
|
RETURNING *`,
|
||||||
|
[vehicleId, docId, userId]
|
||||||
|
);
|
||||||
|
return res.rows[0] ? this.mapDocumentRecord(res.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all documents associated with a vehicle (either as primary or shared).
|
||||||
|
* Returns documents where vehicle_id = vehicleId OR vehicleId = ANY(shared_vehicle_ids).
|
||||||
|
*/
|
||||||
|
async listByVehicle(userId: string, vehicleId: string): Promise<DocumentRecord[]> {
|
||||||
|
const res = await this.db.query(
|
||||||
|
`SELECT * FROM documents
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND (vehicle_id = $2 OR $2::uuid = ANY(shared_vehicle_ids))
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
[userId, vehicleId]
|
||||||
|
);
|
||||||
|
return res.rows.map(row => this.mapDocumentRecord(row));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import type { CreateDocumentBody, DocumentRecord, DocumentType, UpdateDocumentBody } from './documents.types';
|
import type { CreateDocumentBody, DocumentRecord, DocumentType, UpdateDocumentBody } from './documents.types';
|
||||||
import { DocumentsRepository } from '../data/documents.repository';
|
import { DocumentsRepository } from '../data/documents.repository';
|
||||||
|
import { OwnershipCostsService } from '../../ownership-costs/domain/ownership-costs.service';
|
||||||
|
import type { OwnershipCostType } from '../../ownership-costs/domain/ownership-costs.types';
|
||||||
import pool from '../../../core/config/database';
|
import pool from '../../../core/config/database';
|
||||||
|
|
||||||
export class DocumentsService {
|
export class DocumentsService {
|
||||||
private readonly repo = new DocumentsRepository(pool);
|
private readonly repo = new DocumentsRepository(pool);
|
||||||
|
private readonly ownershipCostsService = new OwnershipCostsService(pool);
|
||||||
|
|
||||||
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
|
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
|
||||||
await this.assertVehicleOwnership(userId, body.vehicleId);
|
await this.assertVehicleOwnership(userId, body.vehicleId);
|
||||||
|
|
||||||
|
// Validate shared vehicles if provided (insurance type only)
|
||||||
|
if (body.sharedVehicleIds && body.sharedVehicleIds.length > 0) {
|
||||||
|
if (body.documentType !== 'insurance') {
|
||||||
|
const err: any = new Error('Shared vehicles are only supported for insurance documents');
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// Validate ownership of all shared vehicles
|
||||||
|
for (const vid of body.sharedVehicleIds) {
|
||||||
|
await this.assertVehicleOwnership(userId, vid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
return this.repo.insert({
|
const doc = await this.repo.insert({
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
vehicleId: body.vehicleId,
|
vehicleId: body.vehicleId,
|
||||||
@@ -21,7 +38,72 @@ export class DocumentsService {
|
|||||||
expirationDate: body.expirationDate ?? null,
|
expirationDate: body.expirationDate ?? null,
|
||||||
emailNotifications: body.emailNotifications ?? false,
|
emailNotifications: body.emailNotifications ?? false,
|
||||||
scanForMaintenance: body.scanForMaintenance ?? false,
|
scanForMaintenance: body.scanForMaintenance ?? false,
|
||||||
|
sharedVehicleIds: body.sharedVehicleIds ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-create ownership_cost when insurance/registration has cost data
|
||||||
|
await this.autoCreateOwnershipCost(userId, doc, body);
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-creates an ownership_cost record when an insurance or registration
|
||||||
|
* document is created with cost data (premium or cost field in details).
|
||||||
|
*/
|
||||||
|
private async autoCreateOwnershipCost(
|
||||||
|
userId: string,
|
||||||
|
doc: DocumentRecord,
|
||||||
|
body: CreateDocumentBody
|
||||||
|
): Promise<void> {
|
||||||
|
const costType = this.mapDocumentTypeToCostType(body.documentType);
|
||||||
|
if (!costType) return; // Not a cost-linkable document type
|
||||||
|
|
||||||
|
const costAmount = this.extractCostAmount(body);
|
||||||
|
if (!costAmount || costAmount <= 0) return; // No valid cost data
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.ownershipCostsService.createCost(userId, {
|
||||||
|
vehicleId: body.vehicleId,
|
||||||
|
documentId: doc.id,
|
||||||
|
costType,
|
||||||
|
amount: costAmount,
|
||||||
|
description: doc.title,
|
||||||
|
periodStart: body.issuedDate,
|
||||||
|
periodEnd: body.expirationDate,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Log but don't fail document creation if cost creation fails
|
||||||
|
console.error('Failed to auto-create ownership cost for document:', doc.id, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps document types to ownership cost types.
|
||||||
|
* Returns null for document types that don't auto-create costs.
|
||||||
|
*/
|
||||||
|
private mapDocumentTypeToCostType(documentType: string): OwnershipCostType | null {
|
||||||
|
const typeMap: Record<string, OwnershipCostType> = {
|
||||||
|
'insurance': 'insurance',
|
||||||
|
'registration': 'registration',
|
||||||
|
};
|
||||||
|
return typeMap[documentType] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts cost amount from document details.
|
||||||
|
* Insurance uses 'premium', registration uses 'cost'.
|
||||||
|
*/
|
||||||
|
private extractCostAmount(body: CreateDocumentBody): number | null {
|
||||||
|
if (!body.details) return null;
|
||||||
|
|
||||||
|
const premium = body.details.premium;
|
||||||
|
const cost = body.details.cost;
|
||||||
|
|
||||||
|
if (typeof premium === 'number' && premium > 0) return premium;
|
||||||
|
if (typeof cost === 'number' && cost > 0) return cost;
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDocument(userId: string, id: string): Promise<DocumentRecord | null> {
|
async getDocument(userId: string, id: string): Promise<DocumentRecord | null> {
|
||||||
@@ -35,16 +117,184 @@ export class DocumentsService {
|
|||||||
async updateDocument(userId: string, id: string, patch: UpdateDocumentBody) {
|
async updateDocument(userId: string, id: string, patch: UpdateDocumentBody) {
|
||||||
const existing = await this.repo.findById(id, userId);
|
const existing = await this.repo.findById(id, userId);
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
|
// Validate shared vehicles if provided (insurance type only)
|
||||||
|
if (patch.sharedVehicleIds !== undefined) {
|
||||||
|
if (existing.documentType !== 'insurance') {
|
||||||
|
const err: any = new Error('Shared vehicles are only supported for insurance documents');
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// Validate ownership of all shared vehicles
|
||||||
|
for (const vid of patch.sharedVehicleIds) {
|
||||||
|
await this.assertVehicleOwnership(userId, vid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (patch && typeof patch === 'object') {
|
if (patch && typeof patch === 'object') {
|
||||||
return this.repo.updateMetadata(id, userId, patch as any);
|
const updated = await this.repo.updateMetadata(id, userId, patch as any);
|
||||||
|
|
||||||
|
// Sync cost changes to linked ownership_cost if applicable
|
||||||
|
if (updated && patch.details) {
|
||||||
|
await this.syncOwnershipCost(userId, updated, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs cost data changes to linked ownership_cost record.
|
||||||
|
* If document has linked cost and details.premium/cost changed, update it.
|
||||||
|
*/
|
||||||
|
private async syncOwnershipCost(
|
||||||
|
userId: string,
|
||||||
|
doc: DocumentRecord,
|
||||||
|
patch: UpdateDocumentBody
|
||||||
|
): Promise<void> {
|
||||||
|
const costType = this.mapDocumentTypeToCostType(doc.documentType);
|
||||||
|
if (!costType) return;
|
||||||
|
|
||||||
|
const newCostAmount = this.extractCostAmountFromDetails(patch.details);
|
||||||
|
if (newCostAmount === null) return; // No cost in update
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find existing linked cost
|
||||||
|
const linkedCosts = await this.ownershipCostsService.getCosts(userId, { documentId: doc.id });
|
||||||
|
|
||||||
|
if (linkedCosts.length > 0 && newCostAmount > 0) {
|
||||||
|
// Update existing linked cost
|
||||||
|
await this.ownershipCostsService.updateCost(userId, linkedCosts[0].id, {
|
||||||
|
amount: newCostAmount,
|
||||||
|
periodStart: patch.issuedDate ?? undefined,
|
||||||
|
periodEnd: patch.expirationDate ?? undefined,
|
||||||
|
});
|
||||||
|
} else if (linkedCosts.length === 0 && newCostAmount > 0) {
|
||||||
|
// Create new cost if none exists
|
||||||
|
await this.ownershipCostsService.createCost(userId, {
|
||||||
|
vehicleId: doc.vehicleId,
|
||||||
|
documentId: doc.id,
|
||||||
|
costType,
|
||||||
|
amount: newCostAmount,
|
||||||
|
description: doc.title,
|
||||||
|
periodStart: patch.issuedDate ?? doc.issuedDate ?? undefined,
|
||||||
|
periodEnd: patch.expirationDate ?? doc.expirationDate ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to sync ownership cost for document:', doc.id, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts cost amount from details object (for updates).
|
||||||
|
*/
|
||||||
|
private extractCostAmountFromDetails(details?: Record<string, any> | null): number | null {
|
||||||
|
if (!details) return null;
|
||||||
|
|
||||||
|
const premium = details.premium;
|
||||||
|
const cost = details.cost;
|
||||||
|
|
||||||
|
if (typeof premium === 'number') return premium;
|
||||||
|
if (typeof cost === 'number') return cost;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async deleteDocument(userId: string, id: string): Promise<void> {
|
async deleteDocument(userId: string, id: string): Promise<void> {
|
||||||
|
// Note: Linked ownership_cost records are CASCADE deleted via FK
|
||||||
await this.repo.softDelete(id, userId);
|
await this.repo.softDelete(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addVehicleToDocument(userId: string, docId: string, vehicleId: string): Promise<DocumentRecord | null> {
|
||||||
|
// Validate document exists and is owned by user
|
||||||
|
const doc = await this.repo.findById(docId, userId);
|
||||||
|
if (!doc) {
|
||||||
|
const err: any = new Error('Document not found');
|
||||||
|
err.statusCode = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only insurance documents support shared vehicles
|
||||||
|
if (doc.documentType !== 'insurance') {
|
||||||
|
const err: any = new Error('Shared vehicles are only supported for insurance documents');
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate vehicle ownership
|
||||||
|
await this.assertVehicleOwnership(userId, vehicleId);
|
||||||
|
|
||||||
|
// Check if vehicle is already the primary vehicle
|
||||||
|
if (doc.vehicleId === vehicleId) {
|
||||||
|
const err: any = new Error('Vehicle is already the primary vehicle for this document');
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to shared vehicles (repository handles duplicate check)
|
||||||
|
return this.repo.addSharedVehicle(docId, userId, vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeVehicleFromDocument(userId: string, docId: string, vehicleId: string): Promise<DocumentRecord | null> {
|
||||||
|
// Validate document exists and is owned by user
|
||||||
|
const doc = await this.repo.findById(docId, userId);
|
||||||
|
if (!doc) {
|
||||||
|
const err: any = new Error('Document not found');
|
||||||
|
err.statusCode = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context-aware delete logic
|
||||||
|
const isSharedVehicle = doc.sharedVehicleIds.includes(vehicleId);
|
||||||
|
const isPrimaryVehicle = doc.vehicleId === vehicleId;
|
||||||
|
|
||||||
|
if (!isSharedVehicle && !isPrimaryVehicle) {
|
||||||
|
const err: any = new Error('Vehicle is not associated with this document');
|
||||||
|
err.statusCode = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 1: Removing from shared vehicles only
|
||||||
|
if (isSharedVehicle && !isPrimaryVehicle) {
|
||||||
|
return this.repo.removeSharedVehicle(docId, userId, vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: Removing primary vehicle with no shared vehicles -> soft delete document
|
||||||
|
if (isPrimaryVehicle && doc.sharedVehicleIds.length === 0) {
|
||||||
|
await this.repo.softDelete(docId, userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: Removing primary vehicle with shared vehicles -> promote first shared to primary
|
||||||
|
if (isPrimaryVehicle && doc.sharedVehicleIds.length > 0) {
|
||||||
|
const newPrimaryId = doc.sharedVehicleIds[0];
|
||||||
|
const remainingShared = doc.sharedVehicleIds.slice(1);
|
||||||
|
|
||||||
|
// Update primary vehicle and remaining shared vehicles
|
||||||
|
return this.repo.updateMetadata(docId, userId, {
|
||||||
|
sharedVehicleIds: remainingShared,
|
||||||
|
}).then(async () => {
|
||||||
|
// Update vehicle_id separately as it's not part of the metadata update
|
||||||
|
const res = await pool.query(
|
||||||
|
'UPDATE documents SET vehicle_id = $1 WHERE id = $2 AND user_id = $3 AND deleted_at IS NULL RETURNING *',
|
||||||
|
[newPrimaryId, docId, userId]
|
||||||
|
);
|
||||||
|
if (!res.rows[0]) return null;
|
||||||
|
return this.repo.findById(docId, userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDocumentsByVehicle(userId: string, vehicleId: string): Promise<DocumentRecord[]> {
|
||||||
|
// Validate vehicle ownership
|
||||||
|
await this.assertVehicleOwnership(userId, vehicleId);
|
||||||
|
return this.repo.listByVehicle(userId, vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
private async assertVehicleOwnership(userId: string, vehicleId: string) {
|
private async assertVehicleOwnership(userId: string, vehicleId: string) {
|
||||||
const res = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]);
|
const res = await pool.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]);
|
||||||
if (!res.rows[0]) {
|
if (!res.rows[0]) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface DocumentRecord {
|
|||||||
expirationDate?: string | null;
|
expirationDate?: string | null;
|
||||||
emailNotifications?: boolean;
|
emailNotifications?: boolean;
|
||||||
scanForMaintenance?: boolean;
|
scanForMaintenance?: boolean;
|
||||||
|
sharedVehicleIds: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt?: string | null;
|
deletedAt?: string | null;
|
||||||
@@ -38,6 +39,7 @@ export const CreateDocumentBodySchema = z.object({
|
|||||||
expirationDate: z.string().optional(),
|
expirationDate: z.string().optional(),
|
||||||
emailNotifications: z.boolean().optional(),
|
emailNotifications: z.boolean().optional(),
|
||||||
scanForMaintenance: z.boolean().optional(),
|
scanForMaintenance: z.boolean().optional(),
|
||||||
|
sharedVehicleIds: z.array(z.string().uuid()).optional(),
|
||||||
});
|
});
|
||||||
export type CreateDocumentBody = z.infer<typeof CreateDocumentBodySchema>;
|
export type CreateDocumentBody = z.infer<typeof CreateDocumentBodySchema>;
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ export const UpdateDocumentBodySchema = z.object({
|
|||||||
expirationDate: z.string().nullable().optional(),
|
expirationDate: z.string().nullable().optional(),
|
||||||
emailNotifications: z.boolean().optional(),
|
emailNotifications: z.boolean().optional(),
|
||||||
scanForMaintenance: z.boolean().optional(),
|
scanForMaintenance: z.boolean().optional(),
|
||||||
|
sharedVehicleIds: z.array(z.string().uuid()).optional(),
|
||||||
});
|
});
|
||||||
export type UpdateDocumentBody = z.infer<typeof UpdateDocumentBodySchema>;
|
export type UpdateDocumentBody = z.infer<typeof UpdateDocumentBodySchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Migration: Add shared_vehicle_ids array column for cross-vehicle document sharing
|
||||||
|
-- Issue: #31
|
||||||
|
-- Allows a document to be shared with multiple vehicles beyond its primary vehicle_id
|
||||||
|
|
||||||
|
-- Add shared_vehicle_ids column with default empty array
|
||||||
|
ALTER TABLE documents
|
||||||
|
ADD COLUMN shared_vehicle_ids UUID[] DEFAULT '{}' NOT NULL;
|
||||||
|
|
||||||
|
-- Add GIN index for efficient array membership queries
|
||||||
|
-- This allows fast lookups of "which documents are shared with vehicle X"
|
||||||
|
CREATE INDEX idx_documents_shared_vehicle_ids ON documents USING GIN (shared_vehicle_ids array_ops);
|
||||||
|
|
||||||
|
-- Example usage:
|
||||||
|
-- 1. Find all documents shared with a specific vehicle:
|
||||||
|
-- SELECT * FROM documents WHERE 'vehicle-uuid-here' = ANY(shared_vehicle_ids);
|
||||||
|
--
|
||||||
|
-- 2. Find documents by primary OR shared vehicle:
|
||||||
|
-- SELECT * FROM documents WHERE vehicle_id = 'uuid' OR 'uuid' = ANY(shared_vehicle_ids);
|
||||||
@@ -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.userContext!.userId;
|
||||||
|
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.userContext?.userId });
|
||||||
|
return reply.code(500).send({ error: 'Failed to list pending associations' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingAssociationCount(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = request.userContext!.userId;
|
||||||
|
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.userContext?.userId });
|
||||||
|
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.userContext!.userId;
|
||||||
|
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.userContext?.userId;
|
||||||
|
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.userContext!.userId;
|
||||||
|
const { id } = request.params;
|
||||||
|
|
||||||
|
await this.service.dismissAssociation(id, userId);
|
||||||
|
return reply.code(204).send();
|
||||||
|
} catch (error: any) {
|
||||||
|
const userId = request.userContext?.userId;
|
||||||
|
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
backend/src/features/fuel-logs/CLAUDE.md
Normal file
21
backend/src/features/fuel-logs/CLAUDE.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# fuel-logs/
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What | When to read |
|
||||||
|
| ---- | ---- | ------------ |
|
||||||
|
| `README.md` | Feature documentation | Understanding fuel log tracking |
|
||||||
|
| `index.ts` | Feature barrel export | Importing fuel-log services |
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
|
||||||
|
| Directory | What | When to read |
|
||||||
|
| --------- | ---- | ------------ |
|
||||||
|
| `api/` | HTTP endpoints and routes | API changes |
|
||||||
|
| `domain/` | Business logic, services, types | Core fuel-log logic |
|
||||||
|
| `data/` | Repository, database queries | Database operations |
|
||||||
|
| `docs/` | Feature-specific documentation | Fuel-log design details |
|
||||||
|
| `events/` | Event handlers and emitters | Cross-feature event integration |
|
||||||
|
| `external/` | External service integrations | Third-party API work |
|
||||||
|
| `migrations/` | Database schema | Schema changes |
|
||||||
|
| `tests/` | Unit and integration tests | Adding or modifying tests |
|
||||||
@@ -20,12 +20,12 @@ export class FuelLogsController {
|
|||||||
|
|
||||||
async createFuelLog(request: FastifyRequest<{ Body: EnhancedCreateFuelLogRequest }>, reply: FastifyReply) {
|
async createFuelLog(request: FastifyRequest<{ Body: EnhancedCreateFuelLogRequest }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const fuelLog = await this.fuelLogsService.createFuelLog(request.body, userId);
|
const fuelLog = await this.fuelLogsService.createFuelLog(request.body, userId);
|
||||||
|
|
||||||
return reply.code(201).send(fuelLog);
|
return reply.code(201).send(fuelLog);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error creating fuel log', { error, userId: (request as any).user?.sub });
|
logger.error('Error creating fuel log', { error, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -49,14 +49,14 @@ export class FuelLogsController {
|
|||||||
|
|
||||||
async getFuelLogsByVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
async getFuelLogsByVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { vehicleId } = request.params;
|
const { vehicleId } = request.params;
|
||||||
|
|
||||||
const fuelLogs = await this.fuelLogsService.getFuelLogsByVehicle(vehicleId, userId);
|
const fuelLogs = await this.fuelLogsService.getFuelLogsByVehicle(vehicleId, userId);
|
||||||
|
|
||||||
return reply.code(200).send(fuelLogs);
|
return reply.code(200).send(fuelLogs);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error listing fuel logs', { error, vehicleId: request.params.vehicleId, userId: (request as any).user?.sub });
|
logger.error('Error listing fuel logs', { error, vehicleId: request.params.vehicleId, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -80,12 +80,12 @@ export class FuelLogsController {
|
|||||||
|
|
||||||
async getUserFuelLogs(request: FastifyRequest, reply: FastifyReply) {
|
async getUserFuelLogs(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const fuelLogs = await this.fuelLogsService.getUserFuelLogs(userId);
|
const fuelLogs = await this.fuelLogsService.getUserFuelLogs(userId);
|
||||||
|
|
||||||
return reply.code(200).send(fuelLogs);
|
return reply.code(200).send(fuelLogs);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error listing all fuel logs', { error, userId: (request as any).user?.sub });
|
logger.error('Error listing all fuel logs', { error, userId: request.userContext?.userId });
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
message: 'Failed to get fuel logs'
|
message: 'Failed to get fuel logs'
|
||||||
@@ -95,14 +95,14 @@ export class FuelLogsController {
|
|||||||
|
|
||||||
async getFuelLog(request: FastifyRequest<{ Params: FuelLogParams }>, reply: FastifyReply) {
|
async getFuelLog(request: FastifyRequest<{ Params: FuelLogParams }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
const fuelLog = await this.fuelLogsService.getFuelLog(id, userId);
|
const fuelLog = await this.fuelLogsService.getFuelLog(id, userId);
|
||||||
|
|
||||||
return reply.code(200).send(fuelLog);
|
return reply.code(200).send(fuelLog);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error getting fuel log', { error, fuelLogId: request.params.id, userId: (request as any).user?.sub });
|
logger.error('Error getting fuel log', { error, fuelLogId: request.params.id, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message === 'Fuel log not found') {
|
if (error.message === 'Fuel log not found') {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -126,14 +126,14 @@ export class FuelLogsController {
|
|||||||
|
|
||||||
async updateFuelLog(request: FastifyRequest<{ Params: FuelLogParams; Body: EnhancedUpdateFuelLogRequest }>, reply: FastifyReply) {
|
async updateFuelLog(request: FastifyRequest<{ Params: FuelLogParams; Body: EnhancedUpdateFuelLogRequest }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
const updatedFuelLog = await this.fuelLogsService.updateFuelLog(id, request.body, userId);
|
const updatedFuelLog = await this.fuelLogsService.updateFuelLog(id, request.body, userId);
|
||||||
|
|
||||||
return reply.code(200).send(updatedFuelLog);
|
return reply.code(200).send(updatedFuelLog);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error updating fuel log', { error, fuelLogId: request.params.id, userId: (request as any).user?.sub });
|
logger.error('Error updating fuel log', { error, fuelLogId: request.params.id, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -163,14 +163,14 @@ export class FuelLogsController {
|
|||||||
|
|
||||||
async deleteFuelLog(request: FastifyRequest<{ Params: FuelLogParams }>, reply: FastifyReply) {
|
async deleteFuelLog(request: FastifyRequest<{ Params: FuelLogParams }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
await this.fuelLogsService.deleteFuelLog(id, userId);
|
await this.fuelLogsService.deleteFuelLog(id, userId);
|
||||||
|
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error deleting fuel log', { error, fuelLogId: request.params.id, userId: (request as any).user?.sub });
|
logger.error('Error deleting fuel log', { error, fuelLogId: request.params.id, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
@@ -194,14 +194,14 @@ export class FuelLogsController {
|
|||||||
|
|
||||||
async getFuelStats(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
async getFuelStats(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const userId = (request as any).user.sub;
|
const userId = request.userContext!.userId;
|
||||||
const { vehicleId } = request.params;
|
const { vehicleId } = request.params;
|
||||||
|
|
||||||
const stats = await this.fuelLogsService.getVehicleStats(vehicleId, userId);
|
const stats = await this.fuelLogsService.getVehicleStats(vehicleId, userId);
|
||||||
|
|
||||||
return reply.code(200).send(stats);
|
return reply.code(200).send(stats);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error getting fuel stats', { error, vehicleId: request.params.vehicleId, userId: (request as any).user?.sub });
|
logger.error('Error getting fuel stats', { error, vehicleId: request.params.vehicleId, userId: request.userContext?.userId });
|
||||||
|
|
||||||
if (error.message.includes('not found')) {
|
if (error.message.includes('not found')) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
|
|||||||
@@ -148,6 +148,52 @@ export class FuelLogsRepository {
|
|||||||
return this.mapRow(result.rows[0]);
|
return this.mapRow(result.rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async batchInsert(
|
||||||
|
logs: Array<CreateFuelLogRequest & { userId: string }>,
|
||||||
|
client?: any
|
||||||
|
): Promise<FuelLog[]> {
|
||||||
|
if (logs.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-value INSERT for performance (avoids N round-trips)
|
||||||
|
const queryClient = client || this.pool;
|
||||||
|
const placeholders: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramCount = 1;
|
||||||
|
|
||||||
|
logs.forEach((log) => {
|
||||||
|
const logParams = [
|
||||||
|
log.userId,
|
||||||
|
log.vehicleId,
|
||||||
|
log.date,
|
||||||
|
log.odometer,
|
||||||
|
log.gallons,
|
||||||
|
log.pricePerGallon,
|
||||||
|
log.totalCost,
|
||||||
|
log.station,
|
||||||
|
log.location,
|
||||||
|
log.notes
|
||||||
|
];
|
||||||
|
|
||||||
|
const placeholder = `($${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++})`;
|
||||||
|
placeholders.push(placeholder);
|
||||||
|
values.push(...logParams);
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO fuel_logs (
|
||||||
|
user_id, vehicle_id, date, odometer, gallons,
|
||||||
|
price_per_gallon, total_cost, station, location, notes
|
||||||
|
)
|
||||||
|
VALUES ${placeholders.join(', ')}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await queryClient.query(query, values);
|
||||||
|
return result.rows.map((row: any) => this.mapRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<boolean> {
|
async delete(id: string): Promise<boolean> {
|
||||||
const query = 'DELETE FROM fuel_logs WHERE id = $1';
|
const query = 'DELETE FROM fuel_logs WHERE id = $1';
|
||||||
const result = await this.pool.query(query, [id]);
|
const result = await this.pool.query(query, [id]);
|
||||||
@@ -247,7 +293,7 @@ export class FuelLogsRepository {
|
|||||||
data.notes ?? null
|
data.notes ?? null
|
||||||
];
|
];
|
||||||
const res = await this.pool.query(query, values);
|
const res = await this.pool.query(query, values);
|
||||||
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
return res.rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByVehicleIdEnhanced(vehicleId: string): Promise<any[]> {
|
async findByVehicleIdEnhanced(vehicleId: string): Promise<any[]> {
|
||||||
@@ -255,7 +301,7 @@ export class FuelLogsRepository {
|
|||||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`,
|
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`,
|
||||||
[vehicleId]
|
[vehicleId]
|
||||||
);
|
);
|
||||||
return res.rows.map(row => this.mapRow(row));
|
return res.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUserIdEnhanced(userId: string): Promise<any[]> {
|
async findByUserIdEnhanced(userId: string): Promise<any[]> {
|
||||||
@@ -263,12 +309,12 @@ export class FuelLogsRepository {
|
|||||||
`SELECT * FROM fuel_logs WHERE user_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`,
|
`SELECT * FROM fuel_logs WHERE user_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC`,
|
||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
return res.rows.map(row => this.mapRow(row));
|
return res.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByIdEnhanced(id: string): Promise<any | null> {
|
async findByIdEnhanced(id: string): Promise<any | null> {
|
||||||
const res = await this.pool.query(`SELECT * FROM fuel_logs WHERE id = $1`, [id]);
|
const res = await this.pool.query(`SELECT * FROM fuel_logs WHERE id = $1`, [id]);
|
||||||
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
return res.rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPreviousLogByOdometer(vehicleId: string, odometerReading: number): Promise<any | null> {
|
async getPreviousLogByOdometer(vehicleId: string, odometerReading: number): Promise<any | null> {
|
||||||
@@ -276,7 +322,7 @@ export class FuelLogsRepository {
|
|||||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 AND odometer IS NOT NULL AND odometer < $2 ORDER BY odometer DESC LIMIT 1`,
|
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 AND odometer IS NOT NULL AND odometer < $2 ORDER BY odometer DESC LIMIT 1`,
|
||||||
[vehicleId, odometerReading]
|
[vehicleId, odometerReading]
|
||||||
);
|
);
|
||||||
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
return res.rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLatestLogForVehicle(vehicleId: string): Promise<any | null> {
|
async getLatestLogForVehicle(vehicleId: string): Promise<any | null> {
|
||||||
@@ -284,7 +330,7 @@ export class FuelLogsRepository {
|
|||||||
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC LIMIT 1`,
|
`SELECT * FROM fuel_logs WHERE vehicle_id = $1 ORDER BY date_time DESC NULLS LAST, date DESC NULLS LAST, created_at DESC LIMIT 1`,
|
||||||
[vehicleId]
|
[vehicleId]
|
||||||
);
|
);
|
||||||
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
return res.rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateEnhanced(id: string, data: {
|
async updateEnhanced(id: string, data: {
|
||||||
@@ -370,6 +416,6 @@ export class FuelLogsRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.mapRow(result.rows[0]);
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,8 +282,8 @@ export class FuelLogsService {
|
|||||||
tripDistance: row.trip_distance ?? undefined,
|
tripDistance: row.trip_distance ?? undefined,
|
||||||
fuelType: row.fuel_type as FuelType,
|
fuelType: row.fuel_type as FuelType,
|
||||||
fuelGrade: row.fuel_grade ?? undefined,
|
fuelGrade: row.fuel_grade ?? undefined,
|
||||||
fuelUnits: row.fuel_units,
|
fuelUnits: Number(row.fuel_units),
|
||||||
costPerUnit: row.cost_per_unit,
|
costPerUnit: Number(row.cost_per_unit),
|
||||||
totalCost: Number(row.total_cost),
|
totalCost: Number(row.total_cost),
|
||||||
locationData: row.location_data ?? undefined,
|
locationData: row.location_data ?? undefined,
|
||||||
notes: row.notes ?? undefined,
|
notes: row.notes ?? undefined,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
},
|
},
|
||||||
"responseWithEfficiency": {
|
"responseWithEfficiency": {
|
||||||
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||||
"userId": "auth0|user123",
|
"userId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"vehicleId": "550e8400-e29b-41d4-a716-446655440000",
|
"vehicleId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"dateTime": "2024-01-15T10:30:00Z",
|
"dateTime": "2024-01-15T10:30:00Z",
|
||||||
"odometerReading": 52000,
|
"odometerReading": 52000,
|
||||||
|
|||||||
21
backend/src/features/maintenance/CLAUDE.md
Normal file
21
backend/src/features/maintenance/CLAUDE.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# maintenance/
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | What | When to read |
|
||||||
|
| ---- | ---- | ------------ |
|
||||||
|
| `README.md` | Feature documentation | Understanding maintenance tracking |
|
||||||
|
| `index.ts` | Feature barrel export | Importing maintenance services |
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
|
||||||
|
| Directory | What | When to read |
|
||||||
|
| --------- | ---- | ------------ |
|
||||||
|
| `api/` | HTTP endpoints and routes | API changes |
|
||||||
|
| `domain/` | Business logic, services, types | Core maintenance logic |
|
||||||
|
| `data/` | Repository, database queries | Database operations |
|
||||||
|
| `docs/` | Feature-specific documentation | Maintenance design details |
|
||||||
|
| `events/` | Event handlers and emitters | Cross-feature event integration |
|
||||||
|
| `external/` | External service integrations | Third-party API work |
|
||||||
|
| `migrations/` | Database schema | Schema changes |
|
||||||
|
| `tests/` | Unit and integration tests | Adding or modifying tests |
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user