Compare commits
155 Commits
e7471d5c27
...
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 |
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
|
||||
@@ -22,7 +22,7 @@ env:
|
||||
BASE_COMPOSE_FILE: docker-compose.yml
|
||||
COMPOSE_BLUE_GREEN: docker-compose.blue-green.yml
|
||||
COMPOSE_PROD: docker-compose.prod.yml
|
||||
HEALTH_CHECK_TIMEOUT: "60"
|
||||
HEALTH_CHECK_TIMEOUT: "240"
|
||||
LOG_LEVEL: INFO
|
||||
|
||||
jobs:
|
||||
@@ -95,9 +95,11 @@ jobs:
|
||||
sparse-checkout: |
|
||||
scripts/
|
||||
config/
|
||||
secrets/app/google-wif-config.json
|
||||
docker-compose.yml
|
||||
docker-compose.blue-green.yml
|
||||
docker-compose.prod.yml
|
||||
.env.example
|
||||
sparse-checkout-cone-mode: false
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -108,12 +110,26 @@ jobs:
|
||||
cp "$GITHUB_WORKSPACE/docker-compose.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 logging configuration
|
||||
- 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"
|
||||
./scripts/ci/generate-log-config.sh "$LOG_LEVEL" >> .env
|
||||
|
||||
- name: Login to registry
|
||||
run: |
|
||||
@@ -129,6 +145,8 @@ jobs:
|
||||
AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }}
|
||||
AUTH0_MANAGEMENT_CLIENT_ID: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_ID }}
|
||||
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_MAP_ID: ${{ secrets.GOOGLE_MAPS_MAP_ID }}
|
||||
CF_DNS_API_TOKEN: ${{ secrets.CF_DNS_API_TOKEN }}
|
||||
@@ -169,10 +187,32 @@ jobs:
|
||||
run: |
|
||||
cd "$DEPLOY_PATH"
|
||||
# Start shared infrastructure services (database, cache, logging)
|
||||
# These persist across blue-green deployments
|
||||
docker compose -f $BASE_COMPOSE_FILE -f $COMPOSE_BLUE_GREEN -f $COMPOSE_PROD up -d \
|
||||
# --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
|
||||
run: |
|
||||
cd "$DEPLOY_PATH"
|
||||
|
||||
@@ -118,12 +118,26 @@ jobs:
|
||||
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 logging configuration
|
||||
- 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"
|
||||
./scripts/ci/generate-log-config.sh "$LOG_LEVEL" >> .env
|
||||
|
||||
- name: Login to registry
|
||||
run: |
|
||||
@@ -139,6 +153,8 @@ jobs:
|
||||
AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }}
|
||||
AUTH0_MANAGEMENT_CLIENT_ID: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_ID }}
|
||||
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_MAP_ID: ${{ secrets.GOOGLE_MAPS_MAP_ID }}
|
||||
CF_DNS_API_TOKEN: ${{ secrets.CF_DNS_API_TOKEN }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,6 +22,7 @@ secrets/**
|
||||
!secrets/
|
||||
!secrets/**/
|
||||
!secrets/**/*.example
|
||||
!secrets/app/google-wif-config.json
|
||||
|
||||
# Traefik ACME certificates (contains private keys)
|
||||
data/traefik/acme.json
|
||||
226
backend/package-lock.json
generated
226
backend/package-lock.json
generated
@@ -24,12 +24,14 @@
|
||||
"get-jwks": "^11.0.3",
|
||||
"ioredis": "^5.4.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"mailparser": "^3.9.3",
|
||||
"node-cron": "^3.0.3",
|
||||
"opossum": "^8.0.0",
|
||||
"pg": "^8.13.1",
|
||||
"pino": "^9.6.0",
|
||||
"resend": "^3.0.0",
|
||||
"stripe": "^20.2.0",
|
||||
"svix": "^1.85.0",
|
||||
"tar": "^7.4.3",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
@@ -37,6 +39,7 @@
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/jest": "^29.5.10",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/opossum": "^8.0.0",
|
||||
@@ -83,7 +86,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -1766,6 +1768,12 @@
|
||||
"@sinonjs/commons": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tokenizer/token": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
||||
@@ -1921,6 +1929,30 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mailparser": {
|
||||
"version": "3.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz",
|
||||
"integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"iconv-lite": "^0.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mailparser/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
@@ -1934,7 +1966,6 @@
|
||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -2061,7 +2092,6 @@
|
||||
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.50.0",
|
||||
"@typescript-eslint/types": "8.50.0",
|
||||
@@ -2273,6 +2303,17 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@zone-eu/mailsplit": {
|
||||
"version": "5.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
|
||||
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.7",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
|
||||
@@ -2306,7 +2347,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2773,7 +2813,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3470,6 +3509,15 @@
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encoding-japanese": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
|
||||
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
@@ -3566,7 +3614,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3900,6 +3947,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
@@ -4508,6 +4561,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||
@@ -4580,6 +4642,22 @@
|
||||
"node": ">=10.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -4920,7 +4998,6 @@
|
||||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "^29.7.0",
|
||||
"@jest/types": "^29.6.3",
|
||||
@@ -5736,6 +5813,42 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libbase64": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
|
||||
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
|
||||
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.6.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libmime/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libqp": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
|
||||
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
@@ -5780,6 +5893,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -5827,6 +5949,7 @@
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
@@ -5843,6 +5966,24 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"version": "3.9.3",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz",
|
||||
"integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libmime": "5.3.7",
|
||||
"linkify-it": "5.0.0",
|
||||
"nodemailer": "7.0.13",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.261.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
@@ -6071,6 +6212,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.13",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
|
||||
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.11",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
||||
@@ -6419,7 +6569,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
@@ -6785,6 +6934,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||
@@ -7126,6 +7284,7 @@
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
@@ -7381,6 +7540,16 @@
|
||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/steed": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
|
||||
@@ -7602,6 +7771,29 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svix": {
|
||||
"version": "1.85.0",
|
||||
"resolved": "https://registry.npmjs.org/svix/-/svix-1.85.0.tgz",
|
||||
"integrity": "sha512-4OxNw++bnNay8SoBwESgzfjMnYmurS1qBX+luhzvljr6EAPn/hqqmkdCR1pbgIe1K1+BzKZEHjAKz9OYrKJYwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"standardwebhooks": "1.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svix/node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.2",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz",
|
||||
@@ -7692,7 +7884,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7700,6 +7891,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tlds": {
|
||||
"version": "1.261.0",
|
||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
|
||||
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tlds": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tmpl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
@@ -7841,7 +8041,6 @@
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
@@ -7929,7 +8128,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -7962,6 +8160,12 @@
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
|
||||
@@ -34,19 +34,22 @@
|
||||
"get-jwks": "^11.0.3",
|
||||
"ioredis": "^5.4.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"mailparser": "^3.9.3",
|
||||
"node-cron": "^3.0.3",
|
||||
"opossum": "^8.0.0",
|
||||
"pg": "^8.13.1",
|
||||
"pino": "^9.6.0",
|
||||
"resend": "^3.0.0",
|
||||
"stripe": "^20.2.0",
|
||||
"svix": "^1.85.0",
|
||||
"tar": "^7.4.3",
|
||||
"pino": "^9.6.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/jest": "^29.5.10",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/opossum": "^8.0.0",
|
||||
|
||||
@@ -26,10 +26,12 @@ const MIGRATION_ORDER = [
|
||||
'features/admin', // Admin role management and oversight; depends on update_updated_at_column()
|
||||
'features/backup', // Admin backup feature; depends on update_updated_at_column()
|
||||
'features/notifications', // Depends on maintenance and documents
|
||||
'features/email-ingestion', // Depends on documents, notifications (extends email_templates)
|
||||
'features/terms-agreement', // Terms & Conditions acceptance audit trail
|
||||
'features/audit-log', // Centralized audit logging; independent
|
||||
'features/ownership-costs', // Depends on vehicles and documents; TCO recurring costs
|
||||
'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)
|
||||
|
||||
@@ -35,6 +35,7 @@ import { userImportRoutes } from './features/user-import';
|
||||
import { ownershipCostsRoutes } from './features/ownership-costs';
|
||||
import { subscriptionsRoutes, donationsRoutes, webhooksRoutes } from './features/subscriptions';
|
||||
import { ocrRoutes } from './features/ocr';
|
||||
import { emailIngestionWebhookRoutes, emailIngestionRoutes } from './features/email-ingestion';
|
||||
import { pool } from './core/config/database';
|
||||
import { configRoutes } from './core/config/config.routes';
|
||||
|
||||
@@ -96,7 +97,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env['NODE_ENV'],
|
||||
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr']
|
||||
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr', 'email-ingestion']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,7 +107,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
status: 'healthy',
|
||||
scope: 'api',
|
||||
timestamp: new Date().toISOString(),
|
||||
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr']
|
||||
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr', 'email-ingestion']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,6 +153,8 @@ async function buildApp(): Promise<FastifyInstance> {
|
||||
await app.register(subscriptionsRoutes, { prefix: '/api' });
|
||||
await app.register(donationsRoutes, { prefix: '/api' });
|
||||
await app.register(webhooksRoutes, { prefix: '/api' });
|
||||
await app.register(emailIngestionWebhookRoutes, { prefix: '/api' });
|
||||
await app.register(emailIngestionRoutes, { prefix: '/api' });
|
||||
await app.register(ocrRoutes, { prefix: '/api' });
|
||||
await app.register(configRoutes, { prefix: '/api' });
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
| Directory | What | When to read |
|
||||
| --------- | ---- | ------------ |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `storage/` | Storage abstraction and adapters | File storage, S3/filesystem |
|
||||
| `user-preferences/` | User preferences data and migrations | User settings storage |
|
||||
|
||||
@@ -41,14 +41,6 @@ const configSchema = z.object({
|
||||
audience: z.string(),
|
||||
}),
|
||||
|
||||
// External APIs configuration (optional)
|
||||
external: z.object({
|
||||
vpic: z.object({
|
||||
url: z.string(),
|
||||
timeout: z.string(),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
|
||||
// Service configuration
|
||||
service: z.object({
|
||||
name: z.string(),
|
||||
@@ -126,6 +118,7 @@ const secretsSchema = z.object({
|
||||
auth0_management_client_secret: z.string(),
|
||||
google_maps_api_key: z.string(),
|
||||
resend_api_key: z.string(),
|
||||
resend_webhook_secret: z.string().optional(),
|
||||
// Stripe secrets (API keys only - price IDs are config, not secrets)
|
||||
stripe_secret_key: z.string(),
|
||||
stripe_webhook_secret: z.string(),
|
||||
@@ -143,6 +136,10 @@ export interface AppConfiguration {
|
||||
getRedisUrl(): string;
|
||||
getAuth0Config(): { domain: string; audience: string; clientSecret: string };
|
||||
getAuth0ManagementConfig(): { domain: string; clientId: string; clientSecret: string };
|
||||
getResendConfig(): {
|
||||
apiKey: string;
|
||||
webhookSecret: string | undefined;
|
||||
};
|
||||
getStripeConfig(): {
|
||||
secretKey: string;
|
||||
webhookSecret: string;
|
||||
@@ -185,6 +182,7 @@ class ConfigurationLoader {
|
||||
'auth0-management-client-secret',
|
||||
'google-maps-api-key',
|
||||
'resend-api-key',
|
||||
'resend-webhook-secret',
|
||||
'stripe-secret-key',
|
||||
'stripe-webhook-secret',
|
||||
];
|
||||
@@ -250,6 +248,13 @@ class ConfigurationLoader {
|
||||
};
|
||||
},
|
||||
|
||||
getResendConfig() {
|
||||
return {
|
||||
apiKey: secrets.resend_api_key,
|
||||
webhookSecret: secrets.resend_webhook_secret,
|
||||
};
|
||||
},
|
||||
|
||||
getStripeConfig() {
|
||||
return {
|
||||
secretKey: secrets.stripe_secret_key,
|
||||
@@ -258,8 +263,11 @@ class ConfigurationLoader {
|
||||
},
|
||||
};
|
||||
|
||||
// Set RESEND_API_KEY in environment for EmailService
|
||||
// Set Resend environment variables for EmailService and webhook verification
|
||||
process.env['RESEND_API_KEY'] = secrets.resend_api_key;
|
||||
if (secrets.resend_webhook_secret) {
|
||||
process.env['RESEND_WEBHOOK_SECRET'] = secrets.resend_webhook_secret;
|
||||
}
|
||||
|
||||
logger.info('Configuration loaded successfully', {
|
||||
configSource: 'yaml',
|
||||
|
||||
@@ -29,7 +29,17 @@ export const FEATURE_TIERS: Record<string, FeatureConfig> = {
|
||||
'vehicle.vinDecode': {
|
||||
minTier: 'pro',
|
||||
name: 'VIN Decode',
|
||||
upgradePrompt: 'Upgrade to Pro to automatically decode VIN and populate vehicle details from the NHTSA database.',
|
||||
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;
|
||||
|
||||
|
||||
@@ -34,6 +34,30 @@ describe('feature-tiers', () => {
|
||||
expect(feature.name).toBe('Scan for Maintenance Schedule');
|
||||
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', () => {
|
||||
|
||||
@@ -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;
|
||||
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
|
||||
const query = `
|
||||
SELECT auth0_sub, email, role, revoked_at
|
||||
SELECT id, user_profile_id, email, role, revoked_at
|
||||
FROM admin_users
|
||||
WHERE auth0_sub = $1 AND revoked_at IS NULL
|
||||
WHERE user_profile_id = $1 AND revoked_at IS NULL
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
|
||||
@@ -121,11 +121,14 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
|
||||
const userId = request.user?.sub;
|
||||
if (!userId) {
|
||||
// Two identifiers: auth0Sub (external, for Auth0 API) and userId (internal UUID, for all DB operations)
|
||||
const auth0Sub = request.user?.sub;
|
||||
if (!auth0Sub) {
|
||||
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
|
||||
let email = request.user?.email;
|
||||
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 (!email || email.includes('@unknown.local')) {
|
||||
try {
|
||||
const auth0User = await auth0ManagementClient.getUser(userId);
|
||||
const auth0User = await auth0ManagementClient.getUser(auth0Sub);
|
||||
if (auth0User.email) {
|
||||
email = auth0User.email;
|
||||
emailVerified = auth0User.emailVerified;
|
||||
logger.info('Fetched email from Auth0 Management API', {
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
userId: auth0Sub.substring(0, 8) + '...',
|
||||
hasEmail: true,
|
||||
});
|
||||
}
|
||||
} catch (auth0Error) {
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create profile with correct email
|
||||
const profile = await profileRepo.getOrCreate(userId, {
|
||||
email: email || `${userId}@unknown.local`,
|
||||
const profile = await profileRepo.getOrCreate(auth0Sub, {
|
||||
email: email || `${auth0Sub}@unknown.local`,
|
||||
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.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
|
||||
if (!emailVerified) {
|
||||
try {
|
||||
const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(userId);
|
||||
const isVerifiedInAuth0 = await auth0ManagementClient.checkEmailVerified(auth0Sub);
|
||||
if (isVerifiedInAuth0 && !profile.emailVerified) {
|
||||
await profileRepo.updateEmailVerified(userId, true);
|
||||
emailVerified = true;
|
||||
@@ -197,7 +201,7 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
|
||||
} catch (profileError) {
|
||||
// Log but don't fail auth if profile fetch fails
|
||||
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',
|
||||
});
|
||||
// Fall back to JWT email if available
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('tier guard plugin', () => {
|
||||
// Mock authenticate to set userContext
|
||||
authenticateMock = jest.fn(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
userId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
@@ -48,7 +48,7 @@ describe('tier guard plugin', () => {
|
||||
it('allows access when user tier meets minimum', async () => {
|
||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
userId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
@@ -71,7 +71,7 @@ describe('tier guard plugin', () => {
|
||||
it('allows access when user tier exceeds minimum', async () => {
|
||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
userId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
@@ -130,7 +130,7 @@ describe('tier guard plugin', () => {
|
||||
it('allows pro tier access to pro feature', async () => {
|
||||
authenticateMock.mockImplementation(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|user123',
|
||||
userId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
email: 'user@example.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| `fuel-logs/` | Fuel consumption tracking | Fuel log CRUD, statistics |
|
||||
| `maintenance/` | Maintenance record management | Service records, reminders |
|
||||
| `notifications/` | Email and push notifications | Alert system, email templates |
|
||||
| `ocr/` | OCR proxy to mvp-ocr service | Image text extraction, async jobs |
|
||||
| `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 |
|
||||
| `ownership-costs/` | Ownership cost tracking and reports | Cost aggregation, expense analysis |
|
||||
| `platform/` | Vehicle data and VIN decoding | Make/model lookup, VIN validation |
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { AdminService } from '../domain/admin.service';
|
||||
import { AdminRepository } from '../data/admin.repository';
|
||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
CreateAdminInput,
|
||||
AdminAuth0SubInput,
|
||||
AdminIdInput,
|
||||
AuditLogsQueryInput,
|
||||
BulkCreateAdminInput,
|
||||
BulkRevokeAdminInput,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
} from './admin.validation';
|
||||
import {
|
||||
createAdminSchema,
|
||||
adminAuth0SubSchema,
|
||||
adminIdSchema,
|
||||
auditLogsQuerySchema,
|
||||
bulkCreateAdminSchema,
|
||||
bulkRevokeAdminSchema,
|
||||
@@ -33,10 +34,12 @@ import {
|
||||
|
||||
export class AdminController {
|
||||
private adminService: AdminService;
|
||||
private userProfileRepository: UserProfileRepository;
|
||||
|
||||
constructor() {
|
||||
const repository = new AdminRepository(pool);
|
||||
this.adminService = new AdminService(repository);
|
||||
this.userProfileRepository = new UserProfileRepository(pool);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,49 +50,18 @@ export class AdminController {
|
||||
const userId = request.userContext?.userId;
|
||||
const userEmail = this.resolveUserEmail(request);
|
||||
|
||||
console.log('[DEBUG] Admin verify - userId:', userId);
|
||||
console.log('[DEBUG] Admin verify - userEmail:', userEmail);
|
||||
|
||||
if (userEmail && request.userContext) {
|
||||
request.userContext.email = userEmail.toLowerCase();
|
||||
}
|
||||
|
||||
if (!userId && !userEmail) {
|
||||
console.log('[DEBUG] Admin verify - No userId or userEmail, returning 401');
|
||||
if (!userId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing'
|
||||
});
|
||||
}
|
||||
|
||||
let adminRecord = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
const adminRecord = await this.adminService.getAdminByUserProfileId(userId);
|
||||
|
||||
if (adminRecord && !adminRecord.revokedAt) {
|
||||
if (request.userContext) {
|
||||
@@ -97,12 +69,11 @@ export class AdminController {
|
||||
request.userContext.adminRecord = adminRecord;
|
||||
}
|
||||
|
||||
console.log('[DEBUG] Admin verify - Returning isAdmin: true');
|
||||
// User is an active admin
|
||||
return reply.code(200).send({
|
||||
isAdmin: true,
|
||||
adminRecord: {
|
||||
auth0Sub: adminRecord.auth0Sub,
|
||||
id: adminRecord.id,
|
||||
userProfileId: adminRecord.userProfileId,
|
||||
email: adminRecord.email,
|
||||
role: adminRecord.role
|
||||
}
|
||||
@@ -114,14 +85,11 @@ export class AdminController {
|
||||
request.userContext.adminRecord = undefined;
|
||||
}
|
||||
|
||||
console.log('[DEBUG] Admin verify - Returning isAdmin: false');
|
||||
// User is not an admin
|
||||
return reply.code(200).send({
|
||||
isAdmin: false,
|
||||
adminRecord: null
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('[DEBUG] Admin verify - Error caught:', error instanceof Error ? error.message : 'Unknown error');
|
||||
logger.error('Error verifying admin access', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
userId: request.userContext?.userId?.substring(0, 8) + '...'
|
||||
@@ -139,9 +107,9 @@ export class AdminController {
|
||||
*/
|
||||
async listAdmins(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
const actorUserProfileId = request.userContext?.userId;
|
||||
|
||||
if (!actorId) {
|
||||
if (!actorUserProfileId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing'
|
||||
@@ -150,11 +118,6 @@ export class AdminController {
|
||||
|
||||
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({
|
||||
total: admins.length,
|
||||
admins
|
||||
@@ -162,7 +125,7 @@ export class AdminController {
|
||||
} catch (error: any) {
|
||||
logger.error('Error listing admins', {
|
||||
error: error.message,
|
||||
actorId: request.userContext?.userId
|
||||
actorUserProfileId: request.userContext?.userId
|
||||
});
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
@@ -179,15 +142,24 @@ export class AdminController {
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
const actorUserProfileId = request.userContext?.userId;
|
||||
|
||||
if (!actorId) {
|
||||
if (!actorUserProfileId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
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
|
||||
const validation = createAdminSchema.safeParse(request.body);
|
||||
if (!validation.success) {
|
||||
@@ -200,23 +172,27 @@ export class AdminController {
|
||||
|
||||
const { email, role } = validation.data;
|
||||
|
||||
// Generate auth0Sub for the new admin
|
||||
// In production, this should be the actual Auth0 user ID
|
||||
// For now, we'll use email-based identifier
|
||||
const auth0Sub = `auth0|${email.replace('@', '_at_')}`;
|
||||
// Look up user profile by email to get UUID
|
||||
const userProfile = await this.userProfileRepository.getByEmail(email);
|
||||
if (!userProfile) {
|
||||
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(
|
||||
email,
|
||||
role,
|
||||
auth0Sub,
|
||||
actorId
|
||||
userProfile.id,
|
||||
actorAdmin.id
|
||||
);
|
||||
|
||||
return reply.code(201).send(admin);
|
||||
} catch (error: any) {
|
||||
logger.error('Error creating admin', {
|
||||
error: error.message,
|
||||
actorId: request.userContext?.userId
|
||||
actorUserProfileId: request.userContext?.userId
|
||||
});
|
||||
|
||||
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(
|
||||
request: FastifyRequest<{ Params: AdminAuth0SubInput }>,
|
||||
request: FastifyRequest<{ Params: AdminIdInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
const actorUserProfileId = request.userContext?.userId;
|
||||
|
||||
if (!actorId) {
|
||||
if (!actorUserProfileId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
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
|
||||
const validation = adminAuth0SubSchema.safeParse(request.params);
|
||||
const validation = adminIdSchema.safeParse(request.params);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid auth0Sub parameter',
|
||||
message: 'Invalid admin ID parameter',
|
||||
details: validation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = validation.data;
|
||||
const { id } = validation.data;
|
||||
|
||||
// Check if admin exists
|
||||
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
|
||||
const targetAdmin = await this.adminService.getAdminById(id);
|
||||
if (!targetAdmin) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
@@ -272,14 +257,14 @@ export class AdminController {
|
||||
}
|
||||
|
||||
// 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);
|
||||
} catch (error: any) {
|
||||
logger.error('Error revoking admin', {
|
||||
error: error.message,
|
||||
actorId: request.userContext?.userId,
|
||||
targetAuth0Sub: request.params.auth0Sub
|
||||
actorUserProfileId: request.userContext?.userId,
|
||||
targetAdminId: (request.params as any).id
|
||||
});
|
||||
|
||||
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(
|
||||
request: FastifyRequest<{ Params: AdminAuth0SubInput }>,
|
||||
request: FastifyRequest<{ Params: AdminIdInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
const actorUserProfileId = request.userContext?.userId;
|
||||
|
||||
if (!actorId) {
|
||||
if (!actorUserProfileId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
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
|
||||
const validation = adminAuth0SubSchema.safeParse(request.params);
|
||||
const validation = adminIdSchema.safeParse(request.params);
|
||||
if (!validation.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid auth0Sub parameter',
|
||||
message: 'Invalid admin ID parameter',
|
||||
details: validation.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = validation.data;
|
||||
const { id } = validation.data;
|
||||
|
||||
// Check if admin exists
|
||||
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
|
||||
const targetAdmin = await this.adminService.getAdminById(id);
|
||||
if (!targetAdmin) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
@@ -342,14 +336,14 @@ export class AdminController {
|
||||
}
|
||||
|
||||
// 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);
|
||||
} catch (error: any) {
|
||||
logger.error('Error reinstating admin', {
|
||||
error: error.message,
|
||||
actorId: request.userContext?.userId,
|
||||
targetAuth0Sub: request.params.auth0Sub
|
||||
actorUserProfileId: request.userContext?.userId,
|
||||
targetAdminId: (request.params as any).id
|
||||
});
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
@@ -418,15 +412,24 @@ export class AdminController {
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
const actorUserProfileId = request.userContext?.userId;
|
||||
|
||||
if (!actorId) {
|
||||
if (!actorUserProfileId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
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
|
||||
const validation = bulkCreateAdminSchema.safeParse(request.body);
|
||||
if (!validation.success) {
|
||||
@@ -447,15 +450,21 @@ export class AdminController {
|
||||
try {
|
||||
const { email, role = 'admin' } = adminInput;
|
||||
|
||||
// Generate auth0Sub for the new admin
|
||||
// In production, this should be the actual Auth0 user ID
|
||||
const auth0Sub = `auth0|${email.replace('@', '_at_')}`;
|
||||
// Look up user profile by email to get UUID
|
||||
const userProfile = await this.userProfileRepository.getByEmail(email);
|
||||
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(
|
||||
email,
|
||||
role,
|
||||
auth0Sub,
|
||||
actorId
|
||||
userProfile.id,
|
||||
actorAdmin.id
|
||||
);
|
||||
|
||||
created.push(admin);
|
||||
@@ -463,7 +472,7 @@ export class AdminController {
|
||||
logger.error('Error creating admin in bulk operation', {
|
||||
error: error.message,
|
||||
email: adminInput.email,
|
||||
actorId
|
||||
actorAdminId: actorAdmin.id
|
||||
});
|
||||
|
||||
failed.push({
|
||||
@@ -485,7 +494,7 @@ export class AdminController {
|
||||
} catch (error: any) {
|
||||
logger.error('Error in bulk create admins', {
|
||||
error: error.message,
|
||||
actorId: request.userContext?.userId
|
||||
actorUserProfileId: request.userContext?.userId
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
@@ -503,15 +512,24 @@ export class AdminController {
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
const actorUserProfileId = request.userContext?.userId;
|
||||
|
||||
if (!actorId) {
|
||||
if (!actorUserProfileId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
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
|
||||
const validation = bulkRevokeAdminSchema.safeParse(request.body);
|
||||
if (!validation.success) {
|
||||
@@ -522,37 +540,36 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Subs } = validation.data;
|
||||
const { ids } = validation.data;
|
||||
|
||||
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
|
||||
for (const auth0Sub of auth0Subs) {
|
||||
for (const id of ids) {
|
||||
try {
|
||||
// Check if admin exists
|
||||
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
|
||||
const targetAdmin = await this.adminService.getAdminById(id);
|
||||
if (!targetAdmin) {
|
||||
failed.push({
|
||||
auth0Sub,
|
||||
id,
|
||||
error: 'Admin user not found'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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);
|
||||
} catch (error: any) {
|
||||
logger.error('Error revoking admin in bulk operation', {
|
||||
error: error.message,
|
||||
auth0Sub,
|
||||
actorId
|
||||
adminId: id,
|
||||
actorAdminId: actorAdmin.id
|
||||
});
|
||||
|
||||
// Special handling for "last admin" constraint
|
||||
failed.push({
|
||||
auth0Sub,
|
||||
id,
|
||||
error: error.message || 'Failed to revoke admin'
|
||||
});
|
||||
}
|
||||
@@ -570,7 +587,7 @@ export class AdminController {
|
||||
} catch (error: any) {
|
||||
logger.error('Error in bulk revoke admins', {
|
||||
error: error.message,
|
||||
actorId: request.userContext?.userId
|
||||
actorUserProfileId: request.userContext?.userId
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
@@ -588,15 +605,24 @@ export class AdminController {
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const actorId = request.userContext?.userId;
|
||||
const actorUserProfileId = request.userContext?.userId;
|
||||
|
||||
if (!actorId) {
|
||||
if (!actorUserProfileId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
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
|
||||
const validation = bulkReinstateAdminSchema.safeParse(request.body);
|
||||
if (!validation.success) {
|
||||
@@ -607,36 +633,36 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Subs } = validation.data;
|
||||
const { ids } = validation.data;
|
||||
|
||||
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
|
||||
for (const auth0Sub of auth0Subs) {
|
||||
for (const id of ids) {
|
||||
try {
|
||||
// Check if admin exists
|
||||
const targetAdmin = await this.adminService.getAdminByAuth0Sub(auth0Sub);
|
||||
const targetAdmin = await this.adminService.getAdminById(id);
|
||||
if (!targetAdmin) {
|
||||
failed.push({
|
||||
auth0Sub,
|
||||
id,
|
||||
error: 'Admin user not found'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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);
|
||||
} catch (error: any) {
|
||||
logger.error('Error reinstating admin in bulk operation', {
|
||||
error: error.message,
|
||||
auth0Sub,
|
||||
actorId
|
||||
adminId: id,
|
||||
actorAdminId: actorAdmin.id
|
||||
});
|
||||
|
||||
failed.push({
|
||||
auth0Sub,
|
||||
id,
|
||||
error: error.message || 'Failed to reinstate admin'
|
||||
});
|
||||
}
|
||||
@@ -654,7 +680,7 @@ export class AdminController {
|
||||
} catch (error: any) {
|
||||
logger.error('Error in bulk reinstate admins', {
|
||||
error: error.message,
|
||||
actorId: request.userContext?.userId
|
||||
actorUserProfileId: request.userContext?.userId
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
@@ -665,9 +691,6 @@ export class AdminController {
|
||||
}
|
||||
|
||||
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> = [
|
||||
request.userContext?.email,
|
||||
(request as any).user?.email,
|
||||
@@ -676,15 +699,11 @@ export class AdminController {
|
||||
(request as any).user?.preferred_username,
|
||||
];
|
||||
|
||||
console.log('[DEBUG] resolveUserEmail - candidates:', candidates);
|
||||
|
||||
for (const value of candidates) {
|
||||
if (typeof value === 'string' && value.includes('@')) {
|
||||
console.log('[DEBUG] resolveUserEmail - found email:', value);
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
console.log('[DEBUG] resolveUserEmail - no email found');
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AdminController } from './admin.controller';
|
||||
import { UsersController } from './users.controller';
|
||||
import {
|
||||
CreateAdminInput,
|
||||
AdminAuth0SubInput,
|
||||
AdminIdInput,
|
||||
BulkCreateAdminInput,
|
||||
BulkRevokeAdminInput,
|
||||
BulkReinstateAdminInput,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from './admin.validation';
|
||||
import {
|
||||
ListUsersQueryInput,
|
||||
UserAuth0SubInput,
|
||||
UserIdInput,
|
||||
UpdateTierInput,
|
||||
DeactivateUserInput,
|
||||
UpdateProfileInput,
|
||||
@@ -65,14 +65,14 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
handler: adminController.createAdmin.bind(adminController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/admins/:auth0Sub/revoke - Revoke admin access
|
||||
fastify.patch<{ Params: AdminAuth0SubInput }>('/admin/admins/:auth0Sub/revoke', {
|
||||
// PATCH /api/admin/admins/:id/revoke - Revoke admin access
|
||||
fastify.patch<{ Params: AdminIdInput }>('/admin/admins/:id/revoke', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: adminController.revokeAdmin.bind(adminController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/admins/:auth0Sub/reinstate - Restore revoked admin
|
||||
fastify.patch<{ Params: AdminAuth0SubInput }>('/admin/admins/:auth0Sub/reinstate', {
|
||||
// PATCH /api/admin/admins/:id/reinstate - Restore revoked admin
|
||||
fastify.patch<{ Params: AdminIdInput }>('/admin/admins/:id/reinstate', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: adminController.reinstateAdmin.bind(adminController)
|
||||
});
|
||||
@@ -117,50 +117,50 @@ export const adminRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
handler: usersController.listUsers.bind(usersController)
|
||||
});
|
||||
|
||||
// GET /api/admin/users/:auth0Sub - Get single user details
|
||||
fastify.get<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub', {
|
||||
// GET /api/admin/users/:userId - Get single user details
|
||||
fastify.get<{ Params: UserIdInput }>('/admin/users/:userId', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.getUser.bind(usersController)
|
||||
});
|
||||
|
||||
// GET /api/admin/users/:auth0Sub/vehicles - Get user's vehicles (admin view)
|
||||
fastify.get<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub/vehicles', {
|
||||
// GET /api/admin/users/:userId/vehicles - Get user's vehicles (admin view)
|
||||
fastify.get<{ Params: UserIdInput }>('/admin/users/:userId/vehicles', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.getUserVehicles.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/tier - Update subscription tier
|
||||
fastify.patch<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>('/admin/users/:auth0Sub/tier', {
|
||||
// PATCH /api/admin/users/:userId/tier - Update subscription tier
|
||||
fastify.patch<{ Params: UserIdInput; Body: UpdateTierInput }>('/admin/users/:userId/tier', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.updateTier.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/deactivate - Soft delete user
|
||||
fastify.patch<{ Params: UserAuth0SubInput; Body: DeactivateUserInput }>('/admin/users/:auth0Sub/deactivate', {
|
||||
// PATCH /api/admin/users/:userId/deactivate - Soft delete user
|
||||
fastify.patch<{ Params: UserIdInput; Body: DeactivateUserInput }>('/admin/users/:userId/deactivate', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.deactivateUser.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/reactivate - Restore deactivated user
|
||||
fastify.patch<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub/reactivate', {
|
||||
// PATCH /api/admin/users/:userId/reactivate - Restore deactivated user
|
||||
fastify.patch<{ Params: UserIdInput }>('/admin/users/:userId/reactivate', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.reactivateUser.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/profile - Update user email/displayName
|
||||
fastify.patch<{ Params: UserAuth0SubInput; Body: UpdateProfileInput }>('/admin/users/:auth0Sub/profile', {
|
||||
// PATCH /api/admin/users/:userId/profile - Update user email/displayName
|
||||
fastify.patch<{ Params: UserIdInput; Body: UpdateProfileInput }>('/admin/users/:userId/profile', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.updateProfile.bind(usersController)
|
||||
});
|
||||
|
||||
// PATCH /api/admin/users/:auth0Sub/promote - Promote user to admin
|
||||
fastify.patch<{ Params: UserAuth0SubInput; Body: PromoteToAdminInput }>('/admin/users/:auth0Sub/promote', {
|
||||
// PATCH /api/admin/users/:userId/promote - Promote user to admin
|
||||
fastify.patch<{ Params: UserIdInput; Body: PromoteToAdminInput }>('/admin/users/:userId/promote', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.promoteToAdmin.bind(usersController)
|
||||
});
|
||||
|
||||
// DELETE /api/admin/users/:auth0Sub - Hard delete user (permanent)
|
||||
fastify.delete<{ Params: UserAuth0SubInput }>('/admin/users/:auth0Sub', {
|
||||
// DELETE /api/admin/users/:userId - Hard delete user (permanent)
|
||||
fastify.delete<{ Params: UserIdInput }>('/admin/users/:userId', {
|
||||
preHandler: [fastify.requireAdmin],
|
||||
handler: usersController.hardDeleteUser.bind(usersController)
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@ export const createAdminSchema = z.object({
|
||||
role: z.enum(['admin', 'super_admin']).default('admin'),
|
||||
});
|
||||
|
||||
export const adminAuth0SubSchema = z.object({
|
||||
auth0Sub: z.string().min(1, 'auth0Sub is required'),
|
||||
export const adminIdSchema = z.object({
|
||||
id: z.string().uuid('Invalid admin ID format'),
|
||||
});
|
||||
|
||||
export const auditLogsQuerySchema = z.object({
|
||||
@@ -29,14 +29,14 @@ export const bulkCreateAdminSchema = z.object({
|
||||
});
|
||||
|
||||
export const bulkRevokeAdminSchema = z.object({
|
||||
auth0Subs: z.array(z.string().min(1, 'auth0Sub cannot be empty'))
|
||||
.min(1, 'At least one auth0Sub must be provided')
|
||||
ids: z.array(z.string().uuid('Invalid admin ID format'))
|
||||
.min(1, 'At least one admin ID must be provided')
|
||||
.max(100, 'Maximum 100 admins per batch'),
|
||||
});
|
||||
|
||||
export const bulkReinstateAdminSchema = z.object({
|
||||
auth0Subs: z.array(z.string().min(1, 'auth0Sub cannot be empty'))
|
||||
.min(1, 'At least one auth0Sub must be provided')
|
||||
ids: z.array(z.string().uuid('Invalid admin ID format'))
|
||||
.min(1, 'At least one admin ID must be provided')
|
||||
.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 AdminAuth0SubInput = z.infer<typeof adminAuth0SubSchema>;
|
||||
export type AdminIdInput = z.infer<typeof adminIdSchema>;
|
||||
export type AuditLogsQueryInput = z.infer<typeof auditLogsQuerySchema>;
|
||||
export type BulkCreateAdminInput = z.infer<typeof bulkCreateAdminSchema>;
|
||||
export type BulkRevokeAdminInput = z.infer<typeof bulkRevokeAdminSchema>;
|
||||
|
||||
@@ -14,13 +14,13 @@ import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
listUsersQuerySchema,
|
||||
userAuth0SubSchema,
|
||||
userIdSchema,
|
||||
updateTierSchema,
|
||||
deactivateUserSchema,
|
||||
updateProfileSchema,
|
||||
promoteToAdminSchema,
|
||||
ListUsersQueryInput,
|
||||
UserAuth0SubInput,
|
||||
UserIdInput,
|
||||
UpdateTierInput,
|
||||
DeactivateUserInput,
|
||||
UpdateProfileInput,
|
||||
@@ -95,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(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
||||
request: FastifyRequest<{ Params: UserIdInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
@@ -119,7 +119,7 @@ export class UsersController {
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const parseResult = userAuth0SubSchema.safeParse(request.params);
|
||||
const parseResult = userIdSchema.safeParse(request.params);
|
||||
if (!parseResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
@@ -127,14 +127,14 @@ export class UsersController {
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = parseResult.data;
|
||||
const vehicles = await this.userProfileRepository.getUserVehiclesForAdmin(auth0Sub);
|
||||
const { userId } = parseResult.data;
|
||||
const vehicles = await this.userProfileRepository.getUserVehiclesForAdmin(userId);
|
||||
|
||||
return reply.code(200).send({ vehicles });
|
||||
} catch (error) {
|
||||
logger.error('Error getting user vehicles', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
userId: (request.params as any)?.userId,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
@@ -186,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(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
||||
request: FastifyRequest<{ Params: UserIdInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
@@ -202,7 +202,7 @@ export class UsersController {
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const parseResult = userAuth0SubSchema.safeParse(request.params);
|
||||
const parseResult = userIdSchema.safeParse(request.params);
|
||||
if (!parseResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
@@ -210,8 +210,8 @@ export class UsersController {
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = parseResult.data;
|
||||
const user = await this.userProfileService.getUserDetails(auth0Sub);
|
||||
const { userId } = parseResult.data;
|
||||
const user = await this.userProfileService.getUserDetails(userId);
|
||||
|
||||
if (!user) {
|
||||
return reply.code(404).send({
|
||||
@@ -224,7 +224,7 @@ export class UsersController {
|
||||
} catch (error) {
|
||||
logger.error('Error getting user details', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
userId: (request.params as any)?.userId,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
@@ -235,12 +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(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateTierInput }>,
|
||||
request: FastifyRequest<{ Params: UserIdInput; Body: UpdateTierInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
@@ -253,7 +253,7 @@ export class UsersController {
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
const paramsResult = userIdSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
@@ -270,11 +270,11 @@ export class UsersController {
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
const { userId } = paramsResult.data;
|
||||
const { subscriptionTier } = bodyResult.data;
|
||||
|
||||
// Verify user exists before attempting tier change
|
||||
const currentUser = await this.userProfileService.getUserDetails(auth0Sub);
|
||||
const currentUser = await this.userProfileService.getUserDetails(userId);
|
||||
if (!currentUser) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
@@ -285,34 +285,34 @@ export class UsersController {
|
||||
const previousTier = currentUser.subscriptionTier;
|
||||
|
||||
// Use subscriptionsService to update both tables atomically
|
||||
await this.subscriptionsService.adminOverrideTier(auth0Sub, subscriptionTier);
|
||||
await this.subscriptionsService.adminOverrideTier(userId, subscriptionTier);
|
||||
|
||||
// Log audit action
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorId,
|
||||
'UPDATE_TIER',
|
||||
auth0Sub,
|
||||
userId,
|
||||
'user_profile',
|
||||
currentUser.id,
|
||||
{ previousTier, newTier: subscriptionTier }
|
||||
);
|
||||
|
||||
logger.info('User subscription tier updated via admin', {
|
||||
auth0Sub,
|
||||
userId,
|
||||
previousTier,
|
||||
newTier: subscriptionTier,
|
||||
actorAuth0Sub: actorId,
|
||||
actorId,
|
||||
});
|
||||
|
||||
// Return updated user profile
|
||||
const updatedUser = await this.userProfileService.getUserDetails(auth0Sub);
|
||||
const updatedUser = await this.userProfileService.getUserDetails(userId);
|
||||
return reply.code(200).send(updatedUser);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
logger.error('Error updating user tier', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
userId: (request.params as any)?.userId,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User not found') {
|
||||
@@ -330,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(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: DeactivateUserInput }>,
|
||||
request: FastifyRequest<{ Params: UserIdInput; Body: DeactivateUserInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
@@ -346,7 +346,7 @@ export class UsersController {
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
const paramsResult = userIdSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
@@ -363,11 +363,11 @@ export class UsersController {
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
const { userId } = paramsResult.data;
|
||||
const { reason } = bodyResult.data;
|
||||
|
||||
const deactivatedUser = await this.userProfileService.deactivateUser(
|
||||
auth0Sub,
|
||||
userId,
|
||||
actorId,
|
||||
reason
|
||||
);
|
||||
@@ -378,7 +378,7 @@ export class UsersController {
|
||||
|
||||
logger.error('Error deactivating user', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
userId: (request.params as any)?.userId,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User not found') {
|
||||
@@ -410,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(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
||||
request: FastifyRequest<{ Params: UserIdInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
@@ -426,7 +426,7 @@ export class UsersController {
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
const paramsResult = userIdSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
@@ -434,10 +434,10 @@ export class UsersController {
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
const { userId } = paramsResult.data;
|
||||
|
||||
const reactivatedUser = await this.userProfileService.reactivateUser(
|
||||
auth0Sub,
|
||||
userId,
|
||||
actorId
|
||||
);
|
||||
|
||||
@@ -447,7 +447,7 @@ export class UsersController {
|
||||
|
||||
logger.error('Error reactivating user', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
userId: (request.params as any)?.userId,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User not found') {
|
||||
@@ -472,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(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: UpdateProfileInput }>,
|
||||
request: FastifyRequest<{ Params: UserIdInput; Body: UpdateProfileInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
@@ -488,7 +488,7 @@ export class UsersController {
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
const paramsResult = userIdSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
@@ -505,11 +505,11 @@ export class UsersController {
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
const { userId } = paramsResult.data;
|
||||
const updates = bodyResult.data;
|
||||
|
||||
const updatedUser = await this.userProfileService.adminUpdateProfile(
|
||||
auth0Sub,
|
||||
userId,
|
||||
updates,
|
||||
actorId
|
||||
);
|
||||
@@ -520,7 +520,7 @@ export class UsersController {
|
||||
|
||||
logger.error('Error updating user profile', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
userId: (request.params as any)?.userId,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User not found') {
|
||||
@@ -538,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(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput; Body: PromoteToAdminInput }>,
|
||||
request: FastifyRequest<{ Params: UserIdInput; Body: PromoteToAdminInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
@@ -554,7 +554,7 @@ export class UsersController {
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
const paramsResult = userIdSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
@@ -571,11 +571,11 @@ export class UsersController {
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
const { userId } = paramsResult.data;
|
||||
const { role } = bodyResult.data;
|
||||
|
||||
// Get the user profile first to verify they exist and get their email
|
||||
const user = await this.userProfileService.getUserDetails(auth0Sub);
|
||||
// Get the user profile to verify they exist and get their email
|
||||
const user = await this.userProfileService.getUserDetails(userId);
|
||||
if (!user) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not found',
|
||||
@@ -591,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(
|
||||
user.email,
|
||||
role,
|
||||
auth0Sub, // Use the real auth0Sub from the user profile
|
||||
actorId
|
||||
userId,
|
||||
actorAdmin?.id || actorId
|
||||
);
|
||||
|
||||
return reply.code(201).send(adminUser);
|
||||
@@ -605,7 +608,7 @@ export class UsersController {
|
||||
|
||||
logger.error('Error promoting user to admin', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
userId: (request.params as any)?.userId,
|
||||
});
|
||||
|
||||
if (errorMessage.includes('already exists')) {
|
||||
@@ -623,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(
|
||||
request: FastifyRequest<{ Params: UserAuth0SubInput }>,
|
||||
request: FastifyRequest<{ Params: UserIdInput }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
@@ -639,7 +642,7 @@ export class UsersController {
|
||||
}
|
||||
|
||||
// Validate path param
|
||||
const paramsResult = userAuth0SubSchema.safeParse(request.params);
|
||||
const paramsResult = userIdSchema.safeParse(request.params);
|
||||
if (!paramsResult.success) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation error',
|
||||
@@ -647,14 +650,14 @@ export class UsersController {
|
||||
});
|
||||
}
|
||||
|
||||
const { auth0Sub } = paramsResult.data;
|
||||
const { userId } = paramsResult.data;
|
||||
|
||||
// Optional reason from query params
|
||||
const reason = (request.query as any)?.reason;
|
||||
|
||||
// Hard delete user
|
||||
await this.userProfileService.adminHardDeleteUser(
|
||||
auth0Sub,
|
||||
userId,
|
||||
actorId,
|
||||
reason
|
||||
);
|
||||
@@ -667,7 +670,7 @@ export class UsersController {
|
||||
|
||||
logger.error('Error hard deleting user', {
|
||||
error: errorMessage,
|
||||
auth0Sub: request.params?.auth0Sub,
|
||||
userId: (request.params as any)?.userId,
|
||||
});
|
||||
|
||||
if (errorMessage === 'Cannot delete your own account') {
|
||||
|
||||
@@ -19,9 +19,9 @@ export const listUsersQuerySchema = z.object({
|
||||
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||
});
|
||||
|
||||
// Path param for user auth0Sub
|
||||
export const userAuth0SubSchema = z.object({
|
||||
auth0Sub: z.string().min(1, 'auth0Sub is required'),
|
||||
// Path param for user UUID
|
||||
export const userIdSchema = z.object({
|
||||
userId: z.string().uuid('Invalid user ID format'),
|
||||
});
|
||||
|
||||
// Body for updating subscription tier
|
||||
@@ -50,7 +50,7 @@ export const promoteToAdminSchema = z.object({
|
||||
|
||||
// Type exports
|
||||
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 DeactivateUserInput = z.infer<typeof deactivateUserSchema>;
|
||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
||||
|
||||
@@ -10,29 +10,49 @@ import { logger } from '../../../core/logging/logger';
|
||||
export class AdminRepository {
|
||||
constructor(private pool: Pool) {}
|
||||
|
||||
async getAdminByAuth0Sub(auth0Sub: string): Promise<AdminUser | null> {
|
||||
async getAdminById(id: string): Promise<AdminUser | null> {
|
||||
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
|
||||
WHERE auth0_sub = $1
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
const result = await this.pool.query(query, [id]);
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.mapRowToAdminUser(result.rows[0]);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
async getAdminByEmail(email: string): Promise<AdminUser | null> {
|
||||
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
|
||||
WHERE LOWER(email) = LOWER($1)
|
||||
LIMIT 1
|
||||
@@ -52,7 +72,7 @@ export class AdminRepository {
|
||||
|
||||
async getAllAdmins(): Promise<AdminUser[]> {
|
||||
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
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
@@ -68,7 +88,7 @@ export class AdminRepository {
|
||||
|
||||
async getActiveAdmins(): Promise<AdminUser[]> {
|
||||
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
|
||||
WHERE revoked_at IS NULL
|
||||
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 = `
|
||||
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)
|
||||
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 {
|
||||
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) {
|
||||
throw new Error('Failed to create admin user');
|
||||
}
|
||||
return this.mapRowToAdminUser(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error creating admin', { error, auth0Sub, email });
|
||||
logger.error('Error creating admin', { error, userProfileId, email });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async revokeAdmin(auth0Sub: string): Promise<AdminUser> {
|
||||
async revokeAdmin(id: string): Promise<AdminUser> {
|
||||
const query = `
|
||||
UPDATE admin_users
|
||||
SET revoked_at = CURRENT_TIMESTAMP
|
||||
WHERE auth0_sub = $1
|
||||
RETURNING auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
|
||||
WHERE id = $1
|
||||
RETURNING id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
const result = await this.pool.query(query, [id]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Admin user not found');
|
||||
}
|
||||
return this.mapRowToAdminUser(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error revoking admin', { error, auth0Sub });
|
||||
logger.error('Error revoking admin', { error, id });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async reinstateAdmin(auth0Sub: string): Promise<AdminUser> {
|
||||
async reinstateAdmin(id: string): Promise<AdminUser> {
|
||||
const query = `
|
||||
UPDATE admin_users
|
||||
SET revoked_at = NULL
|
||||
WHERE auth0_sub = $1
|
||||
RETURNING auth0_sub, email, role, created_at, created_by, revoked_at, updated_at
|
||||
WHERE id = $1
|
||||
RETURNING id, user_profile_id, email, role, created_at, created_by, revoked_at, updated_at
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
const result = await this.pool.query(query, [id]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Admin user not found');
|
||||
}
|
||||
return this.mapRowToAdminUser(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error reinstating admin', { error, auth0Sub });
|
||||
logger.error('Error reinstating admin', { error, id });
|
||||
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 {
|
||||
return {
|
||||
auth0Sub: row.auth0_sub,
|
||||
id: row.id,
|
||||
userProfileId: row.user_profile_id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
createdAt: new Date(row.created_at),
|
||||
|
||||
@@ -11,11 +11,20 @@ import { auditLogService } from '../../audit-log';
|
||||
export class AdminService {
|
||||
constructor(private repository: AdminRepository) {}
|
||||
|
||||
async getAdminByAuth0Sub(auth0Sub: string): Promise<AdminUser | null> {
|
||||
async getAdminById(id: string): Promise<AdminUser | null> {
|
||||
try {
|
||||
return await this.repository.getAdminByAuth0Sub(auth0Sub);
|
||||
return await this.repository.getAdminById(id);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -47,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 {
|
||||
// Check if admin already exists
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
@@ -57,10 +66,10 @@ export class AdminService {
|
||||
}
|
||||
|
||||
// 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 (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,
|
||||
role
|
||||
});
|
||||
@@ -68,10 +77,10 @@ export class AdminService {
|
||||
// Log to unified audit log
|
||||
await auditLogService.info(
|
||||
'admin',
|
||||
createdBy,
|
||||
userProfileId,
|
||||
`Admin user created: ${admin.email}`,
|
||||
'admin_user',
|
||||
admin.auth0Sub,
|
||||
admin.id,
|
||||
{ email: admin.email, role }
|
||||
).catch(err => logger.error('Failed to log admin create audit event', { error: err }));
|
||||
|
||||
@@ -83,7 +92,7 @@ export class AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
async revokeAdmin(auth0Sub: string, revokedBy: string): Promise<AdminUser> {
|
||||
async revokeAdmin(id: string, revokedByAdminId: string): Promise<AdminUser> {
|
||||
try {
|
||||
// Check that at least one active admin will remain
|
||||
const activeAdmins = await this.repository.getActiveAdmins();
|
||||
@@ -92,51 +101,51 @@ export class AdminService {
|
||||
}
|
||||
|
||||
// Revoke the admin
|
||||
const admin = await this.repository.revokeAdmin(auth0Sub);
|
||||
const admin = await this.repository.revokeAdmin(id);
|
||||
|
||||
// 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);
|
||||
|
||||
// Log to unified audit log
|
||||
await auditLogService.info(
|
||||
'admin',
|
||||
revokedBy,
|
||||
admin.userProfileId,
|
||||
`Admin user revoked: ${admin.email}`,
|
||||
'admin_user',
|
||||
auth0Sub,
|
||||
id,
|
||||
{ email: admin.email }
|
||||
).catch(err => logger.error('Failed to log admin revoke audit event', { error: err }));
|
||||
|
||||
logger.info('Admin user revoked', { auth0Sub, email: admin.email });
|
||||
logger.info('Admin user revoked', { id, email: admin.email });
|
||||
return admin;
|
||||
} catch (error) {
|
||||
logger.error('Error revoking admin', { error, auth0Sub });
|
||||
logger.error('Error revoking admin', { error, id });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async reinstateAdmin(auth0Sub: string, reinstatedBy: string): Promise<AdminUser> {
|
||||
async reinstateAdmin(id: string, reinstatedByAdminId: string): Promise<AdminUser> {
|
||||
try {
|
||||
// Reinstate the admin
|
||||
const admin = await this.repository.reinstateAdmin(auth0Sub);
|
||||
const admin = await this.repository.reinstateAdmin(id);
|
||||
|
||||
// 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);
|
||||
|
||||
// Log to unified audit log
|
||||
await auditLogService.info(
|
||||
'admin',
|
||||
reinstatedBy,
|
||||
admin.userProfileId,
|
||||
`Admin user reinstated: ${admin.email}`,
|
||||
'admin_user',
|
||||
auth0Sub,
|
||||
id,
|
||||
{ email: admin.email }
|
||||
).catch(err => logger.error('Failed to log admin reinstate audit event', { error: err }));
|
||||
|
||||
logger.info('Admin user reinstated', { auth0Sub, email: admin.email });
|
||||
logger.info('Admin user reinstated', { id, email: admin.email });
|
||||
return admin;
|
||||
} catch (error) {
|
||||
logger.error('Error reinstating admin', { error, auth0Sub });
|
||||
logger.error('Error reinstating admin', { error, id });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -150,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 {
|
||||
auth0Sub: string;
|
||||
id: string;
|
||||
userProfileId: string;
|
||||
email: string;
|
||||
role: 'admin' | 'super_admin';
|
||||
createdAt: Date;
|
||||
@@ -19,11 +20,11 @@ export interface CreateAdminRequest {
|
||||
}
|
||||
|
||||
export interface RevokeAdminRequest {
|
||||
auth0Sub: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ReinstateAdminRequest {
|
||||
auth0Sub: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AdminAuditLog {
|
||||
@@ -71,25 +72,25 @@ export interface BulkCreateAdminResponse {
|
||||
}
|
||||
|
||||
export interface BulkRevokeAdminRequest {
|
||||
auth0Subs: string[];
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
export interface BulkRevokeAdminResponse {
|
||||
revoked: AdminUser[];
|
||||
failed: Array<{
|
||||
auth0Sub: string;
|
||||
id: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface BulkReinstateAdminRequest {
|
||||
auth0Subs: string[];
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
export interface BulkReinstateAdminResponse {
|
||||
reinstated: AdminUser[];
|
||||
failed: Array<{
|
||||
auth0Sub: string;
|
||||
id: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -4,18 +4,19 @@
|
||||
*/
|
||||
|
||||
import request from 'supertest';
|
||||
import { app } from '../../../../app';
|
||||
import { buildApp } from '../../../../app';
|
||||
import pool from '../../../../core/config/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import fastifyPlugin from 'fastify-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';
|
||||
|
||||
let currentUser = {
|
||||
sub: DEFAULT_ADMIN_SUB,
|
||||
sub: 'auth0|test-admin-123',
|
||||
email: DEFAULT_ADMIN_EMAIL,
|
||||
};
|
||||
|
||||
@@ -25,11 +26,15 @@ jest.mock('../../../../core/plugins/auth.plugin', () => {
|
||||
default: fastifyPlugin(async function(fastify) {
|
||||
fastify.decorate('authenticate', async function(request, _reply) {
|
||||
// Inject dynamic test user context
|
||||
// JWT sub is still auth0|xxx format
|
||||
request.user = { sub: currentUser.sub };
|
||||
request.userContext = {
|
||||
userId: currentUser.sub,
|
||||
userId: DEFAULT_ADMIN_ID,
|
||||
email: currentUser.email,
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
isAdmin: false, // Will be set by admin guard
|
||||
subscriptionTier: 'free',
|
||||
};
|
||||
});
|
||||
}, { name: 'auth-plugin' })
|
||||
@@ -37,10 +42,14 @@ jest.mock('../../../../core/plugins/auth.plugin', () => {
|
||||
});
|
||||
|
||||
describe('Admin Management Integration Tests', () => {
|
||||
let testAdminAuth0Sub: string;
|
||||
let testNonAdminAuth0Sub: string;
|
||||
let app: FastifyInstance;
|
||||
let testAdminId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Build the app
|
||||
app = await buildApp();
|
||||
await app.ready();
|
||||
|
||||
// Run the admin migration directly using the migration file
|
||||
const migrationFile = join(__dirname, '../../migrations/001_create_admin_users.sql');
|
||||
const migrationSQL = readFileSync(migrationFile, 'utf-8');
|
||||
@@ -50,33 +59,31 @@ describe('Admin Management Integration Tests', () => {
|
||||
setAdminGuardPool(pool);
|
||||
|
||||
// Create test admin user
|
||||
testAdminAuth0Sub = DEFAULT_ADMIN_SUB;
|
||||
testAdminId = DEFAULT_ADMIN_ID;
|
||||
await pool.query(`
|
||||
INSERT INTO admin_users (auth0_sub, email, role, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (auth0_sub) DO NOTHING
|
||||
`, [testAdminAuth0Sub, DEFAULT_ADMIN_EMAIL, 'admin', 'system']);
|
||||
|
||||
// Create test non-admin auth0Sub for permission tests
|
||||
testNonAdminAuth0Sub = 'test-non-admin-456';
|
||||
INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_profile_id) DO NOTHING
|
||||
`, [testAdminId, testAdminId, DEFAULT_ADMIN_EMAIL, 'admin', 'system']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test database
|
||||
await pool.query('DROP TABLE IF EXISTS admin_audit_logs CASCADE');
|
||||
await pool.query('DROP TABLE IF EXISTS admin_users CASCADE');
|
||||
await app.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up test data before each test (except the test admin)
|
||||
await pool.query(
|
||||
'DELETE FROM admin_users WHERE auth0_sub != $1 AND auth0_sub != $2',
|
||||
[testAdminAuth0Sub, 'system|bootstrap']
|
||||
'DELETE FROM admin_users WHERE user_profile_id != $1',
|
||||
[testAdminId]
|
||||
);
|
||||
await pool.query('DELETE FROM admin_audit_logs');
|
||||
currentUser = {
|
||||
sub: DEFAULT_ADMIN_SUB,
|
||||
sub: 'auth0|test-admin-123',
|
||||
email: DEFAULT_ADMIN_EMAIL,
|
||||
};
|
||||
});
|
||||
@@ -85,11 +92,11 @@ describe('Admin Management Integration Tests', () => {
|
||||
it('should reject non-admin user trying to list admins', async () => {
|
||||
// Create mock for non-admin user
|
||||
currentUser = {
|
||||
sub: testNonAdminAuth0Sub,
|
||||
sub: 'auth0|test-non-admin-456',
|
||||
email: 'test-user@example.com',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
const response = await request(app.server)
|
||||
.get('/api/admin/admins')
|
||||
.expect(403);
|
||||
|
||||
@@ -101,51 +108,51 @@ describe('Admin Management Integration Tests', () => {
|
||||
describe('GET /api/admin/verify', () => {
|
||||
it('should confirm admin access for existing admin', async () => {
|
||||
currentUser = {
|
||||
sub: testAdminAuth0Sub,
|
||||
sub: 'auth0|test-admin-123',
|
||||
email: DEFAULT_ADMIN_EMAIL,
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
const response = await request(app.server)
|
||||
.get('/api/admin/verify')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.isAdmin).toBe(true);
|
||||
expect(response.body.adminRecord).toMatchObject({
|
||||
auth0Sub: testAdminAuth0Sub,
|
||||
id: testAdminId,
|
||||
email: DEFAULT_ADMIN_EMAIL,
|
||||
});
|
||||
});
|
||||
|
||||
it('should link admin record by email when auth0_sub differs', async () => {
|
||||
const placeholderSub = 'auth0|placeholder-sub';
|
||||
const realSub = 'auth0|real-admin-sub';
|
||||
it('should link admin record by email when user_profile_id differs', async () => {
|
||||
const placeholderId = '9b9a1234-1234-1234-1234-123456789abc';
|
||||
const realId = 'a1b2c3d4-5678-90ab-cdef-123456789def';
|
||||
const email = 'link-admin@example.com';
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO admin_users (auth0_sub, email, role, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, [placeholderSub, email, 'admin', testAdminAuth0Sub]);
|
||||
INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, [placeholderId, placeholderId, email, 'admin', testAdminId]);
|
||||
|
||||
currentUser = {
|
||||
sub: realSub,
|
||||
sub: 'auth0|real-admin-sub',
|
||||
email,
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
const response = await request(app.server)
|
||||
.get('/api/admin/verify')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.isAdmin).toBe(true);
|
||||
expect(response.body.adminRecord).toMatchObject({
|
||||
auth0Sub: realSub,
|
||||
userProfileId: realId,
|
||||
email,
|
||||
});
|
||||
|
||||
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]
|
||||
);
|
||||
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 () => {
|
||||
@@ -154,7 +161,7 @@ describe('Admin Management Integration Tests', () => {
|
||||
email: 'non-admin@example.com',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
const response = await request(app.server)
|
||||
.get('/api/admin/verify')
|
||||
.expect(200);
|
||||
|
||||
@@ -166,17 +173,19 @@ describe('Admin Management Integration Tests', () => {
|
||||
describe('GET /api/admin/admins', () => {
|
||||
it('should list all admin users', async () => {
|
||||
// Create additional test admins
|
||||
const admin1Id = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
||||
const admin2Id = '8f14e45f-ceea-367f-a27f-c9a6d0c67e0e';
|
||||
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),
|
||||
($5, $6, $7, $8)
|
||||
($1, $2, $3, $4, $5),
|
||||
($6, $7, $8, $9, $10)
|
||||
`, [
|
||||
'auth0|admin1', 'admin1@example.com', 'admin', testAdminAuth0Sub,
|
||||
'auth0|admin2', 'admin2@example.com', 'super_admin', testAdminAuth0Sub
|
||||
admin1Id, admin1Id, 'admin1@example.com', 'admin', testAdminId,
|
||||
admin2Id, admin2Id, 'admin2@example.com', 'super_admin', testAdminId
|
||||
]);
|
||||
|
||||
const response = await request(app)
|
||||
const response = await request(app.server)
|
||||
.get('/api/admin/admins')
|
||||
.expect(200);
|
||||
|
||||
@@ -184,7 +193,7 @@ describe('Admin Management Integration Tests', () => {
|
||||
expect(response.body).toHaveProperty('admins');
|
||||
expect(response.body.admins.length).toBeGreaterThanOrEqual(3); // At least test admin + 2 created
|
||||
expect(response.body.admins[0]).toMatchObject({
|
||||
auth0Sub: expect.any(String),
|
||||
id: expect.any(String),
|
||||
email: expect.any(String),
|
||||
role: expect.stringMatching(/^(admin|super_admin)$/),
|
||||
createdAt: expect.any(String),
|
||||
@@ -194,12 +203,13 @@ describe('Admin Management Integration Tests', () => {
|
||||
|
||||
it('should include revoked admins in the list', async () => {
|
||||
// Create and revoke an admin
|
||||
const revokedId = 'f1e2d3c4-b5a6-9788-6543-210fedcba987';
|
||||
await pool.query(`
|
||||
INSERT INTO admin_users (auth0_sub, email, role, created_by, revoked_at)
|
||||
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
||||
`, ['auth0|revoked', 'revoked@example.com', 'admin', testAdminAuth0Sub]);
|
||||
INSERT INTO admin_users (id, user_profile_id, email, role, created_by, revoked_at)
|
||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||
`, [revokedId, revokedId, 'revoked@example.com', 'admin', testAdminId]);
|
||||
|
||||
const response = await request(app)
|
||||
const response = await request(app.server)
|
||||
.get('/api/admin/admins')
|
||||
.expect(200);
|
||||
|
||||
@@ -218,17 +228,17 @@ describe('Admin Management Integration Tests', () => {
|
||||
role: 'admin'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
const response = await request(app.server)
|
||||
.post('/api/admin/admins')
|
||||
.send(newAdminData)
|
||||
.expect(201);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
auth0Sub: expect.any(String),
|
||||
id: expect.any(String),
|
||||
email: 'newadmin@example.com',
|
||||
role: 'admin',
|
||||
createdAt: expect.any(String),
|
||||
createdBy: testAdminAuth0Sub,
|
||||
createdBy: testAdminId,
|
||||
revokedAt: null
|
||||
});
|
||||
|
||||
@@ -238,7 +248,7 @@ describe('Admin Management Integration Tests', () => {
|
||||
['CREATE', 'newadmin@example.com']
|
||||
);
|
||||
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 () => {
|
||||
@@ -247,7 +257,7 @@ describe('Admin Management Integration Tests', () => {
|
||||
role: 'admin'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
const response = await request(app.server)
|
||||
.post('/api/admin/admins')
|
||||
.send(invalidData)
|
||||
.expect(400);
|
||||
@@ -263,13 +273,13 @@ describe('Admin Management Integration Tests', () => {
|
||||
};
|
||||
|
||||
// Create first admin
|
||||
await request(app)
|
||||
await request(app.server)
|
||||
.post('/api/admin/admins')
|
||||
.send(adminData)
|
||||
.expect(201);
|
||||
|
||||
// Try to create duplicate
|
||||
const response = await request(app)
|
||||
const response = await request(app.server)
|
||||
.post('/api/admin/admins')
|
||||
.send(adminData)
|
||||
.expect(400);
|
||||
@@ -284,7 +294,7 @@ describe('Admin Management Integration Tests', () => {
|
||||
role: 'super_admin'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
const response = await request(app.server)
|
||||
.post('/api/admin/admins')
|
||||
.send(superAdminData)
|
||||
.expect(201);
|
||||
@@ -297,7 +307,7 @@ describe('Admin Management Integration Tests', () => {
|
||||
email: 'defaultrole@example.com'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
const response = await request(app.server)
|
||||
.post('/api/admin/admins')
|
||||
.send(adminData)
|
||||
.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 () => {
|
||||
// Create admin to revoke
|
||||
const toRevokeId = 'b1c2d3e4-f5a6-7890-1234-567890abcdef';
|
||||
const createResult = await pool.query(`
|
||||
INSERT INTO admin_users (auth0_sub, email, role, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING auth0_sub
|
||||
`, ['auth0|to-revoke', 'torevoke@example.com', 'admin', testAdminAuth0Sub]);
|
||||
INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`, [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)
|
||||
.patch(`/api/admin/admins/${auth0Sub}/revoke`)
|
||||
const response = await request(app.server)
|
||||
.patch(`/api/admin/admins/${adminId}/revoke`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
auth0Sub,
|
||||
id: adminId,
|
||||
email: 'torevoke@example.com',
|
||||
revokedAt: expect.any(String)
|
||||
});
|
||||
@@ -330,7 +341,7 @@ describe('Admin Management Integration Tests', () => {
|
||||
// Verify audit log
|
||||
const auditResult = await pool.query(
|
||||
'SELECT * FROM admin_audit_logs WHERE action = $1 AND target_admin_id = $2',
|
||||
['REVOKE', auth0Sub]
|
||||
['REVOKE', adminId]
|
||||
);
|
||||
expect(auditResult.rows.length).toBe(1);
|
||||
});
|
||||
@@ -338,12 +349,12 @@ describe('Admin Management Integration Tests', () => {
|
||||
it('should prevent revoking last active admin', async () => {
|
||||
// First, ensure only one active admin exists
|
||||
await pool.query(
|
||||
'UPDATE admin_users SET revoked_at = CURRENT_TIMESTAMP WHERE auth0_sub != $1',
|
||||
[testAdminAuth0Sub]
|
||||
'UPDATE admin_users SET revoked_at = CURRENT_TIMESTAMP WHERE user_profile_id != $1',
|
||||
[testAdminId]
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.patch(`/api/admin/admins/${testAdminAuth0Sub}/revoke`)
|
||||
const response = await request(app.server)
|
||||
.patch(`/api/admin/admins/${testAdminId}/revoke`)
|
||||
.expect(400);
|
||||
|
||||
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 () => {
|
||||
const response = await request(app)
|
||||
.patch('/api/admin/admins/auth0|nonexistent/revoke')
|
||||
const response = await request(app.server)
|
||||
.patch('/api/admin/admins/00000000-0000-0000-0000-000000000000/revoke')
|
||||
.expect(404);
|
||||
|
||||
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 () => {
|
||||
// Create revoked admin
|
||||
const reinstateId = 'c2d3e4f5-a6b7-8901-2345-678901bcdef0';
|
||||
const createResult = await pool.query(`
|
||||
INSERT INTO admin_users (auth0_sub, email, role, created_by, revoked_at)
|
||||
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
||||
RETURNING auth0_sub
|
||||
`, ['auth0|to-reinstate', 'toreinstate@example.com', 'admin', testAdminAuth0Sub]);
|
||||
INSERT INTO admin_users (id, user_profile_id, email, role, created_by, revoked_at)
|
||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
`, [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)
|
||||
.patch(`/api/admin/admins/${auth0Sub}/reinstate`)
|
||||
const response = await request(app.server)
|
||||
.patch(`/api/admin/admins/${adminId}/reinstate`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
auth0Sub,
|
||||
id: adminId,
|
||||
email: 'toreinstate@example.com',
|
||||
revokedAt: null
|
||||
});
|
||||
@@ -384,14 +396,14 @@ describe('Admin Management Integration Tests', () => {
|
||||
// Verify audit log
|
||||
const auditResult = await pool.query(
|
||||
'SELECT * FROM admin_audit_logs WHERE action = $1 AND target_admin_id = $2',
|
||||
['REINSTATE', auth0Sub]
|
||||
['REINSTATE', adminId]
|
||||
);
|
||||
expect(auditResult.rows.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent admin', async () => {
|
||||
const response = await request(app)
|
||||
.patch('/api/admin/admins/auth0|nonexistent/reinstate')
|
||||
const response = await request(app.server)
|
||||
.patch('/api/admin/admins/00000000-0000-0000-0000-000000000000/reinstate')
|
||||
.expect(404);
|
||||
|
||||
expect(response.body.error).toBe('Not Found');
|
||||
@@ -400,16 +412,17 @@ describe('Admin Management Integration Tests', () => {
|
||||
|
||||
it('should handle reinstating already active admin', async () => {
|
||||
// Create active admin
|
||||
const activeId = 'd3e4f5a6-b7c8-9012-3456-789012cdef01';
|
||||
const createResult = await pool.query(`
|
||||
INSERT INTO admin_users (auth0_sub, email, role, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING auth0_sub
|
||||
`, ['auth0|already-active', 'active@example.com', 'admin', testAdminAuth0Sub]);
|
||||
INSERT INTO admin_users (id, user_profile_id, email, role, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`, [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)
|
||||
.patch(`/api/admin/admins/${auth0Sub}/reinstate`)
|
||||
const response = await request(app.server)
|
||||
.patch(`/api/admin/admins/${adminId}/reinstate`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.revokedAt).toBeNull();
|
||||
@@ -426,12 +439,12 @@ describe('Admin Management Integration Tests', () => {
|
||||
($5, $6, $7, $8),
|
||||
($9, $10, $11, $12)
|
||||
`, [
|
||||
testAdminAuth0Sub, 'CREATE', 'admin_user', 'test1@example.com',
|
||||
testAdminAuth0Sub, 'REVOKE', 'admin_user', 'test2@example.com',
|
||||
testAdminAuth0Sub, 'REINSTATE', 'admin_user', 'test3@example.com'
|
||||
testAdminId, 'CREATE', 'admin_user', 'test1@example.com',
|
||||
testAdminId, 'REVOKE', 'admin_user', 'test2@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')
|
||||
.expect(200);
|
||||
|
||||
@@ -440,7 +453,7 @@ describe('Admin Management Integration Tests', () => {
|
||||
expect(response.body.logs.length).toBeGreaterThanOrEqual(3);
|
||||
expect(response.body.logs[0]).toMatchObject({
|
||||
id: expect.any(String),
|
||||
actorAdminId: testAdminAuth0Sub,
|
||||
actorAdminId: testAdminId,
|
||||
action: expect.any(String),
|
||||
resourceType: expect.any(String),
|
||||
createdAt: expect.any(String)
|
||||
@@ -453,10 +466,10 @@ describe('Admin Management Integration Tests', () => {
|
||||
await pool.query(`
|
||||
INSERT INTO admin_audit_logs (actor_admin_id, action, resource_type, resource_id)
|
||||
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')
|
||||
.expect(200);
|
||||
|
||||
@@ -473,12 +486,12 @@ describe('Admin Management Integration Tests', () => {
|
||||
($3, $4, CURRENT_TIMESTAMP - INTERVAL '1 minute'),
|
||||
($5, $6, CURRENT_TIMESTAMP)
|
||||
`, [
|
||||
testAdminAuth0Sub, 'FIRST',
|
||||
testAdminAuth0Sub, 'SECOND',
|
||||
testAdminAuth0Sub, 'THIRD'
|
||||
testAdminId, 'FIRST',
|
||||
testAdminId, 'SECOND',
|
||||
testAdminId, 'THIRD'
|
||||
]);
|
||||
|
||||
const response = await request(app)
|
||||
const response = await request(app.server)
|
||||
.get('/api/admin/audit-logs?limit=3')
|
||||
.expect(200);
|
||||
|
||||
@@ -491,45 +504,45 @@ describe('Admin Management Integration Tests', () => {
|
||||
describe('End-to-end workflow', () => {
|
||||
it('should create, revoke, and reinstate admin with full audit trail', async () => {
|
||||
// 1. Create new admin
|
||||
const createResponse = await request(app)
|
||||
const createResponse = await request(app.server)
|
||||
.post('/api/admin/admins')
|
||||
.send({ email: 'workflow@example.com', role: 'admin' })
|
||||
.expect(201);
|
||||
|
||||
const auth0Sub = createResponse.body.auth0Sub;
|
||||
const adminId = createResponse.body.id;
|
||||
|
||||
// 2. Verify admin appears in list
|
||||
const listResponse = await request(app)
|
||||
const listResponse = await request(app.server)
|
||||
.get('/api/admin/admins')
|
||||
.expect(200);
|
||||
|
||||
const createdAdmin = listResponse.body.admins.find(
|
||||
(admin: any) => admin.auth0Sub === auth0Sub
|
||||
(admin: any) => admin.id === adminId
|
||||
);
|
||||
expect(createdAdmin).toBeDefined();
|
||||
expect(createdAdmin.revokedAt).toBeNull();
|
||||
|
||||
// 3. Revoke admin
|
||||
const revokeResponse = await request(app)
|
||||
.patch(`/api/admin/admins/${auth0Sub}/revoke`)
|
||||
const revokeResponse = await request(app.server)
|
||||
.patch(`/api/admin/admins/${adminId}/revoke`)
|
||||
.expect(200);
|
||||
|
||||
expect(revokeResponse.body.revokedAt).toBeTruthy();
|
||||
|
||||
// 4. Reinstate admin
|
||||
const reinstateResponse = await request(app)
|
||||
.patch(`/api/admin/admins/${auth0Sub}/reinstate`)
|
||||
const reinstateResponse = await request(app.server)
|
||||
.patch(`/api/admin/admins/${adminId}/reinstate`)
|
||||
.expect(200);
|
||||
|
||||
expect(reinstateResponse.body.revokedAt).toBeNull();
|
||||
|
||||
// 5. Verify complete audit trail
|
||||
const auditResponse = await request(app)
|
||||
const auditResponse = await request(app.server)
|
||||
.get('/api/admin/audit-logs')
|
||||
.expect(200);
|
||||
|
||||
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);
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('admin guard plugin', () => {
|
||||
fastify = Fastify();
|
||||
authenticateMock = jest.fn(async (request: FastifyRequest) => {
|
||||
request.userContext = {
|
||||
userId: 'auth0|admin',
|
||||
userId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
|
||||
email: 'admin@motovaultpro.com',
|
||||
emailVerified: true,
|
||||
onboardingCompleted: true,
|
||||
@@ -41,7 +41,7 @@ describe('admin guard plugin', () => {
|
||||
mockPool = {
|
||||
query: jest.fn().mockResolvedValue({
|
||||
rows: [{
|
||||
auth0_sub: 'auth0|admin',
|
||||
user_profile_id: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
|
||||
email: 'admin@motovaultpro.com',
|
||||
role: 'admin',
|
||||
revoked_at: null,
|
||||
|
||||
@@ -6,13 +6,23 @@
|
||||
import { AdminService } from '../../domain/admin.service';
|
||||
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', () => {
|
||||
let adminService: AdminService;
|
||||
let mockRepository: jest.Mocked<AdminRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepository = {
|
||||
getAdminByAuth0Sub: jest.fn(),
|
||||
getAdminById: jest.fn(),
|
||||
getAdminByUserProfileId: jest.fn(),
|
||||
getAdminByEmail: jest.fn(),
|
||||
getAllAdmins: jest.fn(),
|
||||
getActiveAdmins: jest.fn(),
|
||||
@@ -26,30 +36,31 @@ describe('AdminService', () => {
|
||||
adminService = new AdminService(mockRepository);
|
||||
});
|
||||
|
||||
describe('getAdminByAuth0Sub', () => {
|
||||
describe('getAdminById', () => {
|
||||
it('should return admin when found', async () => {
|
||||
const mockAdmin = {
|
||||
auth0Sub: 'auth0|123456',
|
||||
id: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
|
||||
userProfileId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
|
||||
email: 'admin@motovaultpro.com',
|
||||
role: 'admin',
|
||||
role: 'admin' as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: 'system',
|
||||
createdBy: '550e8400-e29b-41d4-a716-446655440000',
|
||||
revokedAt: null,
|
||||
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(mockRepository.getAdminByAuth0Sub).toHaveBeenCalledWith('auth0|123456');
|
||||
expect(mockRepository.getAdminById).toHaveBeenCalledWith('7c9e6679-7425-40de-944b-e07fc1f90ae7');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -57,12 +68,15 @@ describe('AdminService', () => {
|
||||
|
||||
describe('createAdmin', () => {
|
||||
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 = {
|
||||
auth0Sub: 'auth0|newadmin',
|
||||
id: newAdminId,
|
||||
userProfileId: newAdminId,
|
||||
email: 'newadmin@motovaultpro.com',
|
||||
role: 'admin',
|
||||
role: 'admin' as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: 'auth0|existing',
|
||||
createdBy: creatorId,
|
||||
revokedAt: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -74,16 +88,16 @@ describe('AdminService', () => {
|
||||
const result = await adminService.createAdmin(
|
||||
'newadmin@motovaultpro.com',
|
||||
'admin',
|
||||
'auth0|newadmin',
|
||||
'auth0|existing'
|
||||
newAdminId,
|
||||
creatorId
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockAdmin);
|
||||
expect(mockRepository.createAdmin).toHaveBeenCalled();
|
||||
expect(mockRepository.logAuditAction).toHaveBeenCalledWith(
|
||||
'auth0|existing',
|
||||
creatorId,
|
||||
'CREATE',
|
||||
mockAdmin.auth0Sub,
|
||||
mockAdmin.id,
|
||||
'admin_user',
|
||||
mockAdmin.email,
|
||||
expect.any(Object)
|
||||
@@ -91,12 +105,14 @@ describe('AdminService', () => {
|
||||
});
|
||||
|
||||
it('should reject if admin already exists', async () => {
|
||||
const existingId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
const existingAdmin = {
|
||||
auth0Sub: 'auth0|existing',
|
||||
id: existingId,
|
||||
userProfileId: existingId,
|
||||
email: 'admin@motovaultpro.com',
|
||||
role: 'admin',
|
||||
role: 'admin' as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: 'system',
|
||||
createdBy: '550e8400-e29b-41d4-a716-446655440000',
|
||||
revokedAt: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -104,39 +120,46 @@ describe('AdminService', () => {
|
||||
mockRepository.getAdminByEmail.mockResolvedValue(existingAdmin);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeAdmin', () => {
|
||||
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 = {
|
||||
auth0Sub: 'auth0|toadmin',
|
||||
id: toRevokeId,
|
||||
userProfileId: toRevokeId,
|
||||
email: 'toadmin@motovaultpro.com',
|
||||
role: 'admin',
|
||||
role: 'admin' as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: 'system',
|
||||
createdBy: '550e8400-e29b-41d4-a716-446655440000',
|
||||
revokedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const activeAdmins = [
|
||||
{
|
||||
auth0Sub: 'auth0|admin1',
|
||||
id: admin1Id,
|
||||
userProfileId: admin1Id,
|
||||
email: 'admin1@motovaultpro.com',
|
||||
role: 'admin',
|
||||
role: 'admin' as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: 'system',
|
||||
createdBy: '550e8400-e29b-41d4-a716-446655440000',
|
||||
revokedAt: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
auth0Sub: 'auth0|admin2',
|
||||
id: admin2Id,
|
||||
userProfileId: admin2Id,
|
||||
email: 'admin2@motovaultpro.com',
|
||||
role: 'admin',
|
||||
role: 'admin' as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: 'system',
|
||||
createdBy: '550e8400-e29b-41d4-a716-446655440000',
|
||||
revokedAt: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
@@ -146,20 +169,22 @@ describe('AdminService', () => {
|
||||
mockRepository.revokeAdmin.mockResolvedValue(revokedAdmin);
|
||||
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(mockRepository.revokeAdmin).toHaveBeenCalledWith('auth0|toadmin');
|
||||
expect(mockRepository.revokeAdmin).toHaveBeenCalledWith(toRevokeId);
|
||||
expect(mockRepository.logAuditAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prevent revoking last active admin', async () => {
|
||||
const lastAdminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
const lastAdmin = {
|
||||
auth0Sub: 'auth0|lastadmin',
|
||||
id: lastAdminId,
|
||||
userProfileId: lastAdminId,
|
||||
email: 'last@motovaultpro.com',
|
||||
role: 'admin',
|
||||
role: 'admin' as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: 'system',
|
||||
createdBy: '550e8400-e29b-41d4-a716-446655440000',
|
||||
revokedAt: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -167,19 +192,22 @@ describe('AdminService', () => {
|
||||
mockRepository.getActiveAdmins.mockResolvedValue([lastAdmin]);
|
||||
|
||||
await expect(
|
||||
adminService.revokeAdmin('auth0|lastadmin', 'auth0|lastadmin')
|
||||
adminService.revokeAdmin(lastAdminId, lastAdminId)
|
||||
).rejects.toThrow('Cannot revoke the last active admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reinstateAdmin', () => {
|
||||
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 = {
|
||||
auth0Sub: 'auth0|reinstate',
|
||||
id: reinstateId,
|
||||
userProfileId: reinstateId,
|
||||
email: 'reinstate@motovaultpro.com',
|
||||
role: 'admin',
|
||||
role: 'admin' as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: 'system',
|
||||
createdBy: '550e8400-e29b-41d4-a716-446655440000',
|
||||
revokedAt: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -187,14 +215,14 @@ describe('AdminService', () => {
|
||||
mockRepository.reinstateAdmin.mockResolvedValue(reinstatedAdmin);
|
||||
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(mockRepository.reinstateAdmin).toHaveBeenCalledWith('auth0|reinstate');
|
||||
expect(mockRepository.reinstateAdmin).toHaveBeenCalledWith(reinstateId);
|
||||
expect(mockRepository.logAuditAction).toHaveBeenCalledWith(
|
||||
'auth0|admin',
|
||||
adminActorId,
|
||||
'REINSTATE',
|
||||
'auth0|reinstate',
|
||||
reinstateId,
|
||||
'admin_user',
|
||||
reinstatedAdmin.email
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('AuditLog Feature Integration', () => {
|
||||
|
||||
describe('Vehicle logging integration', () => {
|
||||
it('should create audit log with vehicle category and correct resource', async () => {
|
||||
const userId = 'test-user-vehicle-123';
|
||||
const userId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const vehicleId = 'vehicle-uuid-123';
|
||||
const entry = await service.info(
|
||||
'vehicle',
|
||||
@@ -56,7 +56,7 @@ describe('AuditLog Feature Integration', () => {
|
||||
});
|
||||
|
||||
it('should log vehicle update with correct fields', async () => {
|
||||
const userId = 'test-user-vehicle-456';
|
||||
const userId = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
||||
const vehicleId = 'vehicle-uuid-456';
|
||||
const entry = await service.info(
|
||||
'vehicle',
|
||||
@@ -75,7 +75,7 @@ describe('AuditLog Feature Integration', () => {
|
||||
});
|
||||
|
||||
it('should log vehicle deletion with vehicle info', async () => {
|
||||
const userId = 'test-user-vehicle-789';
|
||||
const userId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
const vehicleId = 'vehicle-uuid-789';
|
||||
const entry = await service.info(
|
||||
'vehicle',
|
||||
@@ -96,7 +96,7 @@ describe('AuditLog Feature Integration', () => {
|
||||
|
||||
describe('Auth logging integration', () => {
|
||||
it('should create audit log with auth category for signup', async () => {
|
||||
const userId = 'test-user-auth-123';
|
||||
const userId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const entry = await service.info(
|
||||
'auth',
|
||||
userId,
|
||||
@@ -116,7 +116,7 @@ describe('AuditLog Feature Integration', () => {
|
||||
});
|
||||
|
||||
it('should create audit log for password reset request', async () => {
|
||||
const userId = 'test-user-auth-456';
|
||||
const userId = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
||||
const entry = await service.info(
|
||||
'auth',
|
||||
userId,
|
||||
@@ -134,14 +134,14 @@ describe('AuditLog Feature Integration', () => {
|
||||
|
||||
describe('Admin logging integration', () => {
|
||||
it('should create audit log for admin user creation', async () => {
|
||||
const adminId = 'admin-user-123';
|
||||
const targetAdminSub = 'auth0|target-admin-456';
|
||||
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',
|
||||
targetAdminSub,
|
||||
targetAdminId,
|
||||
{ email: 'newadmin@example.com', role: 'admin' }
|
||||
);
|
||||
|
||||
@@ -156,14 +156,14 @@ describe('AuditLog Feature Integration', () => {
|
||||
});
|
||||
|
||||
it('should create audit log for admin revocation', async () => {
|
||||
const adminId = 'admin-user-123';
|
||||
const targetAdminSub = 'auth0|target-admin-789';
|
||||
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',
|
||||
targetAdminSub,
|
||||
targetAdminId,
|
||||
{ email: 'revoked@example.com' }
|
||||
);
|
||||
|
||||
@@ -174,14 +174,14 @@ describe('AuditLog Feature Integration', () => {
|
||||
});
|
||||
|
||||
it('should create audit log for admin reinstatement', async () => {
|
||||
const adminId = 'admin-user-123';
|
||||
const targetAdminSub = 'auth0|target-admin-reinstated';
|
||||
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',
|
||||
targetAdminSub,
|
||||
targetAdminId,
|
||||
{ email: 'reinstated@example.com' }
|
||||
);
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('AuditLog Feature Integration', () => {
|
||||
|
||||
describe('Backup/System logging integration', () => {
|
||||
it('should create audit log for backup creation', async () => {
|
||||
const adminId = 'admin-user-backup-123';
|
||||
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
const backupId = 'backup-uuid-123';
|
||||
const entry = await service.info(
|
||||
'system',
|
||||
@@ -215,7 +215,7 @@ describe('AuditLog Feature Integration', () => {
|
||||
});
|
||||
|
||||
it('should create audit log for backup restore', async () => {
|
||||
const adminId = 'admin-user-backup-456';
|
||||
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
const backupId = 'backup-uuid-456';
|
||||
const entry = await service.info(
|
||||
'system',
|
||||
@@ -233,7 +233,7 @@ describe('AuditLog Feature Integration', () => {
|
||||
});
|
||||
|
||||
it('should create error-level audit log for backup failure', async () => {
|
||||
const adminId = 'admin-user-backup-789';
|
||||
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
const backupId = 'backup-uuid-789';
|
||||
const entry = await service.error(
|
||||
'system',
|
||||
@@ -253,7 +253,7 @@ describe('AuditLog Feature Integration', () => {
|
||||
});
|
||||
|
||||
it('should create error-level audit log for restore failure', async () => {
|
||||
const adminId = 'admin-user-restore-fail';
|
||||
const adminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
const backupId = 'backup-uuid-restore-fail';
|
||||
const entry = await service.error(
|
||||
'system',
|
||||
|
||||
@@ -126,7 +126,7 @@ export class AuditLogRepository {
|
||||
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.auth0_sub
|
||||
LEFT JOIN user_profiles up ON al.user_id = up.id
|
||||
${whereClause}
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT $${nextParamIndex} OFFSET $${nextParamIndex + 1}
|
||||
@@ -170,7 +170,7 @@ export class AuditLogRepository {
|
||||
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.auth0_sub
|
||||
LEFT JOIN user_profiles up ON al.user_id = up.id
|
||||
${whereClause}
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ${MAX_EXPORT_RECORDS}
|
||||
|
||||
@@ -110,17 +110,17 @@ export class AuthController {
|
||||
*/
|
||||
async getVerifyStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
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);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get verification status', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
@@ -137,17 +137,17 @@ export class AuthController {
|
||||
*/
|
||||
async resendVerification(request: FastifyRequest, reply: FastifyReply) {
|
||||
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);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to resend verification email', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
@@ -199,23 +199,26 @@ export class AuthController {
|
||||
*/
|
||||
async getUserStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
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);
|
||||
await auditLogService.info(
|
||||
'auth',
|
||||
userId,
|
||||
'User login',
|
||||
'user',
|
||||
userId,
|
||||
{ ipAddress }
|
||||
).catch(err => logger.error('Failed to log login audit event', { error: err }));
|
||||
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', {
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
userId: userId?.substring(0, 8) + '...',
|
||||
emailVerified: result.emailVerified,
|
||||
onboardingCompleted: result.onboardingCompleted,
|
||||
});
|
||||
@@ -224,7 +227,7 @@ export class AuthController {
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get user status', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
@@ -241,12 +244,12 @@ export class AuthController {
|
||||
*/
|
||||
async getSecurityStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
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', {
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
userId: request.userContext?.userId,
|
||||
emailVerified: result.emailVerified,
|
||||
});
|
||||
|
||||
@@ -254,7 +257,7 @@ export class AuthController {
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get security status', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
@@ -271,28 +274,31 @@ export class AuthController {
|
||||
*/
|
||||
async requestPasswordReset(request: FastifyRequest, reply: FastifyReply) {
|
||||
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', {
|
||||
userId: userId.substring(0, 8) + '...',
|
||||
userId: userId?.substring(0, 8) + '...',
|
||||
});
|
||||
|
||||
// Log password reset request to unified audit log
|
||||
await auditLogService.info(
|
||||
'auth',
|
||||
userId,
|
||||
'Password reset requested',
|
||||
'user',
|
||||
userId
|
||||
).catch(err => logger.error('Failed to log password reset audit event', { error: err }));
|
||||
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);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to request password reset', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
@@ -312,21 +318,23 @@ export class AuthController {
|
||||
*/
|
||||
async trackLogout(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext?.userId;
|
||||
const ipAddress = this.getClientIp(request);
|
||||
|
||||
// Log logout event to audit trail
|
||||
await auditLogService.info(
|
||||
'auth',
|
||||
userId,
|
||||
'User logout',
|
||||
'user',
|
||||
userId,
|
||||
{ ipAddress }
|
||||
).catch(err => logger.error('Failed to log logout audit event', { error: err }));
|
||||
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) + '...',
|
||||
userId: userId?.substring(0, 8) + '...',
|
||||
});
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
@@ -334,7 +342,7 @@ export class AuthController {
|
||||
// Don't block logout on audit failure - always return success
|
||||
logger.error('Failed to track logout', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
|
||||
@@ -19,6 +19,7 @@ jest.mock('../../../../core/plugins/auth.plugin', () => {
|
||||
return {
|
||||
default: fastifyPlugin(async function (fastify) {
|
||||
fastify.decorate('authenticate', async function (request, _reply) {
|
||||
// JWT sub is still auth0|xxx format
|
||||
request.user = { sub: 'auth0|test-user-123' };
|
||||
});
|
||||
}, { name: 'auth-plugin' }),
|
||||
|
||||
@@ -103,6 +103,8 @@ describe('AuthService', () => {
|
||||
onboardingCompletedAt: null,
|
||||
deactivatedAt: null,
|
||||
deactivatedBy: null,
|
||||
deletionRequestedAt: null,
|
||||
deletionScheduledFor: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
@@ -116,6 +118,8 @@ describe('AuthService', () => {
|
||||
onboardingCompletedAt: null,
|
||||
deactivatedAt: null,
|
||||
deactivatedBy: null,
|
||||
deletionRequestedAt: null,
|
||||
deletionScheduledFor: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
@@ -149,6 +153,8 @@ describe('AuthService', () => {
|
||||
onboardingCompletedAt: null,
|
||||
deactivatedAt: null,
|
||||
deactivatedBy: null,
|
||||
deletionRequestedAt: null,
|
||||
deletionScheduledFor: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
@@ -45,12 +45,12 @@ export class BackupController {
|
||||
request: FastifyRequest<{ Body: CreateBackupBody }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const adminSub = (request as any).userContext?.auth0Sub;
|
||||
const adminUserId = request.userContext?.userId;
|
||||
|
||||
const result = await this.backupService.createBackup({
|
||||
name: request.body.name,
|
||||
backupType: 'manual',
|
||||
createdBy: adminSub,
|
||||
createdBy: adminUserId,
|
||||
includeDocuments: request.body.includeDocuments,
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ export class BackupController {
|
||||
// Log backup creation to unified audit log
|
||||
await auditLogService.info(
|
||||
'system',
|
||||
adminSub || null,
|
||||
adminUserId || null,
|
||||
`Backup created: ${request.body.name || 'Manual backup'}`,
|
||||
'backup',
|
||||
result.backupId,
|
||||
@@ -74,7 +74,7 @@ export class BackupController {
|
||||
// Log backup failure
|
||||
await auditLogService.error(
|
||||
'system',
|
||||
adminSub || null,
|
||||
adminUserId || null,
|
||||
`Backup failed: ${request.body.name || 'Manual backup'}`,
|
||||
'backup',
|
||||
result.backupId,
|
||||
@@ -139,7 +139,7 @@ export class BackupController {
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const adminSub = (request as any).userContext?.auth0Sub;
|
||||
const adminUserId = request.userContext?.userId;
|
||||
|
||||
// Handle multipart file upload
|
||||
const data = await request.file();
|
||||
@@ -173,7 +173,7 @@ export class BackupController {
|
||||
const backup = await this.backupService.importUploadedBackup(
|
||||
tempPath,
|
||||
filename,
|
||||
adminSub
|
||||
adminUserId
|
||||
);
|
||||
|
||||
reply.status(201).send({
|
||||
@@ -217,7 +217,7 @@ export class BackupController {
|
||||
request: FastifyRequest<{ Params: BackupIdParam; Body: RestoreBody }>,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const adminSub = (request as any).userContext?.auth0Sub;
|
||||
const adminUserId = request.userContext?.userId;
|
||||
|
||||
try {
|
||||
const result = await this.restoreService.executeRestore({
|
||||
@@ -229,7 +229,7 @@ export class BackupController {
|
||||
// Log successful restore to unified audit log
|
||||
await auditLogService.info(
|
||||
'system',
|
||||
adminSub || null,
|
||||
adminUserId || null,
|
||||
`Backup restored: ${request.params.id}`,
|
||||
'backup',
|
||||
request.params.id,
|
||||
@@ -246,7 +246,7 @@ export class BackupController {
|
||||
// Log restore failure
|
||||
await auditLogService.error(
|
||||
'system',
|
||||
adminSub || null,
|
||||
adminUserId || null,
|
||||
`Backup restore failed: ${request.params.id}`,
|
||||
'backup',
|
||||
request.params.id,
|
||||
|
||||
@@ -15,7 +15,7 @@ export class DocumentsController {
|
||||
private readonly service = new DocumentsService();
|
||||
|
||||
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', {
|
||||
operation: 'documents.list',
|
||||
@@ -43,7 +43,7 @@ export class DocumentsController {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
logger.info('Document get requested', {
|
||||
@@ -74,7 +74,7 @@ export class DocumentsController {
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
logger.info('Document create requested', {
|
||||
@@ -120,7 +120,7 @@ export class DocumentsController {
|
||||
}
|
||||
|
||||
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 documentId = request.params.id;
|
||||
|
||||
@@ -174,7 +174,7 @@ export class DocumentsController {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
logger.info('Document delete requested', {
|
||||
@@ -221,7 +221,7 @@ export class DocumentsController {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
logger.info('Document upload requested', {
|
||||
@@ -373,7 +373,7 @@ export class DocumentsController {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
logger.info('Document download requested', {
|
||||
@@ -423,7 +423,7 @@ export class DocumentsController {
|
||||
}
|
||||
|
||||
async listByVehicle(request: FastifyRequest<{ Params: { vehicleId: string } }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
const vehicleId = request.params.vehicleId;
|
||||
|
||||
logger.info('Documents by vehicle requested', {
|
||||
@@ -457,7 +457,7 @@ export class DocumentsController {
|
||||
}
|
||||
|
||||
async addVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
const { id: documentId, vehicleId } = request.params;
|
||||
|
||||
logger.info('Add vehicle to document requested', {
|
||||
@@ -523,7 +523,7 @@ export class DocumentsController {
|
||||
}
|
||||
|
||||
async removeVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
const { id: documentId, vehicleId } = request.params;
|
||||
|
||||
logger.info('Remove vehicle from document requested', {
|
||||
|
||||
@@ -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();
|
||||
@@ -20,12 +20,12 @@ export class FuelLogsController {
|
||||
|
||||
async createFuelLog(request: FastifyRequest<{ Body: EnhancedCreateFuelLogRequest }>, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const fuelLog = await this.fuelLogsService.createFuelLog(request.body, userId);
|
||||
|
||||
return reply.code(201).send(fuelLog);
|
||||
} 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')) {
|
||||
return reply.code(404).send({
|
||||
@@ -49,14 +49,14 @@ export class FuelLogsController {
|
||||
|
||||
async getFuelLogsByVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const { vehicleId } = request.params;
|
||||
|
||||
const fuelLogs = await this.fuelLogsService.getFuelLogsByVehicle(vehicleId, userId);
|
||||
|
||||
return reply.code(200).send(fuelLogs);
|
||||
} 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')) {
|
||||
return reply.code(404).send({
|
||||
@@ -80,12 +80,12 @@ export class FuelLogsController {
|
||||
|
||||
async getUserFuelLogs(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const fuelLogs = await this.fuelLogsService.getUserFuelLogs(userId);
|
||||
|
||||
return reply.code(200).send(fuelLogs);
|
||||
} 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({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get fuel logs'
|
||||
@@ -95,14 +95,14 @@ export class FuelLogsController {
|
||||
|
||||
async getFuelLog(request: FastifyRequest<{ Params: FuelLogParams }>, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const { id } = request.params;
|
||||
|
||||
const fuelLog = await this.fuelLogsService.getFuelLog(id, userId);
|
||||
|
||||
return reply.code(200).send(fuelLog);
|
||||
} 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') {
|
||||
return reply.code(404).send({
|
||||
@@ -126,14 +126,14 @@ export class FuelLogsController {
|
||||
|
||||
async updateFuelLog(request: FastifyRequest<{ Params: FuelLogParams; Body: EnhancedUpdateFuelLogRequest }>, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const { id } = request.params;
|
||||
|
||||
const updatedFuelLog = await this.fuelLogsService.updateFuelLog(id, request.body, userId);
|
||||
|
||||
return reply.code(200).send(updatedFuelLog);
|
||||
} 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')) {
|
||||
return reply.code(404).send({
|
||||
@@ -163,14 +163,14 @@ export class FuelLogsController {
|
||||
|
||||
async deleteFuelLog(request: FastifyRequest<{ Params: FuelLogParams }>, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const { id } = request.params;
|
||||
|
||||
await this.fuelLogsService.deleteFuelLog(id, userId);
|
||||
|
||||
return reply.code(204).send();
|
||||
} 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')) {
|
||||
return reply.code(404).send({
|
||||
@@ -194,14 +194,14 @@ export class FuelLogsController {
|
||||
|
||||
async getFuelStats(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const { vehicleId } = request.params;
|
||||
|
||||
const stats = await this.fuelLogsService.getVehicleStats(vehicleId, userId);
|
||||
|
||||
return reply.code(200).send(stats);
|
||||
} 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')) {
|
||||
return reply.code(404).send({
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
},
|
||||
"responseWithEfficiency": {
|
||||
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
"userId": "auth0|user123",
|
||||
"userId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"vehicleId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"dateTime": "2024-01-15T10:30:00Z",
|
||||
"odometerReading": 52000,
|
||||
|
||||
@@ -18,7 +18,7 @@ export class MaintenanceController {
|
||||
request: FastifyRequest<{ Querystring: { vehicleId?: string; category?: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
logger.info('Maintenance records list requested', {
|
||||
operation: 'maintenance.records.list',
|
||||
@@ -58,7 +58,7 @@ export class MaintenanceController {
|
||||
}
|
||||
|
||||
async getRecord(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
const recordId = request.params.id;
|
||||
|
||||
logger.info('Maintenance record get requested', {
|
||||
@@ -102,7 +102,7 @@ export class MaintenanceController {
|
||||
request: FastifyRequest<{ Params: { vehicleId: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
const vehicleId = request.params.vehicleId;
|
||||
|
||||
logger.info('Maintenance records by vehicle requested', {
|
||||
@@ -134,7 +134,7 @@ export class MaintenanceController {
|
||||
}
|
||||
|
||||
async createRecord(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
logger.info('Maintenance record create requested', {
|
||||
operation: 'maintenance.records.create',
|
||||
@@ -190,7 +190,7 @@ export class MaintenanceController {
|
||||
request: FastifyRequest<{ Params: { id: string }; Body: unknown }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
const recordId = request.params.id;
|
||||
|
||||
logger.info('Maintenance record update requested', {
|
||||
@@ -255,7 +255,7 @@ export class MaintenanceController {
|
||||
}
|
||||
|
||||
async deleteRecord(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
const recordId = request.params.id;
|
||||
|
||||
logger.info('Maintenance record delete requested', {
|
||||
@@ -289,7 +289,7 @@ export class MaintenanceController {
|
||||
request: FastifyRequest<{ Params: { vehicleId: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
const vehicleId = request.params.vehicleId;
|
||||
|
||||
logger.info('Maintenance schedules by vehicle requested', {
|
||||
@@ -321,7 +321,7 @@ export class MaintenanceController {
|
||||
}
|
||||
|
||||
async createSchedule(request: FastifyRequest<{ Body: unknown }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
logger.info('Maintenance schedule create requested', {
|
||||
operation: 'maintenance.schedules.create',
|
||||
@@ -377,7 +377,7 @@ export class MaintenanceController {
|
||||
request: FastifyRequest<{ Params: { id: string }; Body: unknown }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
const scheduleId = request.params.id;
|
||||
|
||||
logger.info('Maintenance schedule update requested', {
|
||||
@@ -442,7 +442,7 @@ export class MaintenanceController {
|
||||
}
|
||||
|
||||
async deleteSchedule(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
const scheduleId = request.params.id;
|
||||
|
||||
logger.info('Maintenance schedule delete requested', {
|
||||
@@ -476,7 +476,7 @@ export class MaintenanceController {
|
||||
request: FastifyRequest<{ Params: { vehicleId: string }; Querystring: { currentMileage?: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
const vehicleId = request.params.vehicleId;
|
||||
const currentMileage = request.query.currentMileage ? parseInt(request.query.currentMileage, 10) : undefined;
|
||||
|
||||
@@ -510,7 +510,7 @@ export class MaintenanceController {
|
||||
}
|
||||
|
||||
async getSubtypes(request: FastifyRequest<{ Params: { category: string } }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
const category = request.params.category;
|
||||
|
||||
logger.info('Maintenance subtypes requested', {
|
||||
|
||||
@@ -21,6 +21,7 @@ export class MaintenanceRepository {
|
||||
cost: row.cost,
|
||||
shopName: row.shop_name,
|
||||
notes: row.notes,
|
||||
receiptDocumentId: row.receipt_document_id,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
@@ -66,11 +67,12 @@ export class MaintenanceRepository {
|
||||
cost?: number | null;
|
||||
shopName?: string | null;
|
||||
notes?: string | null;
|
||||
receiptDocumentId?: string | null;
|
||||
}): Promise<MaintenanceRecord> {
|
||||
const res = await this.db.query(
|
||||
`INSERT INTO maintenance_records (
|
||||
id, user_id, vehicle_id, category, subtypes, date, odometer_reading, cost, shop_name, notes
|
||||
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10)
|
||||
id, user_id, vehicle_id, category, subtypes, date, odometer_reading, cost, shop_name, notes, receipt_document_id
|
||||
) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *`,
|
||||
[
|
||||
record.id,
|
||||
@@ -83,6 +85,7 @@ export class MaintenanceRepository {
|
||||
record.cost ?? null,
|
||||
record.shopName ?? null,
|
||||
record.notes ?? null,
|
||||
record.receiptDocumentId ?? null,
|
||||
]
|
||||
);
|
||||
return this.mapMaintenanceRecord(res.rows[0]);
|
||||
@@ -96,6 +99,26 @@ export class MaintenanceRepository {
|
||||
return res.rows[0] ? this.mapMaintenanceRecord(res.rows[0]) : null;
|
||||
}
|
||||
|
||||
async findRecordByIdWithDocument(id: string, userId: string): Promise<{ record: MaintenanceRecord; receiptDocument: { documentId: string; fileName: string; contentType: string; storageKey: string } | null } | null> {
|
||||
const res = await this.db.query(
|
||||
`SELECT mr.*, d.id AS doc_id, d.file_name AS doc_file_name, d.content_type AS doc_content_type, d.storage_key AS doc_storage_key
|
||||
FROM maintenance_records mr
|
||||
LEFT JOIN documents d ON mr.receipt_document_id = d.id
|
||||
WHERE mr.id = $1 AND mr.user_id = $2`,
|
||||
[id, userId]
|
||||
);
|
||||
if (!res.rows[0]) return null;
|
||||
const row = res.rows[0];
|
||||
const record = this.mapMaintenanceRecord(row);
|
||||
const receiptDocument = row.doc_id ? {
|
||||
documentId: row.doc_id,
|
||||
fileName: row.doc_file_name,
|
||||
contentType: row.doc_content_type,
|
||||
storageKey: row.doc_storage_key,
|
||||
} : null;
|
||||
return { record, receiptDocument };
|
||||
}
|
||||
|
||||
async findRecordsByUserId(
|
||||
userId: string,
|
||||
filters?: { vehicleId?: string; category?: MaintenanceCategory }
|
||||
|
||||
@@ -10,7 +10,8 @@ import type {
|
||||
MaintenanceScheduleResponse,
|
||||
MaintenanceCategory,
|
||||
ScheduleType,
|
||||
MaintenanceCostStats
|
||||
MaintenanceCostStats,
|
||||
ReceiptDocumentMeta
|
||||
} from './maintenance.types';
|
||||
import { validateSubtypes } from './maintenance.types';
|
||||
import { MaintenanceRepository } from '../data/maintenance.repository';
|
||||
@@ -40,6 +41,7 @@ export class MaintenanceService {
|
||||
cost: body.cost,
|
||||
shopName: body.shopName,
|
||||
notes: body.notes,
|
||||
receiptDocumentId: body.receiptDocumentId,
|
||||
});
|
||||
|
||||
// Auto-link: Find and update matching 'time_since_last' schedules
|
||||
@@ -49,9 +51,9 @@ export class MaintenanceService {
|
||||
}
|
||||
|
||||
async getRecord(userId: string, id: string): Promise<MaintenanceRecordResponse | null> {
|
||||
const record = await this.repo.findRecordById(id, userId);
|
||||
if (!record) return null;
|
||||
return this.toRecordResponse(record);
|
||||
const result = await this.repo.findRecordByIdWithDocument(id, userId);
|
||||
if (!result) return null;
|
||||
return this.toRecordResponse(result.record, result.receiptDocument);
|
||||
}
|
||||
|
||||
async getRecords(userId: string, filters?: { vehicleId?: string; category?: MaintenanceCategory }): Promise<MaintenanceRecordResponse[]> {
|
||||
@@ -272,10 +274,11 @@ export class MaintenanceService {
|
||||
return { nextDueDate, nextDueMileage };
|
||||
}
|
||||
|
||||
private toRecordResponse(record: MaintenanceRecord): MaintenanceRecordResponse {
|
||||
private toRecordResponse(record: MaintenanceRecord, receiptDocument?: ReceiptDocumentMeta | null): MaintenanceRecordResponse {
|
||||
return {
|
||||
...record,
|
||||
subtypeCount: record.subtypes.length,
|
||||
receiptDocument: receiptDocument ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface MaintenanceRecord {
|
||||
cost?: number;
|
||||
shopName?: string;
|
||||
notes?: string;
|
||||
receiptDocumentId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -113,6 +114,7 @@ export const CreateMaintenanceRecordSchema = z.object({
|
||||
cost: z.number().positive().optional(),
|
||||
shopName: z.string().max(200).optional(),
|
||||
notes: z.string().max(10000).optional(),
|
||||
receiptDocumentId: z.string().uuid().optional(),
|
||||
});
|
||||
export type CreateMaintenanceRecordRequest = z.infer<typeof CreateMaintenanceRecordSchema>;
|
||||
|
||||
@@ -157,9 +159,18 @@ export const UpdateScheduleSchema = z.object({
|
||||
});
|
||||
export type UpdateScheduleRequest = z.infer<typeof UpdateScheduleSchema>;
|
||||
|
||||
// Receipt document metadata returned on GET
|
||||
export interface ReceiptDocumentMeta {
|
||||
documentId: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
// Response types
|
||||
export interface MaintenanceRecordResponse extends MaintenanceRecord {
|
||||
subtypeCount: number;
|
||||
receiptDocument?: ReceiptDocumentMeta | null;
|
||||
}
|
||||
|
||||
// TCO aggregation stats
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Add receipt_document_id FK to link maintenance records to scanned receipt documents
|
||||
ALTER TABLE maintenance_records
|
||||
ADD COLUMN receipt_document_id UUID REFERENCES documents(id) ON DELETE SET NULL;
|
||||
|
||||
-- Index for querying records by receipt document
|
||||
CREATE INDEX idx_maintenance_records_receipt_document_id ON maintenance_records(receipt_document_id)
|
||||
WHERE receipt_document_id IS NOT NULL;
|
||||
@@ -99,7 +99,7 @@
|
||||
},
|
||||
"maintenanceScheduleResponse": {
|
||||
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
"userId": "auth0|user123",
|
||||
"userId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"vehicleId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"type": "oil_change",
|
||||
"category": "routine_maintenance",
|
||||
|
||||
@@ -97,7 +97,7 @@ Templates use `{{variableName}}` syntax for variable substitution.
|
||||
|
||||
### Environment Variables
|
||||
- `RESEND_API_KEY` - Resend API key (required, stored in secrets)
|
||||
- `FROM_EMAIL` - Sender email address (default: noreply@motovaultpro.com)
|
||||
- `FROM_EMAIL` - Sender email address (default: hello@notify.motovaultpro.com)
|
||||
|
||||
### Email Delivery
|
||||
- Uses Resend API for transactional emails
|
||||
|
||||
@@ -24,7 +24,7 @@ export class NotificationsController {
|
||||
// ========================
|
||||
|
||||
async getSummary(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
try {
|
||||
const summary = await this.service.getNotificationSummary(userId);
|
||||
@@ -38,7 +38,7 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
async getDueMaintenanceItems(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
try {
|
||||
const items = await this.service.getDueMaintenanceItems(userId);
|
||||
@@ -52,7 +52,7 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
async getExpiringDocuments(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
try {
|
||||
const documents = await this.service.getExpiringDocuments(userId);
|
||||
@@ -70,7 +70,7 @@ export class NotificationsController {
|
||||
// ========================
|
||||
|
||||
async getInAppNotifications(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user!.sub!;
|
||||
const userId = request.userContext!.userId;
|
||||
const query = request.query as { limit?: string; includeRead?: string };
|
||||
const limit = query.limit ? parseInt(query.limit, 10) : 20;
|
||||
const includeRead = query.includeRead === 'true';
|
||||
@@ -85,7 +85,7 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
async getUnreadCount(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user!.sub!;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
try {
|
||||
const count = await this.service.getUnreadCount(userId);
|
||||
@@ -97,7 +97,7 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
async markAsRead(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
const userId = request.user!.sub!;
|
||||
const userId = request.userContext!.userId;
|
||||
const notificationId = request.params.id;
|
||||
|
||||
try {
|
||||
@@ -113,7 +113,7 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
async markAllAsRead(request: FastifyRequest, reply: FastifyReply) {
|
||||
const userId = request.user!.sub!;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
try {
|
||||
const count = await this.service.markAllAsRead(userId);
|
||||
@@ -125,7 +125,7 @@ export class NotificationsController {
|
||||
}
|
||||
|
||||
async deleteNotification(request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) {
|
||||
const userId = request.user!.sub!;
|
||||
const userId = request.userContext!.userId;
|
||||
const notificationId = request.params.id;
|
||||
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { EMAIL_STYLES } from './email-styles';
|
||||
|
||||
// External logo URL - hosted on GitHub for reliability
|
||||
const LOGO_URL = 'https://raw.githubusercontent.com/ericgullickson/images/c58b0e4773e8395b532f97f6ab529e38ea4dc8be/motovaultpro-auth0-small.png';
|
||||
const LOGO_URL = 'https://motovaultpro.com/images/logos/motovaultpro-auth0-small.png';
|
||||
|
||||
/**
|
||||
* Renders the complete HTML email layout with branding
|
||||
@@ -65,10 +65,10 @@ export function renderEmailLayout(content: string): string {
|
||||
<a href="https://motovaultpro.com" style="${EMAIL_STYLES.footerLink}" target="_blank">Login to MotoVaultPro</a>
|
||||
</p>
|
||||
<p style="${EMAIL_STYLES.footerText}">
|
||||
<a href="{{unsubscribeUrl}}" style="${EMAIL_STYLES.footerLink}" target="_blank">Manage Email Preferences</a>
|
||||
<a href="https://motovaultpro.com/settings" style="${EMAIL_STYLES.footerLink}" target="_blank">Manage Email Preferences</a>
|
||||
</p>
|
||||
<p style="${EMAIL_STYLES.copyright}">
|
||||
© {new Date().getFullYear()} MotoVaultPro. All rights reserved.
|
||||
© ${new Date().getFullYear()} MotoVaultPro. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -16,7 +16,7 @@ export class EmailService {
|
||||
}
|
||||
|
||||
this.resend = new Resend(apiKey);
|
||||
this.fromEmail = process.env['FROM_EMAIL'] || 'noreply@motovaultpro.com';
|
||||
this.fromEmail = process.env['FROM_EMAIL'] || 'hello@notify.motovaultpro.com';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,6 +33,10 @@ export class EmailService {
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
headers: {
|
||||
'List-Unsubscribe': '<https://motovaultpro.com/settings>',
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
@@ -445,6 +445,29 @@ export class NotificationsService {
|
||||
reason: 'Subscription upgrade',
|
||||
additionalInfo: 'You now have access to all the features included in the Pro tier. Enjoy your enhanced MotoVaultPro experience!',
|
||||
};
|
||||
case 'receipt_processed':
|
||||
return {
|
||||
...baseVariables,
|
||||
vehicleName: '2024 Toyota Camry',
|
||||
recordType: 'Fuel Log',
|
||||
merchantName: 'Shell Gas Station',
|
||||
totalAmount: '45.50',
|
||||
date: new Date().toLocaleDateString(),
|
||||
};
|
||||
case 'receipt_failed':
|
||||
return {
|
||||
...baseVariables,
|
||||
errorReason: 'Unable to extract receipt data from the attached image.',
|
||||
guidance: 'Please try again with a clearer image or PDF. Tips: use a well-lit, high-contrast photo; ensure text is legible and not cut off; PDF receipts work best.',
|
||||
};
|
||||
case 'receipt_pending_vehicle':
|
||||
return {
|
||||
...baseVariables,
|
||||
recordType: 'Maintenance Record',
|
||||
merchantName: 'AutoZone',
|
||||
totalAmount: '89.99',
|
||||
date: new Date().toLocaleDateString(),
|
||||
};
|
||||
default:
|
||||
return baseVariables;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ export type TemplateKey =
|
||||
| 'maintenance_overdue'
|
||||
| 'document_expiring'
|
||||
| 'document_expired'
|
||||
| 'subscription_tier_change';
|
||||
| 'subscription_tier_change'
|
||||
| 'receipt_processed'
|
||||
| 'receipt_failed'
|
||||
| 'receipt_pending_vehicle';
|
||||
|
||||
// Email template API response type (camelCase for frontend)
|
||||
export interface EmailTemplate {
|
||||
@@ -86,7 +89,10 @@ export const TemplateKeySchema = z.enum([
|
||||
'maintenance_overdue',
|
||||
'document_expiring',
|
||||
'document_expired',
|
||||
'subscription_tier_change'
|
||||
'subscription_tier_change',
|
||||
'receipt_processed',
|
||||
'receipt_failed',
|
||||
'receipt_pending_vehicle'
|
||||
]);
|
||||
|
||||
export const UpdateEmailTemplateSchema = z.object({
|
||||
|
||||
@@ -1,16 +1,47 @@
|
||||
# ocr/
|
||||
|
||||
Backend proxy for the Python OCR microservice. Handles authentication, tier gating, file validation, and request forwarding for VIN extraction, fuel receipt scanning, and maintenance manual extraction.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What | When to read |
|
||||
| ---- | ---- | ------------ |
|
||||
| `README.md` | Feature documentation | Understanding OCR proxy |
|
||||
| `README.md` | Feature documentation with architecture diagrams | Understanding OCR proxy, data flows |
|
||||
| `index.ts` | Feature barrel export | Importing OCR services |
|
||||
|
||||
## Subdirectories
|
||||
|
||||
| Directory | What | When to read |
|
||||
| --------- | ---- | ------------ |
|
||||
| `api/` | HTTP endpoints and routes | API changes |
|
||||
| `domain/` | Business logic, types | Core OCR proxy logic |
|
||||
| `external/` | External OCR service client | OCR service integration |
|
||||
| `api/` | HTTP endpoints, routes, request validation | API changes, adding endpoints |
|
||||
| `domain/` | Business logic, TypeScript types | Core OCR proxy logic, type definitions |
|
||||
| `external/` | HTTP client to Python OCR service | OCR service integration, error handling |
|
||||
| `tests/` | Unit tests for receipt and manual extraction | Test changes, adding test coverage |
|
||||
|
||||
## api/
|
||||
|
||||
| File | What | When to read |
|
||||
| ---- | ---- | ------------ |
|
||||
| `ocr.controller.ts` | Request handlers for all OCR endpoints (extract, extractVin, extractReceipt, extractManual, submitJob, getJobStatus) | Adding/modifying endpoint behavior |
|
||||
| `ocr.routes.ts` | Fastify route registration with auth and tier guard preHandlers | Route configuration, middleware changes |
|
||||
| `ocr.validation.ts` | Request/response type definitions for route schemas | Changing request/response shapes |
|
||||
|
||||
## domain/
|
||||
|
||||
| File | What | When to read |
|
||||
| ---- | ---- | ------------ |
|
||||
| `ocr.service.ts` | Business logic layer: file validation, size limits (10MB sync, 200MB async), content type checks, service delegation | Core logic changes, validation rules |
|
||||
| `ocr.types.ts` | TypeScript types: OcrResponse, VinExtractionResponse, ReceiptExtractionResponse, ManualExtractionResult, JobResponse, ManualJobResponse | Type changes, adding new response shapes |
|
||||
|
||||
## external/
|
||||
|
||||
| File | What | When to read |
|
||||
| ---- | ---- | ------------ |
|
||||
| `ocr-client.ts` | HTTP client to mvp-ocr Python service (extract, extractVin, extractReceipt, decodeVin, submitJob, submitManualJob, getJobStatus, isHealthy) | OCR service communication, error handling |
|
||||
|
||||
## tests/
|
||||
|
||||
| File | What | When to read |
|
||||
| ---- | ---- | ------------ |
|
||||
| `unit/ocr-receipt.test.ts` | Receipt extraction tests with mock client | Receipt flow changes |
|
||||
| `unit/ocr-manual.test.ts` | Manual PDF extraction tests | Manual extraction flow changes |
|
||||
|
||||
@@ -1,54 +1,180 @@
|
||||
# OCR Feature
|
||||
|
||||
Backend proxy for OCR service communication. Handles authentication, validation, and file streaming to the OCR container.
|
||||
Backend proxy for the Python OCR microservice. Handles authentication, tier gating, file validation, and request forwarding for three extraction types: VIN decoding, fuel receipt scanning, and maintenance manual extraction.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/ocr/extract` | Synchronous OCR extraction (max 10MB) |
|
||||
| POST | `/api/ocr/jobs` | Submit async OCR job (max 200MB) |
|
||||
| GET | `/api/ocr/jobs/:jobId` | Poll async job status |
|
||||
| Method | Endpoint | Description | Auth | Tier | Max Size |
|
||||
|--------|----------|-------------|------|------|----------|
|
||||
| POST | `/api/ocr/extract` | Synchronous general OCR extraction | Required | - | 10MB |
|
||||
| POST | `/api/ocr/extract/vin` | VIN-specific extraction | Required | - | 10MB |
|
||||
| POST | `/api/ocr/extract/receipt` | Fuel receipt extraction | Required | Pro | 10MB |
|
||||
| POST | `/api/ocr/extract/manual` | Async maintenance manual extraction | Required | Pro | 200MB |
|
||||
| POST | `/api/ocr/jobs` | Submit async OCR job | Required | - | 200MB |
|
||||
| GET | `/api/ocr/jobs/:jobId` | Poll async job status | Required | - | - |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
api/
|
||||
ocr.controller.ts # Request handlers
|
||||
ocr.routes.ts # Route registration
|
||||
ocr.validation.ts # Request validation types
|
||||
domain/
|
||||
ocr.service.ts # Business logic
|
||||
ocr.types.ts # TypeScript types
|
||||
external/
|
||||
ocr-client.ts # HTTP client to OCR service
|
||||
Frontend
|
||||
|
|
||||
v
|
||||
Backend Proxy (this feature)
|
||||
|
|
||||
+-- ocr.routes.ts --------> Route registration (auth + tier preHandlers)
|
||||
|
|
||||
+-- ocr.controller.ts ----> Request handlers (file validation, size checks)
|
||||
|
|
||||
+-- ocr.service.ts -------> Business logic (content type validation, delegation)
|
||||
|
|
||||
+-- ocr-client.ts --------> HTTP client to mvp-ocr:8000
|
||||
|
|
||||
v
|
||||
Python OCR Service
|
||||
```
|
||||
|
||||
## Receipt OCR Flow
|
||||
|
||||
```
|
||||
Mobile Camera / File Upload
|
||||
|
|
||||
v
|
||||
POST /api/ocr/extract/receipt (multipart/form-data)
|
||||
|
|
||||
v
|
||||
OcrController.extractReceipt()
|
||||
- Validates file size (<= 10MB)
|
||||
- Validates content type (JPEG, PNG, HEIC)
|
||||
|
|
||||
v
|
||||
OcrService.extractReceipt()
|
||||
|
|
||||
v
|
||||
OcrClient.extractReceipt() --> HTTP POST --> Python /extract/receipt
|
||||
| |
|
||||
v v
|
||||
ReceiptExtractionResponse ReceiptExtractor + HybridEngine
|
||||
| (Vision API / PaddleOCR fallback)
|
||||
v
|
||||
Frontend receives extractedFields:
|
||||
merchantName, transactionDate, totalAmount,
|
||||
fuelQuantity, pricePerUnit, fuelGrade
|
||||
```
|
||||
|
||||
After receipt extraction, the frontend calls `POST /api/stations/match` with the `merchantName` to auto-match a gas station via Google Places API. The station match is a separate request handled by the stations feature.
|
||||
|
||||
## Manual Extraction Flow
|
||||
|
||||
```
|
||||
PDF Upload + "Scan for Maintenance Schedule"
|
||||
|
|
||||
v
|
||||
POST /api/ocr/extract/manual (multipart/form-data)
|
||||
- Requires Pro tier (document.scanMaintenanceSchedule)
|
||||
- Validates file size (<= 200MB)
|
||||
- Validates content type (application/pdf)
|
||||
- Validates PDF magic bytes (%PDF header)
|
||||
|
|
||||
v
|
||||
OcrService.submitManualJob()
|
||||
|
|
||||
v
|
||||
OcrClient.submitManualJob() --> HTTP POST --> Python /extract/manual
|
||||
| |
|
||||
v v
|
||||
{ jobId, status: 'pending' } GeminiEngine (Vertex AI)
|
||||
Gemini 2.5 Flash
|
||||
Frontend polls: (structured JSON output)
|
||||
GET /api/ocr/jobs/:jobId |
|
||||
(progress: 10% -> 50% -> 95% -> 100%) v
|
||||
| ManualExtractionResult
|
||||
v { vehicleInfo, maintenanceSchedules[] }
|
||||
ManualJobResponse with result
|
||||
|
|
||||
v
|
||||
Frontend displays MaintenanceScheduleReviewScreen
|
||||
- User selects/edits items
|
||||
- Batch creates maintenance schedules
|
||||
```
|
||||
|
||||
Jobs expire after 2 hours (Redis TTL). Expired job polling returns HTTP 410 Gone.
|
||||
|
||||
## Supported File Types
|
||||
|
||||
### Sync Endpoints (extract, extractVin, extractReceipt)
|
||||
- HEIC (converted server-side)
|
||||
- JPEG
|
||||
- PNG
|
||||
- PDF (first page only)
|
||||
|
||||
## Response Format
|
||||
### Async Endpoints (extractManual)
|
||||
- PDF (validated via magic bytes)
|
||||
|
||||
## Response Types
|
||||
|
||||
### ReceiptExtractionResponse
|
||||
```typescript
|
||||
interface OcrResponse {
|
||||
{
|
||||
success: boolean;
|
||||
documentType: 'vin' | 'receipt' | 'manual' | 'unknown';
|
||||
receiptType: string;
|
||||
extractedFields: {
|
||||
merchantName: { value: string; confidence: number };
|
||||
transactionDate: { value: string; confidence: number };
|
||||
totalAmount: { value: string; confidence: number };
|
||||
fuelQuantity: { value: string; confidence: number };
|
||||
pricePerUnit: { value: string; confidence: number };
|
||||
fuelGrade: { value: string; confidence: number };
|
||||
};
|
||||
rawText: string;
|
||||
confidence: number; // 0.0 - 1.0
|
||||
extractedFields: Record<string, { value: string; confidence: number }>;
|
||||
processingTimeMs: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Async Job Flow
|
||||
### ManualJobResponse
|
||||
```typescript
|
||||
{
|
||||
jobId: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
progress?: { percent: number; message: string };
|
||||
estimatedSeconds?: number;
|
||||
result?: ManualExtractionResult;
|
||||
error?: string;
|
||||
}
|
||||
```
|
||||
|
||||
1. POST `/api/ocr/jobs` with file
|
||||
2. Receive `{ jobId, status: 'pending' }`
|
||||
3. Poll GET `/api/ocr/jobs/:jobId`
|
||||
4. When `status: 'completed'`, result contains OCR data
|
||||
### ManualExtractionResult
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
vehicleInfo?: { make: string; model: string; year: number };
|
||||
maintenanceSchedules: Array<{
|
||||
serviceName: string;
|
||||
intervalMiles: number | null;
|
||||
intervalMonths: number | null;
|
||||
details: string;
|
||||
confidence: number;
|
||||
subtypes: string[];
|
||||
}>;
|
||||
rawTables: any[];
|
||||
processingTimeMs: number;
|
||||
totalPages: number;
|
||||
pagesProcessed: number;
|
||||
}
|
||||
```
|
||||
|
||||
Jobs expire after 1 hour.
|
||||
## Error Handling
|
||||
|
||||
The backend proxy translates Python service error codes:
|
||||
|
||||
| Python Status | Backend Status | Meaning |
|
||||
|---------------|----------------|---------|
|
||||
| 413 | 413 | File too large |
|
||||
| 415 | 415 | Unsupported media type |
|
||||
| 422 | 422 | Extraction failed |
|
||||
| 410 | 410 | Job expired (TTL) |
|
||||
| Other | 500 | Internal server error |
|
||||
|
||||
## Tier Gating
|
||||
|
||||
Manual extraction requires Pro tier. The tier guard middleware (`requireTier` plugin) validates the user's subscription tier before processing. Free-tier users receive HTTP 403 with `TIER_REQUIRED` error code and an upgrade prompt.
|
||||
|
||||
VIN extraction is available to all tiers. Receipt extraction requires Pro tier (`fuelLog.receiptScan`).
|
||||
|
||||
@@ -15,6 +15,15 @@ const SUPPORTED_TYPES = new Set([
|
||||
'application/pdf',
|
||||
]);
|
||||
|
||||
/** Image-only MIME types for receipt extraction */
|
||||
const SUPPORTED_IMAGE_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
'application/pdf',
|
||||
]);
|
||||
|
||||
export class OcrController {
|
||||
/**
|
||||
* POST /api/ocr/extract
|
||||
@@ -24,7 +33,7 @@ export class OcrController {
|
||||
request: FastifyRequest<{ Querystring: ExtractQuery }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext?.userId as string;
|
||||
const preprocess = request.query.preprocess !== false;
|
||||
|
||||
logger.info('OCR extract requested', {
|
||||
@@ -131,7 +140,7 @@ export class OcrController {
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext?.userId as string;
|
||||
|
||||
logger.info('VIN extract requested', {
|
||||
operation: 'ocr.controller.extractVin',
|
||||
@@ -223,6 +232,350 @@ export class OcrController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ocr/extract/receipt
|
||||
* Extract data from a receipt image using receipt-specific OCR.
|
||||
*/
|
||||
async extractReceipt(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = request.userContext?.userId as string;
|
||||
|
||||
logger.info('Receipt extract requested', {
|
||||
operation: 'ocr.controller.extractReceipt',
|
||||
userId,
|
||||
});
|
||||
|
||||
const file = await (request as any).file({ limits: { files: 1 } });
|
||||
if (!file) {
|
||||
logger.warn('No file provided for receipt extraction', {
|
||||
operation: 'ocr.controller.extractReceipt.no_file',
|
||||
userId,
|
||||
});
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'No file provided',
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = file.mimetype as string;
|
||||
if (!SUPPORTED_IMAGE_TYPES.has(contentType)) {
|
||||
logger.warn('Unsupported file type for receipt extraction', {
|
||||
operation: 'ocr.controller.extractReceipt.unsupported_type',
|
||||
userId,
|
||||
contentType,
|
||||
fileName: file.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC, PDF`,
|
||||
});
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of file.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const fileBuffer = Buffer.concat(chunks);
|
||||
|
||||
if (fileBuffer.length === 0) {
|
||||
logger.warn('Empty file provided for receipt extraction', {
|
||||
operation: 'ocr.controller.extractReceipt.empty_file',
|
||||
userId,
|
||||
fileName: file.filename,
|
||||
});
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Empty file provided',
|
||||
});
|
||||
}
|
||||
|
||||
// Get optional receipt_type from form fields
|
||||
const receiptType = file.fields?.receipt_type?.value as string | undefined;
|
||||
|
||||
try {
|
||||
const result = await ocrService.extractReceipt(userId, {
|
||||
fileBuffer,
|
||||
contentType,
|
||||
receiptType,
|
||||
});
|
||||
|
||||
logger.info('Receipt extract completed', {
|
||||
operation: 'ocr.controller.extractReceipt.success',
|
||||
userId,
|
||||
success: result.success,
|
||||
receiptType: result.receiptType,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
});
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 413) {
|
||||
return reply.code(413).send({
|
||||
error: 'Payload Too Large',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
if (error.statusCode === 415) {
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
if (error.statusCode === 422) {
|
||||
return reply.code(422).send({
|
||||
error: 'Unprocessable Entity',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.error('Receipt extract failed', {
|
||||
operation: 'ocr.controller.extractReceipt.error',
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Receipt extraction failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ocr/extract/maintenance-receipt
|
||||
* Extract data from a maintenance receipt image using maintenance-specific OCR.
|
||||
* Requires Pro tier (maintenance.receiptScan).
|
||||
*/
|
||||
async extractMaintenanceReceipt(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = request.userContext?.userId as string;
|
||||
|
||||
logger.info('Maintenance receipt extract requested', {
|
||||
operation: 'ocr.controller.extractMaintenanceReceipt',
|
||||
userId,
|
||||
});
|
||||
|
||||
const file = await (request as any).file({ limits: { files: 1 } });
|
||||
if (!file) {
|
||||
logger.warn('No file provided for maintenance receipt extraction', {
|
||||
operation: 'ocr.controller.extractMaintenanceReceipt.no_file',
|
||||
userId,
|
||||
});
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'No file provided',
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = file.mimetype as string;
|
||||
if (!SUPPORTED_IMAGE_TYPES.has(contentType)) {
|
||||
logger.warn('Unsupported file type for maintenance receipt extraction', {
|
||||
operation: 'ocr.controller.extractMaintenanceReceipt.unsupported_type',
|
||||
userId,
|
||||
contentType,
|
||||
fileName: file.filename,
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC, PDF`,
|
||||
});
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of file.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const fileBuffer = Buffer.concat(chunks);
|
||||
|
||||
if (fileBuffer.length === 0) {
|
||||
logger.warn('Empty file provided for maintenance receipt extraction', {
|
||||
operation: 'ocr.controller.extractMaintenanceReceipt.empty_file',
|
||||
userId,
|
||||
fileName: file.filename,
|
||||
});
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Empty file provided',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ocrService.extractMaintenanceReceipt(userId, {
|
||||
fileBuffer,
|
||||
contentType,
|
||||
});
|
||||
|
||||
logger.info('Maintenance receipt extract completed', {
|
||||
operation: 'ocr.controller.extractMaintenanceReceipt.success',
|
||||
userId,
|
||||
success: result.success,
|
||||
receiptType: result.receiptType,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
});
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 413) {
|
||||
return reply.code(413).send({
|
||||
error: 'Payload Too Large',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
if (error.statusCode === 415) {
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
if (error.statusCode === 422) {
|
||||
return reply.code(422).send({
|
||||
error: 'Unprocessable Entity',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.error('Maintenance receipt extract failed', {
|
||||
operation: 'ocr.controller.extractMaintenanceReceipt.error',
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Maintenance receipt extraction failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ocr/extract/manual
|
||||
* Submit an async manual extraction job for PDF owner's manuals.
|
||||
* Requires Pro tier (document.scanMaintenanceSchedule).
|
||||
*/
|
||||
async extractManual(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = request.userContext?.userId as string;
|
||||
|
||||
logger.info('Manual extract requested', {
|
||||
operation: 'ocr.controller.extractManual',
|
||||
userId,
|
||||
});
|
||||
|
||||
const file = await (request as any).file({ limits: { files: 1 } });
|
||||
if (!file) {
|
||||
logger.warn('No file provided for manual extraction', {
|
||||
operation: 'ocr.controller.extractManual.no_file',
|
||||
userId,
|
||||
});
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'No file provided',
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = file.mimetype as string;
|
||||
const fileName = file.filename as string | undefined;
|
||||
const isPdfMime = contentType === 'application/pdf';
|
||||
const isPdfExtension = fileName?.toLowerCase().endsWith('.pdf') ?? false;
|
||||
|
||||
if (!isPdfMime && !isPdfExtension) {
|
||||
logger.warn('Non-PDF file provided for manual extraction', {
|
||||
operation: 'ocr.controller.extractManual.not_pdf',
|
||||
userId,
|
||||
contentType,
|
||||
fileName,
|
||||
});
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: `Manual extraction requires PDF files. Received: ${contentType}`,
|
||||
});
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of file.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const fileBuffer = Buffer.concat(chunks);
|
||||
|
||||
if (fileBuffer.length === 0) {
|
||||
logger.warn('Empty file provided for manual extraction', {
|
||||
operation: 'ocr.controller.extractManual.empty_file',
|
||||
userId,
|
||||
fileName,
|
||||
});
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Empty file provided',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate PDF magic bytes (%PDF)
|
||||
const PDF_MAGIC = Buffer.from('%PDF');
|
||||
if (fileBuffer.length < 4 || !fileBuffer.subarray(0, 4).equals(PDF_MAGIC)) {
|
||||
logger.warn('File lacks PDF magic bytes', {
|
||||
operation: 'ocr.controller.extractManual.invalid_magic',
|
||||
userId,
|
||||
fileName,
|
||||
firstBytes: fileBuffer.subarray(0, 4).toString('hex'),
|
||||
});
|
||||
return reply.code(415).send({
|
||||
error: 'Unsupported Media Type',
|
||||
message: 'File does not appear to be a valid PDF (missing %PDF header)',
|
||||
});
|
||||
}
|
||||
|
||||
// Get optional vehicle_id from form fields
|
||||
const vehicleId = file.fields?.vehicle_id?.value as string | undefined;
|
||||
|
||||
try {
|
||||
const result = await ocrService.submitManualJob(userId, {
|
||||
fileBuffer,
|
||||
contentType,
|
||||
vehicleId,
|
||||
});
|
||||
|
||||
logger.info('Manual extract job submitted', {
|
||||
operation: 'ocr.controller.extractManual.success',
|
||||
userId,
|
||||
jobId: result.jobId,
|
||||
status: result.status,
|
||||
estimatedSeconds: result.estimatedSeconds,
|
||||
});
|
||||
|
||||
return reply.code(202).send(result);
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 413) {
|
||||
return reply.code(413).send({
|
||||
error: 'Payload Too Large',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
if (error.statusCode === 400) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.error('Manual extract failed', {
|
||||
operation: 'ocr.controller.extractManual.error',
|
||||
userId,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Manual extraction submission failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ocr/jobs
|
||||
* Submit an async OCR job for large files.
|
||||
@@ -231,7 +584,7 @@ export class OcrController {
|
||||
request: FastifyRequest<{ Body: JobSubmitBody }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext?.userId as string;
|
||||
|
||||
logger.info('OCR job submit requested', {
|
||||
operation: 'ocr.controller.submitJob',
|
||||
@@ -338,7 +691,7 @@ export class OcrController {
|
||||
request: FastifyRequest<{ Params: JobIdParams }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext?.userId as string;
|
||||
const { jobId } = request.params;
|
||||
|
||||
logger.debug('OCR job status requested', {
|
||||
@@ -352,9 +705,9 @@ export class OcrController {
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 404) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
if (error.statusCode === 410) {
|
||||
return reply.code(410).send({
|
||||
error: 'Gone',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* @ai-summary Fastify routes for OCR API
|
||||
*/
|
||||
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify';
|
||||
import { requireTier } from '../../../core/middleware/require-tier';
|
||||
import { OcrController } from './ocr.controller';
|
||||
|
||||
export const ocrRoutes: FastifyPluginAsync = async (
|
||||
@@ -23,6 +24,24 @@ export const ocrRoutes: FastifyPluginAsync = async (
|
||||
handler: ctrl.extractVin.bind(ctrl),
|
||||
});
|
||||
|
||||
// POST /api/ocr/extract/receipt - Receipt-specific OCR extraction (Pro tier required)
|
||||
fastify.post('/ocr/extract/receipt', {
|
||||
preHandler: [requireAuth, requireTier('fuelLog.receiptScan')],
|
||||
handler: ctrl.extractReceipt.bind(ctrl),
|
||||
});
|
||||
|
||||
// POST /api/ocr/extract/maintenance-receipt - Maintenance receipt OCR extraction (Pro tier required)
|
||||
fastify.post('/ocr/extract/maintenance-receipt', {
|
||||
preHandler: [requireAuth, requireTier('maintenance.receiptScan')],
|
||||
handler: ctrl.extractMaintenanceReceipt.bind(ctrl),
|
||||
});
|
||||
|
||||
// POST /api/ocr/extract/manual - Manual extraction (Pro tier required)
|
||||
fastify.post('/ocr/extract/manual', {
|
||||
preHandler: [requireAuth, fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' })],
|
||||
handler: ctrl.extractManual.bind(ctrl),
|
||||
});
|
||||
|
||||
// POST /api/ocr/jobs - Submit async OCR job
|
||||
fastify.post('/ocr/jobs', {
|
||||
preHandler: [requireAuth],
|
||||
|
||||
@@ -5,9 +5,14 @@ import { logger } from '../../../core/logging/logger';
|
||||
import { ocrClient, JobNotFoundError } from '../external/ocr-client';
|
||||
import type {
|
||||
JobResponse,
|
||||
MaintenanceReceiptExtractRequest,
|
||||
ManualJobResponse,
|
||||
ManualJobSubmitRequest,
|
||||
OcrExtractRequest,
|
||||
OcrJobSubmitRequest,
|
||||
OcrResponse,
|
||||
ReceiptExtractRequest,
|
||||
ReceiptExtractionResponse,
|
||||
VinExtractionResponse,
|
||||
} from './ocr.types';
|
||||
|
||||
@@ -26,6 +31,15 @@ const SUPPORTED_TYPES = new Set([
|
||||
'application/pdf',
|
||||
]);
|
||||
|
||||
/** MIME types for receipt extraction */
|
||||
const SUPPORTED_IMAGE_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
'application/pdf',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Domain service for OCR operations.
|
||||
* Handles business logic and validation for OCR requests.
|
||||
@@ -150,6 +164,122 @@ export class OcrService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract data from a receipt image using receipt-specific OCR.
|
||||
*
|
||||
* @param userId - User ID for logging
|
||||
* @param request - Receipt extraction request
|
||||
* @returns Receipt extraction result
|
||||
*/
|
||||
async extractReceipt(userId: string, request: ReceiptExtractRequest): Promise<ReceiptExtractionResponse> {
|
||||
if (request.fileBuffer.length > MAX_SYNC_SIZE) {
|
||||
const err: any = new Error(
|
||||
`File too large. Max: ${MAX_SYNC_SIZE / (1024 * 1024)}MB.`
|
||||
);
|
||||
err.statusCode = 413;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!SUPPORTED_IMAGE_TYPES.has(request.contentType)) {
|
||||
const err: any = new Error(
|
||||
`Unsupported file type: ${request.contentType}. Supported: ${[...SUPPORTED_IMAGE_TYPES].join(', ')}`
|
||||
);
|
||||
err.statusCode = 415;
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.info('Receipt extract requested', {
|
||||
operation: 'ocr.service.extractReceipt',
|
||||
userId,
|
||||
contentType: request.contentType,
|
||||
fileSize: request.fileBuffer.length,
|
||||
receiptType: request.receiptType,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await ocrClient.extractReceipt(
|
||||
request.fileBuffer,
|
||||
request.contentType,
|
||||
request.receiptType
|
||||
);
|
||||
|
||||
logger.info('Receipt extract completed', {
|
||||
operation: 'ocr.service.extractReceipt.success',
|
||||
userId,
|
||||
success: result.success,
|
||||
receiptType: result.receiptType,
|
||||
fieldCount: Object.keys(result.extractedFields).length,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Receipt extract failed', {
|
||||
operation: 'ocr.service.extractReceipt.error',
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract data from a maintenance receipt image using maintenance-specific OCR.
|
||||
*
|
||||
* @param userId - User ID for logging
|
||||
* @param request - Maintenance receipt extraction request
|
||||
* @returns Receipt extraction result
|
||||
*/
|
||||
async extractMaintenanceReceipt(userId: string, request: MaintenanceReceiptExtractRequest): Promise<ReceiptExtractionResponse> {
|
||||
if (request.fileBuffer.length > MAX_SYNC_SIZE) {
|
||||
const err: any = new Error(
|
||||
`File too large. Max: ${MAX_SYNC_SIZE / (1024 * 1024)}MB.`
|
||||
);
|
||||
err.statusCode = 413;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!SUPPORTED_IMAGE_TYPES.has(request.contentType)) {
|
||||
const err: any = new Error(
|
||||
`Unsupported file type: ${request.contentType}. Supported: ${[...SUPPORTED_IMAGE_TYPES].join(', ')}`
|
||||
);
|
||||
err.statusCode = 415;
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.info('Maintenance receipt extract requested', {
|
||||
operation: 'ocr.service.extractMaintenanceReceipt',
|
||||
userId,
|
||||
contentType: request.contentType,
|
||||
fileSize: request.fileBuffer.length,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await ocrClient.extractMaintenanceReceipt(
|
||||
request.fileBuffer,
|
||||
request.contentType
|
||||
);
|
||||
|
||||
logger.info('Maintenance receipt extract completed', {
|
||||
operation: 'ocr.service.extractMaintenanceReceipt.success',
|
||||
userId,
|
||||
success: result.success,
|
||||
receiptType: result.receiptType,
|
||||
fieldCount: Object.keys(result.extractedFields).length,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Maintenance receipt extract failed', {
|
||||
operation: 'ocr.service.extractMaintenanceReceipt.error',
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an async OCR job for large files.
|
||||
*
|
||||
@@ -209,6 +339,66 @@ export class OcrService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an async manual extraction job for PDF owner's manuals.
|
||||
*
|
||||
* @param userId - User ID for logging
|
||||
* @param request - Manual job submission request
|
||||
* @returns Manual job response with job ID
|
||||
*/
|
||||
async submitManualJob(userId: string, request: ManualJobSubmitRequest): Promise<ManualJobResponse> {
|
||||
// Validate file size for async processing (200MB max)
|
||||
if (request.fileBuffer.length > MAX_ASYNC_SIZE) {
|
||||
const err: any = new Error(
|
||||
`File too large. Max: ${MAX_ASYNC_SIZE / (1024 * 1024)}MB.`
|
||||
);
|
||||
err.statusCode = 413;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Manual extraction only supports PDF
|
||||
if (request.contentType !== 'application/pdf') {
|
||||
const err: any = new Error(
|
||||
`Unsupported file type: ${request.contentType}. Manual extraction requires PDF files.`
|
||||
);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.info('Manual job submit requested', {
|
||||
operation: 'ocr.service.submitManualJob',
|
||||
userId,
|
||||
contentType: request.contentType,
|
||||
fileSize: request.fileBuffer.length,
|
||||
hasVehicleId: !!request.vehicleId,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await ocrClient.submitManualJob(
|
||||
request.fileBuffer,
|
||||
request.contentType,
|
||||
request.vehicleId
|
||||
);
|
||||
|
||||
logger.info('Manual job submitted', {
|
||||
operation: 'ocr.service.submitManualJob.success',
|
||||
userId,
|
||||
jobId: result.jobId,
|
||||
status: result.status,
|
||||
estimatedSeconds: result.estimatedSeconds,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Manual job submit failed', {
|
||||
operation: 'ocr.service.submitManualJob.error',
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status of an async OCR job.
|
||||
*
|
||||
@@ -237,8 +427,8 @@ export class OcrService {
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof JobNotFoundError) {
|
||||
const err: any = new Error(`Job ${jobId} not found. Jobs expire after 1 hour.`);
|
||||
err.statusCode = 404;
|
||||
const err: any = new Error('Job expired (max 2 hours). Please resubmit.');
|
||||
err.statusCode = 410;
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,29 @@ export interface OcrExtractRequest {
|
||||
preprocess?: boolean;
|
||||
}
|
||||
|
||||
/** Response from receipt-specific extraction */
|
||||
export interface ReceiptExtractionResponse {
|
||||
success: boolean;
|
||||
receiptType: string;
|
||||
extractedFields: Record<string, ExtractedField>;
|
||||
rawText: string;
|
||||
processingTimeMs: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Request for receipt extraction */
|
||||
export interface ReceiptExtractRequest {
|
||||
fileBuffer: Buffer;
|
||||
contentType: string;
|
||||
receiptType?: string;
|
||||
}
|
||||
|
||||
/** Request for maintenance receipt extraction */
|
||||
export interface MaintenanceReceiptExtractRequest {
|
||||
fileBuffer: Buffer;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
/** Response from VIN-specific extraction */
|
||||
export interface VinExtractionResponse {
|
||||
success: boolean;
|
||||
@@ -62,3 +85,67 @@ export interface OcrJobSubmitRequest {
|
||||
contentType: string;
|
||||
callbackUrl?: string;
|
||||
}
|
||||
|
||||
/** Request to submit a manual extraction job */
|
||||
export interface ManualJobSubmitRequest {
|
||||
fileBuffer: Buffer;
|
||||
contentType: string;
|
||||
vehicleId?: string;
|
||||
}
|
||||
|
||||
/** Vehicle info extracted from a manual */
|
||||
export interface ManualVehicleInfo {
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
year: number | null;
|
||||
}
|
||||
|
||||
/** A single maintenance schedule item extracted from a manual */
|
||||
export interface MaintenanceScheduleItem {
|
||||
service: string;
|
||||
intervalMiles: number | null;
|
||||
intervalMonths: number | null;
|
||||
details: string | null;
|
||||
confidence: number;
|
||||
subtypes: string[];
|
||||
}
|
||||
|
||||
/** Result of manual extraction (nested in ManualJobResponse.result) */
|
||||
export interface ManualExtractionResult {
|
||||
success: boolean;
|
||||
vehicleInfo: ManualVehicleInfo;
|
||||
maintenanceSchedules: MaintenanceScheduleItem[];
|
||||
rawTables: unknown[];
|
||||
processingTimeMs: number;
|
||||
totalPages: number;
|
||||
pagesProcessed: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Response for async manual extraction job */
|
||||
export interface ManualJobResponse {
|
||||
jobId: string;
|
||||
status: JobStatus;
|
||||
progress?: number;
|
||||
estimatedSeconds?: number;
|
||||
result?: ManualExtractionResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Response from VIN decode via Gemini (OCR service) */
|
||||
export interface VinDecodeResponse {
|
||||
success: boolean;
|
||||
vin: string;
|
||||
year: number | null;
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
trimLevel: string | null;
|
||||
bodyType: string | null;
|
||||
driveType: string | null;
|
||||
fuelType: string | null;
|
||||
engine: string | null;
|
||||
transmission: string | null;
|
||||
confidence: number;
|
||||
processingTimeMs: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
215
backend/src/features/ocr/external/ocr-client.ts
vendored
215
backend/src/features/ocr/external/ocr-client.ts
vendored
@@ -2,7 +2,7 @@
|
||||
* @ai-summary HTTP client for OCR service communication
|
||||
*/
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import type { JobResponse, OcrResponse, VinExtractionResponse } from '../domain/ocr.types';
|
||||
import type { JobResponse, ManualJobResponse, OcrResponse, ReceiptExtractionResponse, VinDecodeResponse, VinExtractionResponse } from '../domain/ocr.types';
|
||||
|
||||
/** OCR service configuration */
|
||||
const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000';
|
||||
@@ -119,6 +119,115 @@ export class OcrClient {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract data from a receipt image using receipt-specific OCR.
|
||||
*
|
||||
* @param fileBuffer - Image file buffer
|
||||
* @param contentType - MIME type of the file
|
||||
* @param receiptType - Optional receipt type hint (e.g., 'fuel')
|
||||
* @returns Receipt extraction result
|
||||
*/
|
||||
async extractReceipt(
|
||||
fileBuffer: Buffer,
|
||||
contentType: string,
|
||||
receiptType?: string
|
||||
): Promise<ReceiptExtractionResponse> {
|
||||
const formData = this.buildFormData(fileBuffer, contentType);
|
||||
if (receiptType) {
|
||||
formData.append('receipt_type', receiptType);
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}/extract/receipt`;
|
||||
|
||||
logger.info('OCR receipt extract request', {
|
||||
operation: 'ocr.client.extractReceipt',
|
||||
url,
|
||||
contentType,
|
||||
fileSize: fileBuffer.length,
|
||||
receiptType,
|
||||
});
|
||||
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('OCR receipt extract failed', {
|
||||
operation: 'ocr.client.extractReceipt.error',
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
const err: any = new Error(`OCR service error: ${response.status} - ${errorText}`);
|
||||
err.statusCode = response.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const result = (await response.json()) as ReceiptExtractionResponse;
|
||||
|
||||
logger.info('OCR receipt extract completed', {
|
||||
operation: 'ocr.client.extractReceipt.success',
|
||||
success: result.success,
|
||||
receiptType: result.receiptType,
|
||||
fieldCount: Object.keys(result.extractedFields).length,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract data from a maintenance receipt image using maintenance-specific OCR.
|
||||
*
|
||||
* @param fileBuffer - Image file buffer
|
||||
* @param contentType - MIME type of the file
|
||||
* @returns Receipt extraction result (receiptType: "maintenance")
|
||||
*/
|
||||
async extractMaintenanceReceipt(
|
||||
fileBuffer: Buffer,
|
||||
contentType: string
|
||||
): Promise<ReceiptExtractionResponse> {
|
||||
const formData = this.buildFormData(fileBuffer, contentType);
|
||||
const url = `${this.baseUrl}/extract/maintenance-receipt`;
|
||||
|
||||
logger.info('OCR maintenance receipt extract request', {
|
||||
operation: 'ocr.client.extractMaintenanceReceipt',
|
||||
url,
|
||||
contentType,
|
||||
fileSize: fileBuffer.length,
|
||||
});
|
||||
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('OCR maintenance receipt extract failed', {
|
||||
operation: 'ocr.client.extractMaintenanceReceipt.error',
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
const err: any = new Error(`OCR service error: ${response.status} - ${errorText}`);
|
||||
err.statusCode = response.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const result = (await response.json()) as ReceiptExtractionResponse;
|
||||
|
||||
logger.info('OCR maintenance receipt extract completed', {
|
||||
operation: 'ocr.client.extractMaintenanceReceipt.success',
|
||||
success: result.success,
|
||||
receiptType: result.receiptType,
|
||||
fieldCount: Object.keys(result.extractedFields).length,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an async OCR job for large files.
|
||||
*
|
||||
@@ -209,6 +318,110 @@ export class OcrClient {
|
||||
return (await response.json()) as JobResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an async manual extraction job for PDF owner's manuals.
|
||||
*
|
||||
* @param fileBuffer - PDF file buffer
|
||||
* @param contentType - MIME type of the file (must be application/pdf)
|
||||
* @param vehicleId - Optional vehicle ID for context
|
||||
* @returns Manual job submission response
|
||||
*/
|
||||
async submitManualJob(
|
||||
fileBuffer: Buffer,
|
||||
contentType: string,
|
||||
vehicleId?: string
|
||||
): Promise<ManualJobResponse> {
|
||||
const formData = this.buildFormData(fileBuffer, contentType);
|
||||
if (vehicleId) {
|
||||
formData.append('vehicle_id', vehicleId);
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}/extract/manual`;
|
||||
|
||||
logger.info('OCR manual job submit request', {
|
||||
operation: 'ocr.client.submitManualJob',
|
||||
url,
|
||||
contentType,
|
||||
fileSize: fileBuffer.length,
|
||||
hasVehicleId: !!vehicleId,
|
||||
});
|
||||
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('OCR manual job submit failed', {
|
||||
operation: 'ocr.client.submitManualJob.error',
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
throw new Error(`OCR service error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as ManualJobResponse;
|
||||
|
||||
logger.info('OCR manual job submitted', {
|
||||
operation: 'ocr.client.submitManualJob.success',
|
||||
jobId: result.jobId,
|
||||
status: result.status,
|
||||
estimatedSeconds: result.estimatedSeconds,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a VIN string into structured vehicle data via Gemini.
|
||||
*
|
||||
* Unlike other OCR methods, this sends JSON (not multipart) because
|
||||
* VIN decode has no file upload.
|
||||
*
|
||||
* @param vin - 17-character Vehicle Identification Number
|
||||
* @returns Structured vehicle data from Gemini decode
|
||||
*/
|
||||
async decodeVin(vin: string): Promise<VinDecodeResponse> {
|
||||
const url = `${this.baseUrl}/decode/vin`;
|
||||
|
||||
logger.info('OCR VIN decode request', {
|
||||
operation: 'ocr.client.decodeVin',
|
||||
url,
|
||||
vin,
|
||||
});
|
||||
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vin }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error('OCR VIN decode failed', {
|
||||
operation: 'ocr.client.decodeVin.error',
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
const err: any = new Error(`OCR service error: ${response.status} - ${errorText}`);
|
||||
err.statusCode = response.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const result = (await response.json()) as VinDecodeResponse;
|
||||
|
||||
logger.info('OCR VIN decode completed', {
|
||||
operation: 'ocr.client.decodeVin.success',
|
||||
success: result.success,
|
||||
vin: result.vin,
|
||||
confidence: result.confidence,
|
||||
processingTimeMs: result.processingTimeMs,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the OCR service is healthy.
|
||||
*
|
||||
|
||||
@@ -8,4 +8,5 @@ export type {
|
||||
JobResponse,
|
||||
JobStatus,
|
||||
OcrResponse,
|
||||
ReceiptExtractionResponse,
|
||||
} from './domain/ocr.types';
|
||||
|
||||
295
backend/src/features/ocr/tests/unit/ocr-manual.test.ts
Normal file
295
backend/src/features/ocr/tests/unit/ocr-manual.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for OCR manual extraction endpoint
|
||||
*/
|
||||
|
||||
import { OcrService } from '../../domain/ocr.service';
|
||||
import { ocrClient, JobNotFoundError } from '../../external/ocr-client';
|
||||
import type { ManualJobResponse } from '../../domain/ocr.types';
|
||||
|
||||
jest.mock('../../external/ocr-client');
|
||||
jest.mock('../../../../core/logging/logger');
|
||||
|
||||
const mockSubmitManualJob = ocrClient.submitManualJob as jest.MockedFunction<
|
||||
typeof ocrClient.submitManualJob
|
||||
>;
|
||||
const mockGetJobStatus = ocrClient.getJobStatus as jest.MockedFunction<
|
||||
typeof ocrClient.getJobStatus
|
||||
>;
|
||||
|
||||
describe('OcrService.submitManualJob', () => {
|
||||
let service: OcrService;
|
||||
|
||||
const userId = 'test-user-id';
|
||||
|
||||
const mockManualJobResponse: ManualJobResponse = {
|
||||
jobId: 'manual-job-123',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
estimatedSeconds: 45,
|
||||
result: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
const mockCompletedJobResponse: ManualJobResponse = {
|
||||
jobId: 'manual-job-123',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result: {
|
||||
success: true,
|
||||
vehicleInfo: {
|
||||
make: 'Honda',
|
||||
model: 'Civic',
|
||||
year: 2023,
|
||||
},
|
||||
maintenanceSchedules: [
|
||||
{
|
||||
service: 'Engine Oil Change',
|
||||
intervalMiles: 5000,
|
||||
intervalMonths: 6,
|
||||
details: 'Use 0W-20 full synthetic oil',
|
||||
confidence: 0.95,
|
||||
subtypes: ['oil_change'],
|
||||
},
|
||||
{
|
||||
service: 'Tire Rotation',
|
||||
intervalMiles: 7500,
|
||||
intervalMonths: 6,
|
||||
details: null,
|
||||
confidence: 0.90,
|
||||
subtypes: ['tire_rotation'],
|
||||
},
|
||||
],
|
||||
rawTables: [],
|
||||
processingTimeMs: 45000,
|
||||
totalPages: 120,
|
||||
pagesProcessed: 120,
|
||||
error: null,
|
||||
},
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new OcrService();
|
||||
});
|
||||
|
||||
describe('valid manual job submission', () => {
|
||||
it('should return 202-style response with jobId for PDF submission', async () => {
|
||||
mockSubmitManualJob.mockResolvedValue(mockManualJobResponse);
|
||||
|
||||
const result = await service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('fake-pdf-data'),
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
|
||||
expect(result.jobId).toBe('manual-job-123');
|
||||
expect(result.status).toBe('pending');
|
||||
expect(result.progress).toBe(0);
|
||||
expect(result.estimatedSeconds).toBe(45);
|
||||
expect(result.result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should pass vehicleId to client when provided', async () => {
|
||||
mockSubmitManualJob.mockResolvedValue(mockManualJobResponse);
|
||||
|
||||
await service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('fake-pdf-data'),
|
||||
contentType: 'application/pdf',
|
||||
vehicleId: 'vehicle-abc',
|
||||
});
|
||||
|
||||
expect(mockSubmitManualJob).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
'application/pdf',
|
||||
'vehicle-abc'
|
||||
);
|
||||
});
|
||||
|
||||
it('should call client without vehicleId when not provided', async () => {
|
||||
mockSubmitManualJob.mockResolvedValue(mockManualJobResponse);
|
||||
|
||||
await service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('fake-pdf-data'),
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
|
||||
expect(mockSubmitManualJob).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
'application/pdf',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('completed job result', () => {
|
||||
it('should return completed result with maintenanceSchedules', async () => {
|
||||
mockSubmitManualJob.mockResolvedValue(mockCompletedJobResponse);
|
||||
|
||||
const result = await service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('fake-pdf-data'),
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.result).toBeDefined();
|
||||
expect(result.result!.success).toBe(true);
|
||||
expect(result.result!.maintenanceSchedules).toHaveLength(2);
|
||||
expect(result.result!.maintenanceSchedules[0].service).toBe('Engine Oil Change');
|
||||
expect(result.result!.maintenanceSchedules[0].intervalMiles).toBe(5000);
|
||||
expect(result.result!.maintenanceSchedules[0].subtypes).toEqual(['oil_change']);
|
||||
expect(result.result!.vehicleInfo.make).toBe('Honda');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw 400 for non-PDF file (JPEG)', async () => {
|
||||
await expect(
|
||||
service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('fake-image-data'),
|
||||
contentType: 'image/jpeg',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 400 for non-PDF file (PNG)', async () => {
|
||||
await expect(
|
||||
service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('fake-image-data'),
|
||||
contentType: 'image/png',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 400 for text/plain', async () => {
|
||||
await expect(
|
||||
service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('not a pdf'),
|
||||
contentType: 'text/plain',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 413 for oversized file', async () => {
|
||||
const largeBuffer = Buffer.alloc(201 * 1024 * 1024); // 201MB
|
||||
|
||||
await expect(
|
||||
service.submitManualJob(userId, {
|
||||
fileBuffer: largeBuffer,
|
||||
contentType: 'application/pdf',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 413,
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept file at 200MB boundary', async () => {
|
||||
mockSubmitManualJob.mockResolvedValue(mockManualJobResponse);
|
||||
const exactBuffer = Buffer.alloc(200 * 1024 * 1024); // exactly 200MB
|
||||
|
||||
const result = await service.submitManualJob(userId, {
|
||||
fileBuffer: exactBuffer,
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
|
||||
expect(result.jobId).toBe('manual-job-123');
|
||||
});
|
||||
|
||||
it('should propagate OCR service errors', async () => {
|
||||
mockSubmitManualJob.mockRejectedValue(
|
||||
new Error('OCR service error: 500 - Internal error')
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('fake-pdf-data'),
|
||||
contentType: 'application/pdf',
|
||||
})
|
||||
).rejects.toThrow('OCR service error: 500 - Internal error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OcrService.getJobStatus (manual job polling)', () => {
|
||||
let service: OcrService;
|
||||
const userId = 'test-user-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new OcrService();
|
||||
});
|
||||
|
||||
it('should return completed manual job with schedules', async () => {
|
||||
mockGetJobStatus.mockResolvedValue({
|
||||
jobId: 'manual-job-123',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
});
|
||||
|
||||
const result = await service.getJobStatus(userId, 'manual-job-123');
|
||||
|
||||
expect(result.jobId).toBe('manual-job-123');
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.progress).toBe(100);
|
||||
});
|
||||
|
||||
it('should return processing status with progress', async () => {
|
||||
mockGetJobStatus.mockResolvedValue({
|
||||
jobId: 'manual-job-456',
|
||||
status: 'processing',
|
||||
progress: 50,
|
||||
});
|
||||
|
||||
const result = await service.getJobStatus(userId, 'manual-job-456');
|
||||
|
||||
expect(result.status).toBe('processing');
|
||||
expect(result.progress).toBe(50);
|
||||
});
|
||||
|
||||
it('should throw 410 Gone for expired/missing job', async () => {
|
||||
mockGetJobStatus.mockRejectedValue(new JobNotFoundError('expired-job-789'));
|
||||
|
||||
await expect(
|
||||
service.getJobStatus(userId, 'expired-job-789')
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 410,
|
||||
message: 'Job expired (max 2 hours). Please resubmit.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manual extraction controller validations', () => {
|
||||
it('PDF magic bytes validation rejects non-PDF content', () => {
|
||||
// Controller validates first 4 bytes match %PDF (0x25504446)
|
||||
// Files without %PDF header receive 415 Unsupported Media Type
|
||||
const pdfMagic = Buffer.from('%PDF');
|
||||
const notPdf = Buffer.from('JFIF');
|
||||
|
||||
expect(pdfMagic.subarray(0, 4).equals(Buffer.from('%PDF'))).toBe(true);
|
||||
expect(notPdf.subarray(0, 4).equals(Buffer.from('%PDF'))).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts files with .pdf extension even if mimetype is octet-stream', () => {
|
||||
// Controller checks: contentType === 'application/pdf' OR filename.endsWith('.pdf')
|
||||
// This allows uploads where browser sends generic content type
|
||||
const filename = 'owners-manual.pdf';
|
||||
expect(filename.toLowerCase().endsWith('.pdf')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manual route tier guard', () => {
|
||||
it('route is configured with tier guard for document.scanMaintenanceSchedule', async () => {
|
||||
// Tier guard is enforced at route level via requireTier('document.scanMaintenanceSchedule')
|
||||
// preHandler: [requireAuth, requireTier('document.scanMaintenanceSchedule')]
|
||||
// Free-tier users receive 403 TIER_REQUIRED before the handler executes.
|
||||
// Middleware behavior is tested in core/middleware/require-tier.test.ts
|
||||
const { requireTier } = await import('../../../../core/middleware/require-tier');
|
||||
const handler = requireTier('document.scanMaintenanceSchedule');
|
||||
expect(typeof handler).toBe('function');
|
||||
});
|
||||
});
|
||||
209
backend/src/features/ocr/tests/unit/ocr-receipt.test.ts
Normal file
209
backend/src/features/ocr/tests/unit/ocr-receipt.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for OCR receipt extraction endpoint
|
||||
*/
|
||||
|
||||
import { OcrService } from '../../domain/ocr.service';
|
||||
import { ocrClient } from '../../external/ocr-client';
|
||||
import type { ReceiptExtractionResponse } from '../../domain/ocr.types';
|
||||
|
||||
jest.mock('../../external/ocr-client');
|
||||
jest.mock('../../../../core/logging/logger');
|
||||
|
||||
const mockExtractReceipt = ocrClient.extractReceipt as jest.MockedFunction<
|
||||
typeof ocrClient.extractReceipt
|
||||
>;
|
||||
|
||||
describe('OcrService.extractReceipt', () => {
|
||||
let service: OcrService;
|
||||
|
||||
const userId = 'test-user-id';
|
||||
|
||||
const mockReceiptResponse: ReceiptExtractionResponse = {
|
||||
success: true,
|
||||
receiptType: 'fuel',
|
||||
extractedFields: {
|
||||
merchantName: { value: 'Shell Gas Station', confidence: 0.92 },
|
||||
transactionDate: { value: '2026-02-10', confidence: 0.88 },
|
||||
totalAmount: { value: '45.67', confidence: 0.95 },
|
||||
fuelQuantity: { value: '12.345', confidence: 0.87 },
|
||||
pricePerUnit: { value: '3.699', confidence: 0.90 },
|
||||
fuelGrade: { value: 'Regular 87', confidence: 0.85 },
|
||||
},
|
||||
rawText: 'SHELL\n02/10/2026\nREGULAR 87\n12.345 GAL\n$3.699/GAL\nTOTAL $45.67',
|
||||
processingTimeMs: 1250,
|
||||
error: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new OcrService();
|
||||
});
|
||||
|
||||
describe('valid receipt extraction', () => {
|
||||
it('should return receipt extraction response for valid image', async () => {
|
||||
mockExtractReceipt.mockResolvedValue(mockReceiptResponse);
|
||||
|
||||
const result = await service.extractReceipt(userId, {
|
||||
fileBuffer: Buffer.from('fake-image-data'),
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.receiptType).toBe('fuel');
|
||||
expect(result.extractedFields.merchantName.value).toBe('Shell Gas Station');
|
||||
expect(result.extractedFields.totalAmount.value).toBe('45.67');
|
||||
expect(result.extractedFields.fuelQuantity.value).toBe('12.345');
|
||||
expect(result.extractedFields.pricePerUnit.value).toBe('3.699');
|
||||
expect(result.extractedFields.fuelGrade.value).toBe('Regular 87');
|
||||
expect(result.extractedFields.transactionDate.value).toBe('2026-02-10');
|
||||
expect(result.processingTimeMs).toBe(1250);
|
||||
});
|
||||
|
||||
it('should pass receipt_type hint to client when provided', async () => {
|
||||
mockExtractReceipt.mockResolvedValue(mockReceiptResponse);
|
||||
|
||||
await service.extractReceipt(userId, {
|
||||
fileBuffer: Buffer.from('fake-image-data'),
|
||||
contentType: 'image/jpeg',
|
||||
receiptType: 'fuel',
|
||||
});
|
||||
|
||||
expect(mockExtractReceipt).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
'image/jpeg',
|
||||
'fuel'
|
||||
);
|
||||
});
|
||||
|
||||
it('should support PNG images', async () => {
|
||||
mockExtractReceipt.mockResolvedValue(mockReceiptResponse);
|
||||
|
||||
const result = await service.extractReceipt(userId, {
|
||||
fileBuffer: Buffer.from('fake-png-data'),
|
||||
contentType: 'image/png',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should support HEIC images', async () => {
|
||||
mockExtractReceipt.mockResolvedValue(mockReceiptResponse);
|
||||
|
||||
const result = await service.extractReceipt(userId, {
|
||||
fileBuffer: Buffer.from('fake-heic-data'),
|
||||
contentType: 'image/heic',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('missing optional fields', () => {
|
||||
it('should handle response with some fields not detected', async () => {
|
||||
const partialResponse: ReceiptExtractionResponse = {
|
||||
success: true,
|
||||
receiptType: 'fuel',
|
||||
extractedFields: {
|
||||
merchantName: { value: 'Unknown Station', confidence: 0.60 },
|
||||
totalAmount: { value: '30.00', confidence: 0.88 },
|
||||
},
|
||||
rawText: 'UNKNOWN STATION\nTOTAL $30.00',
|
||||
processingTimeMs: 980,
|
||||
error: null,
|
||||
};
|
||||
|
||||
mockExtractReceipt.mockResolvedValue(partialResponse);
|
||||
|
||||
const result = await service.extractReceipt(userId, {
|
||||
fileBuffer: Buffer.from('fake-image-data'),
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.extractedFields.merchantName).toBeDefined();
|
||||
expect(result.extractedFields.totalAmount).toBeDefined();
|
||||
expect(result.extractedFields.fuelQuantity).toBeUndefined();
|
||||
expect(result.extractedFields.pricePerUnit).toBeUndefined();
|
||||
expect(result.extractedFields.fuelGrade).toBeUndefined();
|
||||
expect(result.extractedFields.transactionDate).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw 415 for unsupported file type (PDF)', async () => {
|
||||
await expect(
|
||||
service.extractReceipt(userId, {
|
||||
fileBuffer: Buffer.from('fake-pdf-data'),
|
||||
contentType: 'application/pdf',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 415,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 415 for text/plain', async () => {
|
||||
await expect(
|
||||
service.extractReceipt(userId, {
|
||||
fileBuffer: Buffer.from('not an image'),
|
||||
contentType: 'text/plain',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 415,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 413 for oversized file', async () => {
|
||||
const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB
|
||||
|
||||
await expect(
|
||||
service.extractReceipt(userId, {
|
||||
fileBuffer: largeBuffer,
|
||||
contentType: 'image/jpeg',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 413,
|
||||
});
|
||||
});
|
||||
|
||||
it('should propagate Python 422 with statusCode for controller forwarding', async () => {
|
||||
const err: any = new Error('OCR service error: 422 - Failed to extract receipt data');
|
||||
err.statusCode = 422;
|
||||
mockExtractReceipt.mockRejectedValue(err);
|
||||
|
||||
await expect(
|
||||
service.extractReceipt(userId, {
|
||||
fileBuffer: Buffer.from('fake-image-data'),
|
||||
contentType: 'image/jpeg',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 422,
|
||||
message: 'OCR service error: 422 - Failed to extract receipt data',
|
||||
});
|
||||
});
|
||||
|
||||
it('should propagate OCR service errors', async () => {
|
||||
mockExtractReceipt.mockRejectedValue(
|
||||
new Error('OCR service error: 500 - Internal error')
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.extractReceipt(userId, {
|
||||
fileBuffer: Buffer.from('fake-image-data'),
|
||||
contentType: 'image/jpeg',
|
||||
})
|
||||
).rejects.toThrow('OCR service error: 500 - Internal error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Receipt route tier guard', () => {
|
||||
it('route is configured with requireTier fuelLog.receiptScan', async () => {
|
||||
// Tier guard is enforced at route level via requireTier('fuelLog.receiptScan')
|
||||
// preHandler: [requireAuth, requireTier('fuelLog.receiptScan')]
|
||||
// Free-tier users receive 403 TIER_REQUIRED before the handler executes.
|
||||
// Middleware behavior is tested in core/middleware/require-tier.test.ts
|
||||
const { requireTier } = await import('../../../../core/middleware/require-tier');
|
||||
const handler = requireTier('fuelLog.receiptScan');
|
||||
expect(typeof handler).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ export class OnboardingController {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error in savePreferences controller', {
|
||||
error,
|
||||
userId: (request as AuthenticatedRequest).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User profile not found') {
|
||||
@@ -86,7 +86,7 @@ export class OnboardingController {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error in completeOnboarding controller', {
|
||||
error,
|
||||
userId: (request as AuthenticatedRequest).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User profile not found') {
|
||||
@@ -124,7 +124,7 @@ export class OnboardingController {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error in getStatus controller', {
|
||||
error,
|
||||
userId: (request as AuthenticatedRequest).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
});
|
||||
|
||||
if (errorMessage === 'User profile not found') {
|
||||
|
||||
@@ -7,7 +7,7 @@ export class OwnershipCostsController {
|
||||
private readonly service = new OwnershipCostsService();
|
||||
|
||||
async list(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
logger.info('Ownership costs list requested', {
|
||||
operation: 'ownership-costs.list',
|
||||
@@ -35,7 +35,7 @@ export class OwnershipCostsController {
|
||||
}
|
||||
|
||||
async get(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
const costId = request.params.id;
|
||||
|
||||
logger.info('Ownership cost get requested', {
|
||||
@@ -66,7 +66,7 @@ export class OwnershipCostsController {
|
||||
}
|
||||
|
||||
async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
logger.info('Ownership cost create requested', {
|
||||
operation: 'ownership-costs.create',
|
||||
@@ -91,7 +91,7 @@ export class OwnershipCostsController {
|
||||
}
|
||||
|
||||
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 costId = request.params.id;
|
||||
|
||||
logger.info('Ownership cost update requested', {
|
||||
@@ -123,7 +123,7 @@ export class OwnershipCostsController {
|
||||
}
|
||||
|
||||
async remove(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||
const userId = (request as any).user?.sub as string;
|
||||
const userId = request.userContext!.userId;
|
||||
const costId = request.params.id;
|
||||
|
||||
logger.info('Ownership cost delete requested', {
|
||||
|
||||
@@ -117,7 +117,7 @@ platform/
|
||||
When implemented, VIN decoding will use:
|
||||
1. **Cache First**: Check Redis (7-day TTL for success, 1-hour for failures)
|
||||
2. **PostgreSQL**: Database function for high-confidence decode
|
||||
3. **vPIC Fallback**: NHTSA vPIC API with circuit breaker protection
|
||||
3. **OCR Service Fallback**: Gemini VIN decode via OCR service
|
||||
4. **Graceful Degradation**: Return meaningful errors when all sources fail
|
||||
|
||||
### Database Schema
|
||||
@@ -164,7 +164,7 @@ When VIN decoding is implemented:
|
||||
|
||||
### External APIs (Planned/Future)
|
||||
When VIN decoding is implemented:
|
||||
- **NHTSA vPIC**: https://vpic.nhtsa.dot.gov/api (VIN decoding fallback)
|
||||
- **OCR Service**: Gemini VIN decode via mvp-ocr (VIN decoding fallback)
|
||||
|
||||
### Database Tables
|
||||
- **vehicle_options** - Hierarchical vehicle data (years, makes, models, trims, engines, transmissions)
|
||||
@@ -269,7 +269,7 @@ npm run lint
|
||||
## Future Considerations
|
||||
|
||||
### Planned Features
|
||||
- VIN decoding endpoint with PostgreSQL + vPIC fallback
|
||||
- VIN decoding endpoint with PostgreSQL + Gemini/OCR service fallback
|
||||
- Circuit breaker pattern for external API resilience
|
||||
|
||||
### Potential Enhancements
|
||||
|
||||
@@ -61,19 +61,3 @@ export interface VINDecodeResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* vPIC API response structure (NHTSA)
|
||||
*/
|
||||
export interface VPICVariable {
|
||||
Variable: string;
|
||||
Value: string | null;
|
||||
ValueId: string | null;
|
||||
VariableId: number;
|
||||
}
|
||||
|
||||
export interface VPICResponse {
|
||||
Count: number;
|
||||
Message: string;
|
||||
SearchCriteria: string;
|
||||
Results: VPICVariable[];
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export class CommunityStationsController {
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
// Validate request body
|
||||
const validation = submitCommunityStationSchema.safeParse(request.body);
|
||||
@@ -62,7 +62,7 @@ export class CommunityStationsController {
|
||||
|
||||
return reply.code(201).send(station);
|
||||
} catch (error: any) {
|
||||
logger.error('Error submitting station', { error, userId: (request as any).user?.sub });
|
||||
logger.error('Error submitting station', { error, userId: request.userContext?.userId });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to submit station'
|
||||
@@ -79,7 +79,7 @@ export class CommunityStationsController {
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
// Validate query params
|
||||
const validation = paginationSchema.safeParse(request.query);
|
||||
@@ -94,7 +94,7 @@ export class CommunityStationsController {
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting user submissions', { error, userId: (request as any).user?.sub });
|
||||
logger.error('Error getting user submissions', { error, userId: request.userContext?.userId });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to retrieve submissions'
|
||||
@@ -111,7 +111,7 @@ export class CommunityStationsController {
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
// Validate params
|
||||
const validation = stationIdSchema.safeParse(request.params);
|
||||
@@ -128,7 +128,7 @@ export class CommunityStationsController {
|
||||
} catch (error: any) {
|
||||
logger.error('Error withdrawing submission', {
|
||||
error,
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
stationId: request.params.id
|
||||
});
|
||||
|
||||
@@ -252,7 +252,7 @@ export class CommunityStationsController {
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
// Validate params
|
||||
const paramsValidation = stationIdSchema.safeParse(request.params);
|
||||
@@ -280,7 +280,7 @@ export class CommunityStationsController {
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Error reporting removal', { error, userId: (request as any).user?.sub });
|
||||
logger.error('Error reporting removal', { error, userId: request.userContext?.userId });
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
@@ -379,7 +379,7 @@ export class CommunityStationsController {
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const adminId = (request as any).user.sub;
|
||||
const adminId = request.userContext!.userId;
|
||||
|
||||
// Validate params
|
||||
const paramsValidation = stationIdSchema.safeParse(request.params);
|
||||
@@ -422,7 +422,7 @@ export class CommunityStationsController {
|
||||
|
||||
return reply.code(200).send(station);
|
||||
} catch (error: any) {
|
||||
logger.error('Error reviewing station', { error, adminId: (request as any).user?.sub });
|
||||
logger.error('Error reviewing station', { error, adminId: request.userContext?.userId });
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
|
||||
@@ -10,6 +10,7 @@ import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import {
|
||||
StationSearchBody,
|
||||
StationMatchBody,
|
||||
SaveStationBody,
|
||||
StationParams,
|
||||
UpdateSavedStationBody
|
||||
@@ -26,7 +27,7 @@ export class StationsController {
|
||||
|
||||
async searchStations(request: FastifyRequest<{ Body: StationSearchBody }>, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const { latitude, longitude, radius, fuelType } = request.body;
|
||||
|
||||
if (!latitude || !longitude) {
|
||||
@@ -45,7 +46,7 @@ export class StationsController {
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Error searching stations', { error, userId: (request as any).user?.sub });
|
||||
logger.error('Error searching stations', { error, userId: request.userContext?.userId });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to search stations'
|
||||
@@ -53,9 +54,32 @@ export class StationsController {
|
||||
}
|
||||
}
|
||||
|
||||
async matchStation(request: FastifyRequest<{ Body: StationMatchBody }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { merchantName } = request.body;
|
||||
|
||||
if (!merchantName || !merchantName.trim()) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Merchant name is required',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.stationsService.matchStationFromReceipt(merchantName);
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Error matching station from receipt', { error, merchantName: request.body?.merchantName });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to match station',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const {
|
||||
placeId,
|
||||
nickname,
|
||||
@@ -82,7 +106,7 @@ export class StationsController {
|
||||
|
||||
return reply.code(201).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Error saving station', { error, userId: (request as any).user?.sub });
|
||||
logger.error('Error saving station', { error, userId: request.userContext?.userId });
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
@@ -103,7 +127,7 @@ export class StationsController {
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const { placeId } = request.params;
|
||||
|
||||
const result = await this.stationsService.updateSavedStation(placeId, userId, request.body);
|
||||
@@ -113,7 +137,7 @@ export class StationsController {
|
||||
logger.error('Error updating saved station', {
|
||||
error,
|
||||
placeId: request.params.placeId,
|
||||
userId: (request as any).user?.sub
|
||||
userId: request.userContext?.userId
|
||||
});
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
@@ -132,12 +156,12 @@ export class StationsController {
|
||||
|
||||
async getSavedStations(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const result = await this.stationsService.getUserSavedStations(userId);
|
||||
|
||||
return reply.code(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Error getting saved stations', { error, userId: (request as any).user?.sub });
|
||||
logger.error('Error getting saved stations', { error, userId: request.userContext?.userId });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get saved stations'
|
||||
@@ -147,14 +171,14 @@ export class StationsController {
|
||||
|
||||
async removeSavedStation(request: FastifyRequest<{ Params: StationParams }>, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const { placeId } = request.params;
|
||||
|
||||
await this.stationsService.removeSavedStation(placeId, userId);
|
||||
|
||||
return reply.code(204).send();
|
||||
} catch (error: any) {
|
||||
logger.error('Error removing saved station', { error, placeId: request.params.placeId, userId: (request as any).user?.sub });
|
||||
logger.error('Error removing saved station', { error, placeId: request.params.placeId, userId: request.userContext?.userId });
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import {
|
||||
StationSearchBody,
|
||||
StationMatchBody,
|
||||
SaveStationBody,
|
||||
StationParams,
|
||||
UpdateSavedStationBody
|
||||
@@ -25,6 +26,12 @@ export const stationsRoutes: FastifyPluginAsync = async (
|
||||
handler: stationsController.searchStations.bind(stationsController)
|
||||
});
|
||||
|
||||
// POST /api/stations/match - Match station from receipt merchant name
|
||||
fastify.post<{ Body: StationMatchBody }>('/stations/match', {
|
||||
preHandler: [fastify.authenticate],
|
||||
handler: stationsController.matchStation.bind(stationsController)
|
||||
});
|
||||
|
||||
// POST /api/stations/save - Save a station to user's favorites
|
||||
fastify.post<{ Body: SaveStationBody }>('/stations/save', {
|
||||
preHandler: [fastify.authenticate],
|
||||
|
||||
@@ -7,6 +7,7 @@ import { googleMapsClient } from '../external/google-maps/google-maps.client';
|
||||
import {
|
||||
StationSearchRequest,
|
||||
StationSearchResponse,
|
||||
StationMatchResponse,
|
||||
SavedStation,
|
||||
StationSavedMetadata,
|
||||
UpdateSavedStationBody
|
||||
@@ -154,6 +155,27 @@ export class StationsService {
|
||||
return enriched;
|
||||
}
|
||||
|
||||
async matchStationFromReceipt(merchantName: string): Promise<StationMatchResponse> {
|
||||
const trimmed = merchantName.trim();
|
||||
if (!trimmed) {
|
||||
return { matched: false, station: null };
|
||||
}
|
||||
|
||||
logger.info('Matching station from receipt merchant name', { merchantName: trimmed });
|
||||
|
||||
const station = await googleMapsClient.searchStationByName(trimmed);
|
||||
|
||||
if (station) {
|
||||
// Cache matched station for future reference (e.g. saveStation)
|
||||
await this.repository.cacheStation(station);
|
||||
}
|
||||
|
||||
return {
|
||||
matched: station !== null,
|
||||
station,
|
||||
};
|
||||
}
|
||||
|
||||
async removeSavedStation(placeId: string, userId: string) {
|
||||
const removed = await this.repository.deleteSavedStation(userId, placeId);
|
||||
|
||||
|
||||
@@ -89,3 +89,12 @@ export interface StationSavedMetadata {
|
||||
has93Octane: boolean;
|
||||
has93OctaneEthanolFree: boolean;
|
||||
}
|
||||
|
||||
export interface StationMatchBody {
|
||||
merchantName: string;
|
||||
}
|
||||
|
||||
export interface StationMatchResponse {
|
||||
matched: boolean;
|
||||
station: Station | null;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import axios from 'axios';
|
||||
import { appConfig } from '../../../../core/config/config-loader';
|
||||
import { logger } from '../../../../core/logging/logger';
|
||||
import { cacheService } from '../../../../core/config/redis';
|
||||
import { GooglePlacesResponse, GooglePlace } from './google-maps.types';
|
||||
import { GooglePlacesResponse, GoogleTextSearchResponse, GooglePlace } from './google-maps.types';
|
||||
import { Station } from '../../domain/stations.types';
|
||||
|
||||
export class GoogleMapsClient {
|
||||
@@ -103,6 +103,92 @@ export class GoogleMapsClient {
|
||||
return station;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a gas station by merchant name using Google Places Text Search API.
|
||||
* Used to match receipt merchant names (e.g. "Shell", "COSTCO #123") to actual stations.
|
||||
*/
|
||||
async searchStationByName(merchantName: string): Promise<Station | null> {
|
||||
const query = `${merchantName} gas station`;
|
||||
const cacheKey = `station-match:${query.toLowerCase().trim()}`;
|
||||
|
||||
try {
|
||||
const cached = await cacheService.get<Station | null>(cacheKey);
|
||||
if (cached !== undefined && cached !== null) {
|
||||
logger.debug('Station name match cache hit', { merchantName });
|
||||
return cached;
|
||||
}
|
||||
|
||||
logger.info('Searching Google Places Text Search for station', { merchantName, query });
|
||||
|
||||
const response = await axios.get<GoogleTextSearchResponse>(
|
||||
`${this.baseURL}/textsearch/json`,
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
type: 'gas_station',
|
||||
key: this.apiKey,
|
||||
},
|
||||
timeout: 5000,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.status !== 'OK' && response.data.status !== 'ZERO_RESULTS') {
|
||||
throw new Error(`Google Places Text Search API error: ${response.data.status}`);
|
||||
}
|
||||
|
||||
if (response.data.results.length === 0) {
|
||||
await cacheService.set(cacheKey, null, this.cacheTTL);
|
||||
return null;
|
||||
}
|
||||
|
||||
const topResult = response.data.results[0];
|
||||
const station = this.transformTextSearchResult(topResult);
|
||||
|
||||
await cacheService.set(cacheKey, station, this.cacheTTL);
|
||||
return station;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
||||
logger.warn('Station name search timed out', { merchantName, timeoutMs: 5000 });
|
||||
} else {
|
||||
logger.error('Station name search failed', { error, merchantName });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private transformTextSearchResult(place: GooglePlace): Station {
|
||||
let photoReference: string | undefined;
|
||||
if (place.photos && place.photos.length > 0 && place.photos[0]) {
|
||||
photoReference = place.photos[0].photo_reference;
|
||||
}
|
||||
|
||||
// Text Search returns formatted_address instead of vicinity
|
||||
const address = (place as any).formatted_address || place.vicinity || '';
|
||||
|
||||
const station: Station = {
|
||||
id: place.place_id,
|
||||
placeId: place.place_id,
|
||||
name: place.name,
|
||||
address,
|
||||
latitude: place.geometry.location.lat,
|
||||
longitude: place.geometry.location.lng,
|
||||
};
|
||||
|
||||
if (photoReference !== undefined) {
|
||||
station.photoReference = photoReference;
|
||||
}
|
||||
|
||||
if (place.opening_hours?.open_now !== undefined) {
|
||||
station.isOpen = place.opening_hours.open_now;
|
||||
}
|
||||
|
||||
if (place.rating !== undefined) {
|
||||
station.rating = place.rating;
|
||||
}
|
||||
|
||||
return station;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch photo from Google Maps API using photo reference
|
||||
* Used by photo proxy endpoint to serve photos without exposing API key
|
||||
|
||||
@@ -52,4 +52,10 @@ export interface GooglePlaceDetails {
|
||||
website?: string;
|
||||
};
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface GoogleTextSearchResponse {
|
||||
results: GooglePlace[];
|
||||
status: string;
|
||||
next_page_token?: string;
|
||||
}
|
||||
@@ -12,8 +12,8 @@ describe('Community Stations API Integration Tests', () => {
|
||||
let app: FastifyInstance;
|
||||
let pool: Pool;
|
||||
|
||||
const testUserId = 'auth0|test-user-123';
|
||||
const testAdminId = 'auth0|test-admin-123';
|
||||
const testUserId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const testAdminId = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
|
||||
const mockStationData = {
|
||||
name: 'Test Gas Station',
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for station matching from receipt merchant names
|
||||
*/
|
||||
|
||||
// Mock config-loader before any imports that use it
|
||||
jest.mock('../../../../core/config/config-loader', () => ({
|
||||
appConfig: {
|
||||
secrets: { google_maps_api_key: 'mock-api-key' },
|
||||
getDatabaseUrl: () => 'postgresql://mock:mock@localhost/mock',
|
||||
getRedisUrl: () => 'redis://localhost',
|
||||
get: () => ({}),
|
||||
},
|
||||
}));
|
||||
jest.mock('axios');
|
||||
jest.mock('../../../../core/config/redis');
|
||||
jest.mock('../../../../core/logging/logger');
|
||||
jest.mock('../../data/stations.repository');
|
||||
jest.mock('../../external/google-maps/google-maps.client', () => {
|
||||
const { GoogleMapsClient } = jest.requireActual('../../external/google-maps/google-maps.client');
|
||||
return {
|
||||
GoogleMapsClient,
|
||||
googleMapsClient: {
|
||||
searchNearbyStations: jest.fn(),
|
||||
searchStationByName: jest.fn(),
|
||||
fetchPhoto: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import axios from 'axios';
|
||||
import { GoogleMapsClient } from '../../external/google-maps/google-maps.client';
|
||||
import { StationsService } from '../../domain/stations.service';
|
||||
import { StationsRepository } from '../../data/stations.repository';
|
||||
import { googleMapsClient } from '../../external/google-maps/google-maps.client';
|
||||
import { logger } from '../../../../core/logging/logger';
|
||||
import { mockStations } from '../fixtures/mock-stations';
|
||||
|
||||
describe('Station Matching from Receipt', () => {
|
||||
describe('GoogleMapsClient.searchStationByName', () => {
|
||||
let client: GoogleMapsClient;
|
||||
let mockAxios: jest.Mocked<typeof axios>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAxios = axios as jest.Mocked<typeof axios>;
|
||||
client = new GoogleMapsClient();
|
||||
});
|
||||
|
||||
it('should match a known station name like "Shell"', async () => {
|
||||
mockAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
place_id: 'ChIJ_shell_match',
|
||||
name: 'Shell Gas Station',
|
||||
formatted_address: '123 Main St, San Francisco, CA 94105',
|
||||
geometry: { location: { lat: 37.7749, lng: -122.4194 } },
|
||||
rating: 4.2,
|
||||
photos: [{ photo_reference: 'shell-photo-ref' }],
|
||||
opening_hours: { open_now: true },
|
||||
types: ['gas_station'],
|
||||
},
|
||||
],
|
||||
status: 'OK',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.searchStationByName('Shell');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.placeId).toBe('ChIJ_shell_match');
|
||||
expect(result?.name).toBe('Shell Gas Station');
|
||||
expect(result?.address).toBe('123 Main St, San Francisco, CA 94105');
|
||||
expect(mockAxios.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining('textsearch/json'),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
query: 'Shell gas station',
|
||||
type: 'gas_station',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should match abbreviated names like "COSTCO #123"', async () => {
|
||||
mockAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
place_id: 'ChIJ_costco_match',
|
||||
name: 'Costco Gasoline',
|
||||
formatted_address: '2000 El Camino Real, Redwood City, CA',
|
||||
geometry: { location: { lat: 37.4849, lng: -122.2278 } },
|
||||
rating: 4.5,
|
||||
types: ['gas_station'],
|
||||
},
|
||||
],
|
||||
status: 'OK',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.searchStationByName('COSTCO #123');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('Costco Gasoline');
|
||||
expect(result?.placeId).toBe('ChIJ_costco_match');
|
||||
});
|
||||
|
||||
it('should match "BP" station name', async () => {
|
||||
mockAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
place_id: 'ChIJ_bp_match',
|
||||
name: 'BP',
|
||||
formatted_address: '500 Market St, San Francisco, CA',
|
||||
geometry: { location: { lat: 37.79, lng: -122.40 } },
|
||||
types: ['gas_station'],
|
||||
},
|
||||
],
|
||||
status: 'OK',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.searchStationByName('BP');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe('BP');
|
||||
});
|
||||
|
||||
it('should return null when no match is found', async () => {
|
||||
mockAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
results: [],
|
||||
status: 'ZERO_RESULTS',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.searchStationByName('Unknown Station XYZ123');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null gracefully on API error', async () => {
|
||||
mockAxios.get.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await client.searchStationByName('Shell');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null on API denial', async () => {
|
||||
mockAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
results: [],
|
||||
status: 'REQUEST_DENIED',
|
||||
error_message: 'Invalid key',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.searchStationByName('Shell');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null with logged warning on Places API timeout', async () => {
|
||||
const timeoutError = new Error('timeout of 5000ms exceeded') as any;
|
||||
timeoutError.code = 'ECONNABORTED';
|
||||
mockAxios.get.mockRejectedValue(timeoutError);
|
||||
|
||||
const mockLogger = logger as jest.Mocked<typeof logger>;
|
||||
|
||||
const result = await client.searchStationByName('Shell');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'Station name search timed out',
|
||||
expect.objectContaining({ merchantName: 'Shell', timeoutMs: 5000 })
|
||||
);
|
||||
expect(mockLogger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include rating and photo reference when available', async () => {
|
||||
mockAxios.get.mockResolvedValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
place_id: 'ChIJ_rated',
|
||||
name: 'Chevron',
|
||||
formatted_address: '789 Oak Ave, Portland, OR',
|
||||
geometry: { location: { lat: 45.52, lng: -122.68 } },
|
||||
rating: 4.7,
|
||||
photos: [{ photo_reference: 'chevron-photo' }],
|
||||
opening_hours: { open_now: false },
|
||||
types: ['gas_station'],
|
||||
},
|
||||
],
|
||||
status: 'OK',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.searchStationByName('Chevron');
|
||||
|
||||
expect(result?.rating).toBe(4.7);
|
||||
expect(result?.photoReference).toBe('chevron-photo');
|
||||
expect(result?.isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StationsService.matchStationFromReceipt', () => {
|
||||
let service: StationsService;
|
||||
let mockRepository: jest.Mocked<StationsRepository>;
|
||||
|
||||
const mockSearchByName = googleMapsClient.searchStationByName as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockRepository = {
|
||||
cacheStation: jest.fn().mockResolvedValue(undefined),
|
||||
getCachedStation: jest.fn(),
|
||||
saveStation: jest.fn(),
|
||||
getUserSavedStations: jest.fn().mockResolvedValue([]),
|
||||
updateSavedStation: jest.fn(),
|
||||
deleteSavedStation: jest.fn(),
|
||||
} as unknown as jest.Mocked<StationsRepository>;
|
||||
|
||||
service = new StationsService(mockRepository);
|
||||
});
|
||||
|
||||
it('should return matched station for known merchant name', async () => {
|
||||
const matchedStation = mockStations[0]!;
|
||||
mockSearchByName.mockResolvedValue(matchedStation);
|
||||
|
||||
const result = await service.matchStationFromReceipt('Shell');
|
||||
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.station).not.toBeNull();
|
||||
expect(result.station?.name).toBe('Shell Gas Station - Downtown');
|
||||
expect(mockRepository.cacheStation).toHaveBeenCalledWith(matchedStation);
|
||||
});
|
||||
|
||||
it('should return no match for unknown merchant', async () => {
|
||||
mockSearchByName.mockResolvedValue(null);
|
||||
|
||||
const result = await service.matchStationFromReceipt('Unknown Store');
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.station).toBeNull();
|
||||
expect(mockRepository.cacheStation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty merchant name', async () => {
|
||||
const result = await service.matchStationFromReceipt('');
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.station).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle whitespace-only merchant name', async () => {
|
||||
const result = await service.matchStationFromReceipt(' ');
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.station).toBeNull();
|
||||
});
|
||||
|
||||
it('should cache matched station for future saveStation calls', async () => {
|
||||
const matchedStation = mockStations[1]!;
|
||||
mockSearchByName.mockResolvedValue(matchedStation);
|
||||
|
||||
await service.matchStationFromReceipt('Chevron');
|
||||
|
||||
expect(mockRepository.cacheStation).toHaveBeenCalledWith(matchedStation);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ export class DonationsController {
|
||||
*/
|
||||
async createDonation(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const { amount } = request.body as CreateDonationBody;
|
||||
|
||||
logger.info('Creating donation', { userId, amount });
|
||||
@@ -63,7 +63,7 @@ export class DonationsController {
|
||||
*/
|
||||
async getDonations(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
logger.info('Getting donations', { userId });
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export class SubscriptionsController {
|
||||
*/
|
||||
async getSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
const subscription = await this.service.getSubscription(userId);
|
||||
|
||||
@@ -39,7 +39,7 @@ export class SubscriptionsController {
|
||||
reply.status(200).send(subscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get subscription', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
@@ -54,14 +54,14 @@ export class SubscriptionsController {
|
||||
*/
|
||||
async checkNeedsVehicleSelection(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
const result = await this.service.checkNeedsVehicleSelection(userId);
|
||||
|
||||
reply.status(200).send(result);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to check needs vehicle selection', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
@@ -85,8 +85,8 @@ export class SubscriptionsController {
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const email = (request as any).user.email;
|
||||
const userId = request.userContext!.userId;
|
||||
const email = request.userContext!.email || '';
|
||||
const { tier, billingCycle, paymentMethodId } = request.body;
|
||||
|
||||
// Validate inputs
|
||||
@@ -134,13 +134,14 @@ export class SubscriptionsController {
|
||||
userId,
|
||||
tier,
|
||||
billingCycle,
|
||||
paymentMethodId || ''
|
||||
paymentMethodId || '',
|
||||
email
|
||||
);
|
||||
|
||||
reply.status(200).send(updatedSubscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to create checkout', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
@@ -155,14 +156,14 @@ export class SubscriptionsController {
|
||||
*/
|
||||
async cancelSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
const subscription = await this.service.cancelSubscription(userId);
|
||||
|
||||
reply.status(200).send(subscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to cancel subscription', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
@@ -177,14 +178,14 @@ export class SubscriptionsController {
|
||||
*/
|
||||
async reactivateSubscription(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
const subscription = await this.service.reactivateSubscription(userId);
|
||||
|
||||
reply.status(200).send(subscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to reactivate subscription', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
@@ -206,7 +207,8 @@ export class SubscriptionsController {
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const email = request.userContext!.email || '';
|
||||
const { paymentMethodId } = request.body;
|
||||
|
||||
// Validate input
|
||||
@@ -218,26 +220,15 @@ export class SubscriptionsController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get subscription
|
||||
const subscription = await this.service.getSubscription(userId);
|
||||
if (!subscription) {
|
||||
reply.status(404).send({
|
||||
error: 'Subscription not found',
|
||||
message: 'No subscription exists for this user',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update payment method via Stripe
|
||||
const stripeClient = new StripeClient();
|
||||
await stripeClient.updatePaymentMethod(subscription.stripeCustomerId, paymentMethodId);
|
||||
// Update payment method via service (creates Stripe customer if needed)
|
||||
await this.service.updatePaymentMethod(userId, paymentMethodId, email);
|
||||
|
||||
reply.status(200).send({
|
||||
message: 'Payment method updated successfully',
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to update payment method', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
@@ -252,14 +243,14 @@ export class SubscriptionsController {
|
||||
*/
|
||||
async getInvoices(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
const invoices = await this.service.getInvoices(userId);
|
||||
|
||||
reply.status(200).send(invoices);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to get invoices', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
@@ -282,7 +273,7 @@ export class SubscriptionsController {
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const { targetTier, vehicleIdsToKeep } = request.body;
|
||||
|
||||
// Validate inputs
|
||||
@@ -320,7 +311,7 @@ export class SubscriptionsController {
|
||||
reply.status(200).send(updatedSubscription);
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to downgrade subscription', {
|
||||
userId: (request as any).user?.sub,
|
||||
userId: request.userContext?.userId,
|
||||
error: error.message,
|
||||
});
|
||||
reply.status(500).send({
|
||||
|
||||
@@ -27,7 +27,7 @@ export class SubscriptionsRepository {
|
||||
/**
|
||||
* Create a new subscription
|
||||
*/
|
||||
async create(data: CreateSubscriptionRequest & { stripeCustomerId: string }): Promise<Subscription> {
|
||||
async create(data: CreateSubscriptionRequest & { stripeCustomerId?: string | null }): Promise<Subscription> {
|
||||
const query = `
|
||||
INSERT INTO subscriptions (
|
||||
user_id, stripe_customer_id, tier, billing_cycle
|
||||
@@ -38,7 +38,7 @@ export class SubscriptionsRepository {
|
||||
|
||||
const values = [
|
||||
data.userId,
|
||||
data.stripeCustomerId,
|
||||
data.stripeCustomerId ?? null,
|
||||
data.tier,
|
||||
data.billingCycle,
|
||||
];
|
||||
@@ -146,6 +146,10 @@ export class SubscriptionsRepository {
|
||||
const values = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (data.stripeCustomerId !== undefined) {
|
||||
fields.push(`stripe_customer_id = $${paramCount++}`);
|
||||
values.push(data.stripeCustomerId);
|
||||
}
|
||||
if (data.stripeSubscriptionId !== undefined) {
|
||||
fields.push(`stripe_subscription_id = $${paramCount++}`);
|
||||
values.push(data.stripeSubscriptionId);
|
||||
@@ -575,18 +579,16 @@ export class SubscriptionsRepository {
|
||||
client?: any
|
||||
): Promise<Subscription> {
|
||||
const queryClient = client || this.pool;
|
||||
// Generate a placeholder Stripe customer ID since admin override bypasses Stripe
|
||||
const placeholderCustomerId = `admin_override_${userId}_${Date.now()}`;
|
||||
|
||||
const query = `
|
||||
INSERT INTO subscriptions (
|
||||
user_id, stripe_customer_id, tier, billing_cycle, status
|
||||
)
|
||||
VALUES ($1, $2, $3, 'monthly', 'active')
|
||||
VALUES ($1, NULL, $2, 'monthly', 'active')
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const values = [userId, placeholderCustomerId, tier];
|
||||
const values = [userId, tier];
|
||||
|
||||
try {
|
||||
const result = await queryClient.query(query, values);
|
||||
@@ -619,7 +621,7 @@ export class SubscriptionsRepository {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
stripeCustomerId: row.stripe_customer_id,
|
||||
stripeCustomerId: row.stripe_customer_id ?? null,
|
||||
stripeSubscriptionId: row.stripe_subscription_id || undefined,
|
||||
tier: row.tier,
|
||||
billingCycle: row.billing_cycle || undefined,
|
||||
|
||||
@@ -165,6 +165,45 @@ export class SubscriptionsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or return existing Stripe customer for a subscription.
|
||||
* Admin-set subscriptions have NULL stripeCustomerId. On first Stripe payment,
|
||||
* the customer is created in-place. Includes cleanup of orphaned Stripe customer
|
||||
* if the DB update fails after customer creation.
|
||||
*/
|
||||
private async ensureStripeCustomer(
|
||||
subscription: Subscription,
|
||||
email: string
|
||||
): Promise<string> {
|
||||
if (subscription.stripeCustomerId) {
|
||||
return subscription.stripeCustomerId;
|
||||
}
|
||||
|
||||
const stripeCustomer = await this.stripeClient.createCustomer(email);
|
||||
try {
|
||||
await this.repository.update(subscription.id, { stripeCustomerId: stripeCustomer.id });
|
||||
logger.info('Created Stripe customer for subscription', {
|
||||
subscriptionId: subscription.id,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
});
|
||||
return stripeCustomer.id;
|
||||
} catch (error) {
|
||||
// Attempt cleanup of orphaned Stripe customer
|
||||
try {
|
||||
await this.stripeClient.deleteCustomer(stripeCustomer.id);
|
||||
logger.warn('Rolled back orphaned Stripe customer after DB update failure', {
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
});
|
||||
} catch (cleanupError: any) {
|
||||
logger.error('Failed to cleanup orphaned Stripe customer', {
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
cleanupError: cleanupError.message,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade from current tier to new tier
|
||||
*/
|
||||
@@ -172,7 +211,8 @@ export class SubscriptionsService {
|
||||
userId: string,
|
||||
newTier: 'pro' | 'enterprise',
|
||||
billingCycle: 'monthly' | 'yearly',
|
||||
paymentMethodId: string
|
||||
paymentMethodId: string,
|
||||
email: string
|
||||
): Promise<Subscription> {
|
||||
try {
|
||||
logger.info('Upgrading subscription', { userId, newTier, billingCycle });
|
||||
@@ -183,12 +223,15 @@ export class SubscriptionsService {
|
||||
throw new Error('No subscription found for user');
|
||||
}
|
||||
|
||||
// Ensure Stripe customer exists (creates one for admin-set subscriptions)
|
||||
const stripeCustomerId = await this.ensureStripeCustomer(currentSubscription, email);
|
||||
|
||||
// Determine price ID from environment variables
|
||||
const priceId = this.getPriceId(newTier, billingCycle);
|
||||
|
||||
// Create or update Stripe subscription
|
||||
const stripeSubscription = await this.stripeClient.createSubscription(
|
||||
currentSubscription.stripeCustomerId,
|
||||
stripeCustomerId,
|
||||
priceId,
|
||||
paymentMethodId
|
||||
);
|
||||
@@ -256,6 +299,10 @@ export class SubscriptionsService {
|
||||
throw new Error('No subscription found for user');
|
||||
}
|
||||
|
||||
if (!currentSubscription.stripeCustomerId) {
|
||||
throw new Error('Cannot cancel subscription without active Stripe billing');
|
||||
}
|
||||
|
||||
if (!currentSubscription.stripeSubscriptionId) {
|
||||
throw new Error('No active Stripe subscription to cancel');
|
||||
}
|
||||
@@ -303,6 +350,10 @@ export class SubscriptionsService {
|
||||
throw new Error('No subscription found for user');
|
||||
}
|
||||
|
||||
if (!currentSubscription.stripeCustomerId) {
|
||||
throw new Error('Cannot reactivate subscription without active Stripe billing');
|
||||
}
|
||||
|
||||
if (!currentSubscription.stripeSubscriptionId) {
|
||||
throw new Error('No active Stripe subscription to reactivate');
|
||||
}
|
||||
@@ -519,11 +570,13 @@ export class SubscriptionsService {
|
||||
}
|
||||
|
||||
// Update subscription with Stripe subscription ID
|
||||
// Period dates moved from subscription to items in API 2025-03-31.basil
|
||||
const item = stripeSubscription.items?.data?.[0];
|
||||
await this.repository.update(subscription.id, {
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
status: this.mapStripeStatus(stripeSubscription.status),
|
||||
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
|
||||
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
|
||||
currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000),
|
||||
currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000),
|
||||
});
|
||||
|
||||
// Log event
|
||||
@@ -557,11 +610,13 @@ export class SubscriptionsService {
|
||||
const tier = this.determineTierFromStripeSubscription(stripeSubscription);
|
||||
|
||||
// Update subscription
|
||||
// Period dates moved from subscription to items in API 2025-03-31.basil
|
||||
const item = stripeSubscription.items?.data?.[0];
|
||||
const updateData: UpdateSubscriptionData = {
|
||||
status: this.mapStripeStatus(stripeSubscription.status),
|
||||
tier,
|
||||
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
|
||||
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
|
||||
currentPeriodStart: new Date((item?.current_period_start ?? 0) * 1000),
|
||||
currentPeriodEnd: new Date((item?.current_period_end ?? 0) * 1000),
|
||||
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end || false,
|
||||
};
|
||||
|
||||
@@ -731,7 +786,7 @@ export class SubscriptionsService {
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get user profile for email and name
|
||||
const userProfile = await this.userProfileRepository.getByAuth0Sub(userId);
|
||||
const userProfile = await this.userProfileRepository.getById(userId);
|
||||
if (!userProfile) {
|
||||
logger.warn('User profile not found for tier change notification', { userId });
|
||||
return;
|
||||
@@ -766,17 +821,8 @@ export class SubscriptionsService {
|
||||
* Sync subscription tier to user_profiles table
|
||||
*/
|
||||
private async syncTierToUserProfile(userId: string, tier: SubscriptionTier): Promise<void> {
|
||||
try {
|
||||
await this.userProfileRepository.updateSubscriptionTier(userId, tier);
|
||||
logger.info('Subscription tier synced to user profile', { userId, tier });
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to sync tier to user profile', {
|
||||
userId,
|
||||
tier,
|
||||
error: error.message,
|
||||
});
|
||||
// Don't throw - we don't want to fail the subscription operation if sync fails
|
||||
}
|
||||
await this.userProfileRepository.updateSubscriptionTier(userId, tier);
|
||||
logger.info('Subscription tier synced to user profile', { userId, tier });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -807,6 +853,7 @@ export class SubscriptionsService {
|
||||
switch (stripeStatus) {
|
||||
case 'active':
|
||||
case 'trialing':
|
||||
case 'incomplete':
|
||||
return 'active';
|
||||
case 'past_due':
|
||||
return 'past_due';
|
||||
@@ -889,7 +936,7 @@ export class SubscriptionsService {
|
||||
|
||||
// Sync tier to user_profiles table (within same transaction)
|
||||
await client.query(
|
||||
'UPDATE user_profiles SET subscription_tier = $1 WHERE auth0_sub = $2',
|
||||
'UPDATE user_profiles SET subscription_tier = $1 WHERE id = $2',
|
||||
[newTier, userId]
|
||||
);
|
||||
|
||||
@@ -923,6 +970,19 @@ export class SubscriptionsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update payment method for a user's subscription
|
||||
*/
|
||||
async updatePaymentMethod(userId: string, paymentMethodId: string, email: string): Promise<void> {
|
||||
const subscription = await this.repository.findByUserId(userId);
|
||||
if (!subscription) {
|
||||
throw new Error('No subscription found for user');
|
||||
}
|
||||
|
||||
const stripeCustomerId = await this.ensureStripeCustomer(subscription, email);
|
||||
await this.stripeClient.updatePaymentMethod(stripeCustomerId, paymentMethodId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoices for a user's subscription
|
||||
*/
|
||||
|
||||
@@ -19,7 +19,7 @@ export type DonationStatus = 'pending' | 'succeeded' | 'failed' | 'canceled';
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
userId: string;
|
||||
stripeCustomerId: string;
|
||||
stripeCustomerId: string | null;
|
||||
stripeSubscriptionId?: string;
|
||||
tier: SubscriptionTier;
|
||||
billingCycle?: BillingCycle;
|
||||
@@ -74,7 +74,7 @@ export interface CreateSubscriptionRequest {
|
||||
export interface SubscriptionResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
stripeCustomerId: string;
|
||||
stripeCustomerId: string | null;
|
||||
stripeSubscriptionId?: string;
|
||||
tier: SubscriptionTier;
|
||||
billingCycle?: BillingCycle;
|
||||
@@ -118,6 +118,7 @@ export interface CreateTierVehicleSelectionRequest {
|
||||
|
||||
// Service layer types
|
||||
export interface UpdateSubscriptionData {
|
||||
stripeCustomerId?: string | null;
|
||||
stripeSubscriptionId?: string;
|
||||
tier?: SubscriptionTier;
|
||||
billingCycle?: BillingCycle;
|
||||
|
||||
@@ -75,10 +75,18 @@ export class StripeClient {
|
||||
try {
|
||||
logger.info('Creating Stripe subscription', { customerId, priceId, paymentMethodId });
|
||||
|
||||
// Attach payment method to customer before creating subscription
|
||||
if (paymentMethodId) {
|
||||
await this.stripe.paymentMethods.attach(paymentMethodId, {
|
||||
customer: customerId,
|
||||
});
|
||||
logger.info('Payment method attached to customer', { customerId, paymentMethodId });
|
||||
}
|
||||
|
||||
const subscriptionParams: Stripe.SubscriptionCreateParams = {
|
||||
customer: customerId,
|
||||
items: [{ price: priceId }],
|
||||
payment_behavior: 'default_incomplete',
|
||||
payment_behavior: 'error_if_incomplete',
|
||||
payment_settings: {
|
||||
save_default_payment_method: 'on_subscription',
|
||||
},
|
||||
@@ -93,13 +101,16 @@ export class StripeClient {
|
||||
|
||||
logger.info('Stripe subscription created', { subscriptionId: subscription.id });
|
||||
|
||||
// Period dates moved from subscription to items in API 2025-03-31.basil
|
||||
const item = subscription.items?.data?.[0];
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
customer: subscription.customer as string,
|
||||
status: subscription.status as StripeSubscription['status'],
|
||||
items: subscription.items,
|
||||
currentPeriodStart: (subscription as any).current_period_start || 0,
|
||||
currentPeriodEnd: (subscription as any).current_period_end || 0,
|
||||
currentPeriodStart: item?.current_period_start ?? 0,
|
||||
currentPeriodEnd: item?.current_period_end ?? 0,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
canceledAt: subscription.canceled_at || undefined,
|
||||
created: subscription.created,
|
||||
@@ -140,13 +151,15 @@ export class StripeClient {
|
||||
logger.info('Stripe subscription canceled immediately', { subscriptionId });
|
||||
}
|
||||
|
||||
const item = subscription.items?.data?.[0];
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
customer: subscription.customer as string,
|
||||
status: subscription.status as StripeSubscription['status'],
|
||||
items: subscription.items,
|
||||
currentPeriodStart: (subscription as any).current_period_start || 0,
|
||||
currentPeriodEnd: (subscription as any).current_period_end || 0,
|
||||
currentPeriodStart: item?.current_period_start ?? 0,
|
||||
currentPeriodEnd: item?.current_period_end ?? 0,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
canceledAt: subscription.canceled_at || undefined,
|
||||
created: subscription.created,
|
||||
@@ -260,6 +273,24 @@ export class StripeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Stripe customer (used for cleanup of orphaned customers)
|
||||
*/
|
||||
async deleteCustomer(customerId: string): Promise<void> {
|
||||
try {
|
||||
logger.info('Deleting Stripe customer', { customerId });
|
||||
await this.stripe.customers.del(customerId);
|
||||
logger.info('Stripe customer deleted', { customerId });
|
||||
} catch (error: any) {
|
||||
logger.error('Failed to delete Stripe customer', {
|
||||
customerId,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a subscription by ID
|
||||
*/
|
||||
@@ -268,14 +299,15 @@ export class StripeClient {
|
||||
logger.info('Retrieving Stripe subscription', { subscriptionId });
|
||||
|
||||
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
|
||||
const item = subscription.items?.data?.[0];
|
||||
|
||||
return {
|
||||
id: subscription.id,
|
||||
customer: subscription.customer as string,
|
||||
status: subscription.status as StripeSubscription['status'],
|
||||
items: subscription.items,
|
||||
currentPeriodStart: (subscription as any).current_period_start || 0,
|
||||
currentPeriodEnd: (subscription as any).current_period_end || 0,
|
||||
currentPeriodStart: item?.current_period_start ?? 0,
|
||||
currentPeriodEnd: item?.current_period_end ?? 0,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
canceledAt: subscription.canceled_at || undefined,
|
||||
created: subscription.created,
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function processGracePeriodExpirations(): Promise<GracePeriodResult
|
||||
up.notification_email,
|
||||
up.display_name
|
||||
FROM subscriptions s
|
||||
LEFT JOIN user_profiles up ON s.user_id = up.auth0_sub
|
||||
LEFT JOIN user_profiles up ON s.user_id = up.id
|
||||
WHERE s.status = 'past_due'
|
||||
AND s.grace_period_end < NOW()
|
||||
ORDER BY s.grace_period_end ASC
|
||||
@@ -89,13 +89,13 @@ export async function processGracePeriodExpirations(): Promise<GracePeriodResult
|
||||
|
||||
await client.query(updateQuery, [subscription.id]);
|
||||
|
||||
// Sync tier to user_profiles table (user_id is auth0_sub)
|
||||
// Sync tier to user_profiles table
|
||||
const syncQuery = `
|
||||
UPDATE user_profiles
|
||||
SET
|
||||
subscription_tier = 'free',
|
||||
updated_at = NOW()
|
||||
WHERE auth0_sub = $1
|
||||
WHERE id = $1
|
||||
`;
|
||||
|
||||
await client.query(syncQuery, [subscription.user_id]);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration: Make stripe_customer_id NULLABLE
|
||||
-- Removes the NOT NULL constraint that forced admin_override_ placeholder values.
|
||||
-- Admin-set subscriptions (no Stripe billing) use NULL instead of sentinel strings.
|
||||
-- PostgreSQL UNIQUE constraint allows multiple NULLs (SQL standard).
|
||||
|
||||
-- Drop NOT NULL constraint on stripe_customer_id
|
||||
ALTER TABLE subscriptions ALTER COLUMN stripe_customer_id DROP NOT NULL;
|
||||
|
||||
-- Clean up existing admin_override_ placeholder values to NULL
|
||||
UPDATE subscriptions SET stripe_customer_id = NULL
|
||||
WHERE stripe_customer_id LIKE 'admin_override_%';
|
||||
@@ -15,7 +15,7 @@ export class UserExportController {
|
||||
}
|
||||
|
||||
async downloadExport(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
|
||||
logger.info('User export requested', { userId });
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export class UserImportController {
|
||||
* Uploads and imports user data archive
|
||||
*/
|
||||
async uploadAndImport(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const userId = request.user?.sub;
|
||||
const userId = request.userContext?.userId;
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export class UserImportController {
|
||||
* Generates preview of import data without executing import
|
||||
*/
|
||||
async generatePreview(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const userId = request.user?.sub;
|
||||
const userId = request.userContext?.userId;
|
||||
if (!userId) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export class UserPreferencesController {
|
||||
|
||||
async getPreferences(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
let preferences = await this.repository.findByUserId(userId);
|
||||
|
||||
// Create default preferences if none exist
|
||||
@@ -42,7 +42,7 @@ export class UserPreferencesController {
|
||||
updatedAt: preferences.updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting user preferences', { error, userId: (request as any).user?.sub });
|
||||
logger.error('Error getting user preferences', { error, userId: request.userContext?.userId });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to get preferences',
|
||||
@@ -55,7 +55,7 @@ export class UserPreferencesController {
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const userId = (request as any).user.sub;
|
||||
const userId = request.userContext!.userId;
|
||||
const { unitSystem, currencyCode, timeZone, darkMode } = request.body;
|
||||
|
||||
// Validate unitSystem if provided
|
||||
@@ -115,7 +115,7 @@ export class UserPreferencesController {
|
||||
updatedAt: preferences.updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating user preferences', { error, userId: (request as any).user?.sub });
|
||||
logger.error('Error updating user preferences', { error, userId: request.userContext?.userId });
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to update preferences',
|
||||
|
||||
@@ -18,11 +18,12 @@ import {
|
||||
|
||||
export class UserProfileController {
|
||||
private userProfileService: UserProfileService;
|
||||
private userProfileRepository: UserProfileRepository;
|
||||
|
||||
constructor() {
|
||||
const repository = new UserProfileRepository(pool);
|
||||
this.userProfileRepository = new UserProfileRepository(pool);
|
||||
const adminRepository = new AdminRepository(pool);
|
||||
this.userProfileService = new UserProfileService(repository);
|
||||
this.userProfileService = new UserProfileService(this.userProfileRepository);
|
||||
this.userProfileService.setAdminRepository(adminRepository);
|
||||
}
|
||||
|
||||
@@ -31,27 +32,24 @@ export class UserProfileController {
|
||||
*/
|
||||
async getProfile(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const auth0Sub = request.userContext?.userId;
|
||||
const userId = request.userContext?.userId;
|
||||
|
||||
if (!auth0Sub) {
|
||||
if (!userId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Get user data from Auth0 token
|
||||
const auth0User = {
|
||||
sub: auth0Sub,
|
||||
email: (request as any).user?.email || request.userContext?.email || '',
|
||||
name: (request as any).user?.name,
|
||||
};
|
||||
// Get profile by UUID (auth plugin ensures profile exists during authentication)
|
||||
const profile = await this.userProfileRepository.getById(userId);
|
||||
|
||||
// Get or create profile
|
||||
const profile = await this.userProfileService.getOrCreateProfile(
|
||||
auth0Sub,
|
||||
auth0User
|
||||
);
|
||||
if (!profile) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'User profile not found',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(200).send(profile);
|
||||
} catch (error: any) {
|
||||
@@ -75,9 +73,9 @@ export class UserProfileController {
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const auth0Sub = request.userContext?.userId;
|
||||
const userId = request.userContext?.userId;
|
||||
|
||||
if (!auth0Sub) {
|
||||
if (!userId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
@@ -96,9 +94,9 @@ export class UserProfileController {
|
||||
|
||||
const updates = validation.data;
|
||||
|
||||
// Update profile
|
||||
// Update profile by UUID
|
||||
const profile = await this.userProfileService.updateProfile(
|
||||
auth0Sub,
|
||||
userId,
|
||||
updates
|
||||
);
|
||||
|
||||
@@ -138,9 +136,9 @@ export class UserProfileController {
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const auth0Sub = request.userContext?.userId;
|
||||
const userId = request.userContext?.userId;
|
||||
|
||||
if (!auth0Sub) {
|
||||
if (!userId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
@@ -159,9 +157,9 @@ export class UserProfileController {
|
||||
|
||||
const { confirmationText } = validation.data;
|
||||
|
||||
// Request deletion (user is already authenticated via JWT)
|
||||
// Request deletion by UUID
|
||||
const profile = await this.userProfileService.requestDeletion(
|
||||
auth0Sub,
|
||||
userId,
|
||||
confirmationText
|
||||
);
|
||||
|
||||
@@ -210,17 +208,17 @@ export class UserProfileController {
|
||||
*/
|
||||
async cancelDeletion(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const auth0Sub = request.userContext?.userId;
|
||||
const userId = request.userContext?.userId;
|
||||
|
||||
if (!auth0Sub) {
|
||||
if (!userId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel deletion
|
||||
const profile = await this.userProfileService.cancelDeletion(auth0Sub);
|
||||
// Cancel deletion by UUID
|
||||
const profile = await this.userProfileService.cancelDeletion(userId);
|
||||
|
||||
return reply.code(200).send({
|
||||
message: 'Account deletion canceled successfully',
|
||||
@@ -258,27 +256,24 @@ export class UserProfileController {
|
||||
*/
|
||||
async getDeletionStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const auth0Sub = request.userContext?.userId;
|
||||
const userId = request.userContext?.userId;
|
||||
|
||||
if (!auth0Sub) {
|
||||
if (!userId) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User context missing',
|
||||
});
|
||||
}
|
||||
|
||||
// Get user data from Auth0 token
|
||||
const auth0User = {
|
||||
sub: auth0Sub,
|
||||
email: (request as any).user?.email || request.userContext?.email || '',
|
||||
name: (request as any).user?.name,
|
||||
};
|
||||
// Get profile by UUID (auth plugin ensures profile exists)
|
||||
const profile = await this.userProfileRepository.getById(userId);
|
||||
|
||||
// Get or create profile
|
||||
const profile = await this.userProfileService.getOrCreateProfile(
|
||||
auth0Sub,
|
||||
auth0User
|
||||
);
|
||||
if (!profile) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'User profile not found',
|
||||
});
|
||||
}
|
||||
|
||||
const deletionStatus = this.userProfileService.getDeletionStatus(profile);
|
||||
|
||||
|
||||
@@ -44,6 +44,26 @@ export class UserProfileRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<UserProfile | null> {
|
||||
const query = `
|
||||
SELECT ${USER_PROFILE_COLUMNS}
|
||||
FROM user_profiles
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [id]);
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching user profile by id', { error, id });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getByEmail(email: string): Promise<UserProfile | null> {
|
||||
const query = `
|
||||
SELECT ${USER_PROFILE_COLUMNS}
|
||||
@@ -94,7 +114,7 @@ export class UserProfileRepository {
|
||||
}
|
||||
|
||||
async update(
|
||||
auth0Sub: string,
|
||||
userId: string,
|
||||
updates: { displayName?: string; notificationEmail?: string }
|
||||
): Promise<UserProfile> {
|
||||
const setClauses: string[] = [];
|
||||
@@ -115,12 +135,12 @@ export class UserProfileRepository {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
values.push(auth0Sub);
|
||||
values.push(userId);
|
||||
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET ${setClauses.join(', ')}
|
||||
WHERE auth0_sub = $${paramIndex}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
@@ -133,7 +153,7 @@ export class UserProfileRepository {
|
||||
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error updating user profile', { error, auth0Sub, updates });
|
||||
logger.error('Error updating user profile', { error, userId, updates });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -174,7 +194,7 @@ export class UserProfileRepository {
|
||||
private mapRowToUserWithAdminStatus(row: any): UserWithAdminStatus {
|
||||
return {
|
||||
...this.mapRowToUserProfile(row),
|
||||
isAdmin: !!row.admin_auth0_sub,
|
||||
isAdmin: !!row.admin_id,
|
||||
adminRole: row.admin_role || null,
|
||||
vehicleCount: parseInt(row.vehicle_count, 10) || 0,
|
||||
};
|
||||
@@ -242,14 +262,14 @@ export class UserProfileRepository {
|
||||
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
|
||||
up.subscription_tier, up.email_verified, up.onboarding_completed_at,
|
||||
up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at,
|
||||
au.auth0_sub as admin_auth0_sub,
|
||||
au.id as admin_id,
|
||||
au.role as admin_role,
|
||||
(SELECT COUNT(*) FROM vehicles v
|
||||
WHERE v.user_id = up.auth0_sub
|
||||
WHERE v.user_id = up.id
|
||||
AND v.is_active = true
|
||||
AND v.deleted_at IS NULL) as vehicle_count
|
||||
FROM user_profiles up
|
||||
LEFT JOIN admin_users au ON up.auth0_sub = au.auth0_sub AND au.revoked_at IS NULL
|
||||
LEFT JOIN admin_users au ON au.user_profile_id = up.id AND au.revoked_at IS NULL
|
||||
${whereClause}
|
||||
ORDER BY ${sortColumn} ${sortOrder === 'desc' ? 'DESC' : 'ASC'} NULLS LAST
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
@@ -274,32 +294,32 @@ export class UserProfileRepository {
|
||||
/**
|
||||
* Get single user with admin status
|
||||
*/
|
||||
async getUserWithAdminStatus(auth0Sub: string): Promise<UserWithAdminStatus | null> {
|
||||
async getUserWithAdminStatus(userId: string): Promise<UserWithAdminStatus | null> {
|
||||
const query = `
|
||||
SELECT
|
||||
up.id, up.auth0_sub, up.email, up.display_name, up.notification_email,
|
||||
up.subscription_tier, up.email_verified, up.onboarding_completed_at,
|
||||
up.deactivated_at, up.deactivated_by, up.created_at, up.updated_at,
|
||||
au.auth0_sub as admin_auth0_sub,
|
||||
au.id as admin_id,
|
||||
au.role as admin_role,
|
||||
(SELECT COUNT(*) FROM vehicles v
|
||||
WHERE v.user_id = up.auth0_sub
|
||||
WHERE v.user_id = up.id
|
||||
AND v.is_active = true
|
||||
AND v.deleted_at IS NULL) as vehicle_count
|
||||
FROM user_profiles up
|
||||
LEFT JOIN admin_users au ON up.auth0_sub = au.auth0_sub AND au.revoked_at IS NULL
|
||||
WHERE up.auth0_sub = $1
|
||||
LEFT JOIN admin_users au ON au.user_profile_id = up.id AND au.revoked_at IS NULL
|
||||
WHERE up.id = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
const result = await this.pool.query(query, [userId]);
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.mapRowToUserWithAdminStatus(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching user with admin status', { error, auth0Sub });
|
||||
logger.error('Error fetching user with admin status', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -308,24 +328,24 @@ export class UserProfileRepository {
|
||||
* Update user subscription tier
|
||||
*/
|
||||
async updateSubscriptionTier(
|
||||
auth0Sub: string,
|
||||
userId: string,
|
||||
tier: SubscriptionTier
|
||||
): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET subscription_tier = $1
|
||||
WHERE auth0_sub = $2
|
||||
WHERE id = $2
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [tier, auth0Sub]);
|
||||
const result = await this.pool.query(query, [tier, userId]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error updating subscription tier', { error, auth0Sub, tier });
|
||||
logger.error('Error updating subscription tier', { error, userId, tier });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -333,22 +353,22 @@ export class UserProfileRepository {
|
||||
/**
|
||||
* Deactivate user (soft delete)
|
||||
*/
|
||||
async deactivateUser(auth0Sub: string, deactivatedBy: string): Promise<UserProfile> {
|
||||
async deactivateUser(userId: string, deactivatedBy: string): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET deactivated_at = NOW(), deactivated_by = $1
|
||||
WHERE auth0_sub = $2 AND deactivated_at IS NULL
|
||||
WHERE id = $2 AND deactivated_at IS NULL
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [deactivatedBy, auth0Sub]);
|
||||
const result = await this.pool.query(query, [deactivatedBy, userId]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found or already deactivated');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error deactivating user', { error, auth0Sub, deactivatedBy });
|
||||
logger.error('Error deactivating user', { error, userId, deactivatedBy });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -356,22 +376,22 @@ export class UserProfileRepository {
|
||||
/**
|
||||
* Reactivate user
|
||||
*/
|
||||
async reactivateUser(auth0Sub: string): Promise<UserProfile> {
|
||||
async reactivateUser(userId: string): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET deactivated_at = NULL, deactivated_by = NULL
|
||||
WHERE auth0_sub = $1 AND deactivated_at IS NOT NULL
|
||||
WHERE id = $1 AND deactivated_at IS NOT NULL
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
const result = await this.pool.query(query, [userId]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found or not deactivated');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error reactivating user', { error, auth0Sub });
|
||||
logger.error('Error reactivating user', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -380,7 +400,7 @@ export class UserProfileRepository {
|
||||
* Admin update of user profile (can update email and displayName)
|
||||
*/
|
||||
async adminUpdateProfile(
|
||||
auth0Sub: string,
|
||||
userId: string,
|
||||
updates: { email?: string; displayName?: string }
|
||||
): Promise<UserProfile> {
|
||||
const setClauses: string[] = [];
|
||||
@@ -401,12 +421,12 @@ export class UserProfileRepository {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
values.push(auth0Sub);
|
||||
values.push(userId);
|
||||
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET ${setClauses.join(', ')}, updated_at = NOW()
|
||||
WHERE auth0_sub = $${paramIndex}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
@@ -419,7 +439,7 @@ export class UserProfileRepository {
|
||||
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error admin updating user profile', { error, auth0Sub, updates });
|
||||
logger.error('Error admin updating user profile', { error, userId, updates });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -427,22 +447,22 @@ export class UserProfileRepository {
|
||||
/**
|
||||
* Update email verification status
|
||||
*/
|
||||
async updateEmailVerified(auth0Sub: string, emailVerified: boolean): Promise<UserProfile> {
|
||||
async updateEmailVerified(userId: string, emailVerified: boolean): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET email_verified = $1, updated_at = NOW()
|
||||
WHERE auth0_sub = $2
|
||||
WHERE id = $2
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [emailVerified, auth0Sub]);
|
||||
const result = await this.pool.query(query, [emailVerified, userId]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error updating email verified status', { error, auth0Sub, emailVerified });
|
||||
logger.error('Error updating email verified status', { error, userId, emailVerified });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -450,19 +470,19 @@ export class UserProfileRepository {
|
||||
/**
|
||||
* Mark onboarding as complete
|
||||
*/
|
||||
async markOnboardingComplete(auth0Sub: string): Promise<UserProfile> {
|
||||
async markOnboardingComplete(userId: string): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET onboarding_completed_at = NOW(), updated_at = NOW()
|
||||
WHERE auth0_sub = $1 AND onboarding_completed_at IS NULL
|
||||
WHERE id = $1 AND onboarding_completed_at IS NULL
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
const result = await this.pool.query(query, [userId]);
|
||||
if (result.rows.length === 0) {
|
||||
// Check if already completed or profile not found
|
||||
const existing = await this.getByAuth0Sub(auth0Sub);
|
||||
const existing = await this.getById(userId);
|
||||
if (existing && existing.onboardingCompletedAt) {
|
||||
return existing; // Already completed, return as-is
|
||||
}
|
||||
@@ -470,7 +490,7 @@ export class UserProfileRepository {
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error marking onboarding complete', { error, auth0Sub });
|
||||
logger.error('Error marking onboarding complete', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -478,22 +498,22 @@ export class UserProfileRepository {
|
||||
/**
|
||||
* Update user email (used when fetching correct email from Auth0)
|
||||
*/
|
||||
async updateEmail(auth0Sub: string, email: string): Promise<UserProfile> {
|
||||
async updateEmail(userId: string, email: string): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET email = $1, updated_at = NOW()
|
||||
WHERE auth0_sub = $2
|
||||
WHERE id = $2
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [email, auth0Sub]);
|
||||
const result = await this.pool.query(query, [email, userId]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error updating user email', { error, auth0Sub });
|
||||
logger.error('Error updating user email', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -502,7 +522,7 @@ export class UserProfileRepository {
|
||||
* Request account deletion (sets deletion timestamps and deactivates account)
|
||||
* 30-day grace period before permanent deletion
|
||||
*/
|
||||
async requestDeletion(auth0Sub: string): Promise<UserProfile> {
|
||||
async requestDeletion(userId: string): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET
|
||||
@@ -510,18 +530,18 @@ export class UserProfileRepository {
|
||||
deletion_scheduled_for = NOW() + INTERVAL '30 days',
|
||||
deactivated_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE auth0_sub = $1 AND deletion_requested_at IS NULL
|
||||
WHERE id = $1 AND deletion_requested_at IS NULL
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
const result = await this.pool.query(query, [userId]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found or deletion already requested');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error requesting account deletion', { error, auth0Sub });
|
||||
logger.error('Error requesting account deletion', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -529,7 +549,7 @@ export class UserProfileRepository {
|
||||
/**
|
||||
* Cancel deletion request (clears deletion timestamps and reactivates account)
|
||||
*/
|
||||
async cancelDeletion(auth0Sub: string): Promise<UserProfile> {
|
||||
async cancelDeletion(userId: string): Promise<UserProfile> {
|
||||
const query = `
|
||||
UPDATE user_profiles
|
||||
SET
|
||||
@@ -538,18 +558,18 @@ export class UserProfileRepository {
|
||||
deactivated_at = NULL,
|
||||
deactivated_by = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE auth0_sub = $1 AND deletion_requested_at IS NOT NULL
|
||||
WHERE id = $1 AND deletion_requested_at IS NOT NULL
|
||||
RETURNING ${USER_PROFILE_COLUMNS}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
const result = await this.pool.query(query, [userId]);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User profile not found or no deletion request pending');
|
||||
}
|
||||
return this.mapRowToUserProfile(result.rows[0]);
|
||||
} catch (error) {
|
||||
logger.error('Error canceling account deletion', { error, auth0Sub });
|
||||
logger.error('Error canceling account deletion', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -579,7 +599,7 @@ export class UserProfileRepository {
|
||||
* Hard delete user and all associated data
|
||||
* This is a permanent operation - use with caution
|
||||
*/
|
||||
async hardDeleteUser(auth0Sub: string): Promise<void> {
|
||||
async hardDeleteUser(userId: string): Promise<void> {
|
||||
const client = await this.pool.connect();
|
||||
|
||||
try {
|
||||
@@ -590,51 +610,51 @@ export class UserProfileRepository {
|
||||
`UPDATE community_stations
|
||||
SET submitted_by = 'deleted-user'
|
||||
WHERE submitted_by = $1`,
|
||||
[auth0Sub]
|
||||
[userId]
|
||||
);
|
||||
|
||||
// 2. Delete notification logs
|
||||
await client.query(
|
||||
'DELETE FROM notification_logs WHERE user_id = $1',
|
||||
[auth0Sub]
|
||||
[userId]
|
||||
);
|
||||
|
||||
// 3. Delete user notifications
|
||||
await client.query(
|
||||
'DELETE FROM user_notifications WHERE user_id = $1',
|
||||
[auth0Sub]
|
||||
[userId]
|
||||
);
|
||||
|
||||
// 4. Delete saved stations
|
||||
await client.query(
|
||||
'DELETE FROM saved_stations WHERE user_id = $1',
|
||||
[auth0Sub]
|
||||
[userId]
|
||||
);
|
||||
|
||||
// 5. Delete vehicles (cascades to fuel_logs, maintenance_logs, maintenance_schedules, documents)
|
||||
await client.query(
|
||||
'DELETE FROM vehicles WHERE user_id = $1',
|
||||
[auth0Sub]
|
||||
[userId]
|
||||
);
|
||||
|
||||
// 6. Delete user preferences
|
||||
await client.query(
|
||||
'DELETE FROM user_preferences WHERE user_id = $1',
|
||||
[auth0Sub]
|
||||
[userId]
|
||||
);
|
||||
|
||||
// 7. Delete user profile (final step)
|
||||
await client.query(
|
||||
'DELETE FROM user_profiles WHERE auth0_sub = $1',
|
||||
[auth0Sub]
|
||||
'DELETE FROM user_profiles WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
logger.info('User hard deleted successfully', { auth0Sub });
|
||||
logger.info('User hard deleted successfully', { userId });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('Error hard deleting user', { error, auth0Sub });
|
||||
logger.error('Error hard deleting user', { error, userId });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
@@ -686,7 +706,7 @@ export class UserProfileRepository {
|
||||
* Get vehicles for a user (admin view)
|
||||
* Returns only year, make, model for privacy
|
||||
*/
|
||||
async getUserVehiclesForAdmin(auth0Sub: string): Promise<Array<{ year: number; make: string; model: string }>> {
|
||||
async getUserVehiclesForAdmin(userId: string): Promise<Array<{ year: number; make: string; model: string }>> {
|
||||
const query = `
|
||||
SELECT year, make, model
|
||||
FROM vehicles
|
||||
@@ -697,14 +717,14 @@ export class UserProfileRepository {
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await this.pool.query(query, [auth0Sub]);
|
||||
const result = await this.pool.query(query, [userId]);
|
||||
return result.rows.map(row => ({
|
||||
year: row.year,
|
||||
make: row.make,
|
||||
model: row.model,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Error getting user vehicles for admin', { error, auth0Sub });
|
||||
logger.error('Error getting user vehicles for admin', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user profile by Auth0 sub
|
||||
* Get user profile by Auth0 sub (used during auth flow)
|
||||
*/
|
||||
async getProfile(auth0Sub: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
@@ -72,10 +72,10 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
* Update user profile by UUID
|
||||
*/
|
||||
async updateProfile(
|
||||
auth0Sub: string,
|
||||
userId: string,
|
||||
updates: UpdateProfileRequest
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
@@ -85,17 +85,17 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
// Perform the update
|
||||
const profile = await this.repository.update(auth0Sub, updates);
|
||||
const profile = await this.repository.update(userId, updates);
|
||||
|
||||
logger.info('User profile updated', {
|
||||
auth0Sub,
|
||||
userId,
|
||||
profileId: profile.id,
|
||||
updatedFields: Object.keys(updates),
|
||||
});
|
||||
|
||||
return profile;
|
||||
} catch (error) {
|
||||
logger.error('Error updating user profile', { error, auth0Sub, updates });
|
||||
logger.error('Error updating user profile', { error, userId, updates });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -117,29 +117,29 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user details with admin status (admin-only)
|
||||
* Get user details with admin status by UUID (admin-only)
|
||||
*/
|
||||
async getUserDetails(auth0Sub: string): Promise<UserWithAdminStatus | null> {
|
||||
async getUserDetails(userId: string): Promise<UserWithAdminStatus | null> {
|
||||
try {
|
||||
return await this.repository.getUserWithAdminStatus(auth0Sub);
|
||||
return await this.repository.getUserWithAdminStatus(userId);
|
||||
} catch (error) {
|
||||
logger.error('Error getting user details', { error, auth0Sub });
|
||||
logger.error('Error getting user details', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user subscription tier (admin-only)
|
||||
* Update user subscription tier by UUID (admin-only)
|
||||
* Logs the change to admin audit logs
|
||||
*/
|
||||
async updateSubscriptionTier(
|
||||
auth0Sub: string,
|
||||
userId: string,
|
||||
tier: SubscriptionTier,
|
||||
actorAuth0Sub: string
|
||||
actorUserId: string
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Get current user to log the change
|
||||
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
const currentUser = await this.repository.getById(userId);
|
||||
if (!currentUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
@@ -147,14 +147,14 @@ export class UserProfileService {
|
||||
const previousTier = currentUser.subscriptionTier;
|
||||
|
||||
// Perform the update
|
||||
const updatedProfile = await this.repository.updateSubscriptionTier(auth0Sub, tier);
|
||||
const updatedProfile = await this.repository.updateSubscriptionTier(userId, tier);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorAuth0Sub,
|
||||
actorUserId,
|
||||
'UPDATE_TIER',
|
||||
auth0Sub,
|
||||
userId,
|
||||
'user_profile',
|
||||
updatedProfile.id,
|
||||
{ previousTier, newTier: tier }
|
||||
@@ -162,36 +162,36 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
logger.info('User subscription tier updated', {
|
||||
auth0Sub,
|
||||
userId,
|
||||
previousTier,
|
||||
newTier: tier,
|
||||
actorAuth0Sub,
|
||||
actorUserId,
|
||||
});
|
||||
|
||||
return updatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error updating subscription tier', { error, auth0Sub, tier, actorAuth0Sub });
|
||||
logger.error('Error updating subscription tier', { error, userId, tier, actorUserId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate user account (admin-only soft delete)
|
||||
* Deactivate user account by UUID (admin-only soft delete)
|
||||
* Prevents self-deactivation
|
||||
*/
|
||||
async deactivateUser(
|
||||
auth0Sub: string,
|
||||
actorAuth0Sub: string,
|
||||
userId: string,
|
||||
actorUserId: string,
|
||||
reason?: string
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Prevent self-deactivation
|
||||
if (auth0Sub === actorAuth0Sub) {
|
||||
if (userId === actorUserId) {
|
||||
throw new Error('Cannot deactivate your own account');
|
||||
}
|
||||
|
||||
// Verify user exists and is not already deactivated
|
||||
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
const currentUser = await this.repository.getById(userId);
|
||||
if (!currentUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
@@ -200,14 +200,14 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
// Perform the deactivation
|
||||
const deactivatedProfile = await this.repository.deactivateUser(auth0Sub, actorAuth0Sub);
|
||||
const deactivatedProfile = await this.repository.deactivateUser(userId, actorUserId);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorAuth0Sub,
|
||||
actorUserId,
|
||||
'DEACTIVATE_USER',
|
||||
auth0Sub,
|
||||
userId,
|
||||
'user_profile',
|
||||
deactivatedProfile.id,
|
||||
{ reason: reason || 'No reason provided' }
|
||||
@@ -215,28 +215,28 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
logger.info('User deactivated', {
|
||||
auth0Sub,
|
||||
actorAuth0Sub,
|
||||
userId,
|
||||
actorUserId,
|
||||
reason,
|
||||
});
|
||||
|
||||
return deactivatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error deactivating user', { error, auth0Sub, actorAuth0Sub });
|
||||
logger.error('Error deactivating user', { error, userId, actorUserId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivate a deactivated user account (admin-only)
|
||||
* Reactivate a deactivated user account by UUID (admin-only)
|
||||
*/
|
||||
async reactivateUser(
|
||||
auth0Sub: string,
|
||||
actorAuth0Sub: string
|
||||
userId: string,
|
||||
actorUserId: string
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Verify user exists and is deactivated
|
||||
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
const currentUser = await this.repository.getById(userId);
|
||||
if (!currentUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
@@ -245,14 +245,14 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
// Perform the reactivation
|
||||
const reactivatedProfile = await this.repository.reactivateUser(auth0Sub);
|
||||
const reactivatedProfile = await this.repository.reactivateUser(userId);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorAuth0Sub,
|
||||
actorUserId,
|
||||
'REACTIVATE_USER',
|
||||
auth0Sub,
|
||||
userId,
|
||||
'user_profile',
|
||||
reactivatedProfile.id,
|
||||
{ previouslyDeactivatedBy: currentUser.deactivatedBy }
|
||||
@@ -260,29 +260,29 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
logger.info('User reactivated', {
|
||||
auth0Sub,
|
||||
actorAuth0Sub,
|
||||
userId,
|
||||
actorUserId,
|
||||
});
|
||||
|
||||
return reactivatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error reactivating user', { error, auth0Sub, actorAuth0Sub });
|
||||
logger.error('Error reactivating user', { error, userId, actorUserId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin update of user profile (email, displayName)
|
||||
* Admin update of user profile by UUID (email, displayName)
|
||||
* Logs the change to admin audit logs
|
||||
*/
|
||||
async adminUpdateProfile(
|
||||
auth0Sub: string,
|
||||
userId: string,
|
||||
updates: { email?: string; displayName?: string },
|
||||
actorAuth0Sub: string
|
||||
actorUserId: string
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
// Get current user to log the change
|
||||
const currentUser = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
const currentUser = await this.repository.getById(userId);
|
||||
if (!currentUser) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
@@ -293,14 +293,14 @@ export class UserProfileService {
|
||||
};
|
||||
|
||||
// Perform the update
|
||||
const updatedProfile = await this.repository.adminUpdateProfile(auth0Sub, updates);
|
||||
const updatedProfile = await this.repository.adminUpdateProfile(userId, updates);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorAuth0Sub,
|
||||
actorUserId,
|
||||
'UPDATE_PROFILE',
|
||||
auth0Sub,
|
||||
userId,
|
||||
'user_profile',
|
||||
updatedProfile.id,
|
||||
{
|
||||
@@ -311,14 +311,14 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
logger.info('User profile updated by admin', {
|
||||
auth0Sub,
|
||||
userId,
|
||||
updatedFields: Object.keys(updates),
|
||||
actorAuth0Sub,
|
||||
actorUserId,
|
||||
});
|
||||
|
||||
return updatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error admin updating user profile', { error, auth0Sub, updates, actorAuth0Sub });
|
||||
logger.error('Error admin updating user profile', { error, userId, updates, actorUserId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -328,12 +328,12 @@ export class UserProfileService {
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Request account deletion
|
||||
* Request account deletion by UUID
|
||||
* Sets 30-day grace period before permanent deletion
|
||||
* Note: User is already authenticated via JWT, confirmation text is sufficient
|
||||
*/
|
||||
async requestDeletion(
|
||||
auth0Sub: string,
|
||||
userId: string,
|
||||
confirmationText: string
|
||||
): Promise<UserProfile> {
|
||||
try {
|
||||
@@ -343,7 +343,7 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
// Get user profile
|
||||
const profile = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
const profile = await this.repository.getById(userId);
|
||||
if (!profile) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
@@ -354,14 +354,14 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
// Request deletion
|
||||
const updatedProfile = await this.repository.requestDeletion(auth0Sub);
|
||||
const updatedProfile = await this.repository.requestDeletion(userId);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
auth0Sub,
|
||||
userId,
|
||||
'REQUEST_DELETION',
|
||||
auth0Sub,
|
||||
userId,
|
||||
'user_profile',
|
||||
updatedProfile.id,
|
||||
{
|
||||
@@ -371,42 +371,42 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
logger.info('Account deletion requested', {
|
||||
auth0Sub,
|
||||
userId,
|
||||
deletionScheduledFor: updatedProfile.deletionScheduledFor,
|
||||
});
|
||||
|
||||
return updatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error requesting account deletion', { error, auth0Sub });
|
||||
logger.error('Error requesting account deletion', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending deletion request
|
||||
* Cancel pending deletion request by UUID
|
||||
*/
|
||||
async cancelDeletion(auth0Sub: string): Promise<UserProfile> {
|
||||
async cancelDeletion(userId: string): Promise<UserProfile> {
|
||||
try {
|
||||
// Cancel deletion
|
||||
const updatedProfile = await this.repository.cancelDeletion(auth0Sub);
|
||||
const updatedProfile = await this.repository.cancelDeletion(userId);
|
||||
|
||||
// Log to audit trail
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
auth0Sub,
|
||||
userId,
|
||||
'CANCEL_DELETION',
|
||||
auth0Sub,
|
||||
userId,
|
||||
'user_profile',
|
||||
updatedProfile.id,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Account deletion canceled', { auth0Sub });
|
||||
logger.info('Account deletion canceled', { userId });
|
||||
|
||||
return updatedProfile;
|
||||
} catch (error) {
|
||||
logger.error('Error canceling account deletion', { error, auth0Sub });
|
||||
logger.error('Error canceling account deletion', { error, userId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -438,22 +438,22 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin hard delete user (permanent deletion)
|
||||
* Admin hard delete user by UUID (permanent deletion)
|
||||
* Prevents self-delete
|
||||
*/
|
||||
async adminHardDeleteUser(
|
||||
auth0Sub: string,
|
||||
actorAuth0Sub: string,
|
||||
userId: string,
|
||||
actorUserId: string,
|
||||
reason?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Prevent self-delete
|
||||
if (auth0Sub === actorAuth0Sub) {
|
||||
if (userId === actorUserId) {
|
||||
throw new Error('Cannot delete your own account');
|
||||
}
|
||||
|
||||
// Get user profile before deletion for audit log
|
||||
const profile = await this.repository.getByAuth0Sub(auth0Sub);
|
||||
const profile = await this.repository.getById(userId);
|
||||
if (!profile) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
@@ -461,9 +461,9 @@ export class UserProfileService {
|
||||
// Log to audit trail before deletion
|
||||
if (this.adminRepository) {
|
||||
await this.adminRepository.logAuditAction(
|
||||
actorAuth0Sub,
|
||||
actorUserId,
|
||||
'HARD_DELETE_USER',
|
||||
auth0Sub,
|
||||
userId,
|
||||
'user_profile',
|
||||
profile.id,
|
||||
{
|
||||
@@ -475,18 +475,20 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
// Hard delete from database
|
||||
await this.repository.hardDeleteUser(auth0Sub);
|
||||
await this.repository.hardDeleteUser(userId);
|
||||
|
||||
// Delete from Auth0
|
||||
await auth0ManagementClient.deleteUser(auth0Sub);
|
||||
// Delete from Auth0 (using auth0Sub for Auth0 API)
|
||||
if (profile.auth0Sub) {
|
||||
await auth0ManagementClient.deleteUser(profile.auth0Sub);
|
||||
}
|
||||
|
||||
logger.info('User hard deleted by admin', {
|
||||
auth0Sub,
|
||||
actorAuth0Sub,
|
||||
userId,
|
||||
actorUserId,
|
||||
reason,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error hard deleting user', { error, auth0Sub, actorAuth0Sub });
|
||||
logger.error('Error hard deleting user', { error, userId, actorUserId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user