forked from Yara724/api
Compare commits
182 Commits
7e1ae328ac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f2e20b32eb | |||
|
|
c94dd27a96 | ||
| 6e1405c309 | |||
| f053ee1348 | |||
| 182db56e15 | |||
| 9b348d567d | |||
|
|
e4c3b7a16a | ||
| 23fca04705 | |||
|
|
4b9d946bfd | ||
| c85503e598 | |||
|
|
9828aee8af | ||
| 778544c321 | |||
|
|
3afff67336 | ||
| 793ff639ba | |||
|
|
ec15cff557 | ||
| fd42adf9d6 | |||
|
|
4272790fad | ||
| ac65cdb77b | |||
| 8430c68e3a | |||
| 49fe548215 | |||
|
|
777eae1028 | ||
| b67dd733cd | |||
|
|
4818f73252 | ||
| cc8a5354c7 | |||
|
|
2d5ade33d2 | ||
| b73c92c21f | |||
| f9f462d47b | |||
| c3eb36dc41 | |||
|
|
f75ecf5c2c | ||
| 75c4cb6a05 | |||
|
|
7a4277f8b2 | ||
| e50bc78344 | |||
|
|
2c1cd93dd0 | ||
| ffd44df718 | |||
|
|
64865b70f2 | ||
| c207c30be9 | |||
|
|
fcb169e9ac | ||
| 1867292499 | |||
|
|
2cc96f6132 | ||
| 4d5b91d4fe | |||
| 8ba97537a4 | |||
| 70160543a2 | |||
|
|
8460c86820 | ||
| 61f4181065 | |||
|
|
4058cb4a61 | ||
| b2ad43d050 | |||
|
|
9fc4ad9931 | ||
| 213cdd765b | |||
|
|
06d69aa4d0 | ||
| 318a5c74dd | |||
|
|
7241ae3270 | ||
| 1f4a520145 | |||
|
|
59a1b9064e | ||
| 0420eda35f | |||
|
|
482d2b01f1 | ||
|
|
04d7966776 | ||
| b129c1ef9b | |||
|
|
a1fca82cb2 | ||
|
|
5b114c2069 | ||
| 5e4897f609 | |||
| a79c3ca05f | |||
| 36fa1c552e | |||
| 9742fecc11 | |||
| 8007c30efd | |||
| 144f8f3e2a | |||
| 8674dd8762 | |||
|
|
f39c50aeb0 | ||
| 2fda740171 | |||
| 72e5bd616c | |||
| 4b41a60f64 | |||
| 9310285bd4 | |||
|
|
2a8b66bc16 | ||
| 9168a6bdcd | |||
|
|
057bedeb0c | ||
| 808a3b8526 | |||
|
|
da7a4f8890 | ||
| 21e55012be | |||
|
|
385757c3a0 | ||
| a1b122a33b | |||
|
|
aec9e76918 | ||
| 7c59c2407e | |||
|
|
168e52a475 | ||
|
|
4aa6e03afb | ||
|
|
36a34e27b3 | ||
|
|
6387ebaed0 | ||
| 22a5990934 | |||
|
|
e5de99adde | ||
| c7fd2a6b33 | |||
|
|
5595083e86 | ||
| 2296fa5d86 | |||
|
|
72dec7a917 | ||
| 67019851de | |||
|
|
c955deda5c | ||
|
|
9b83db882b | ||
|
|
bced6a0ec7 | ||
| 5d1110b6e9 | |||
|
|
a7fe04c032 | ||
|
|
0dcb2cf2ca | ||
| 8b125af4e7 | |||
| 1559a40213 | |||
|
|
54ae82aa38 | ||
|
|
7a3ddcc7be | ||
|
|
da3f57870e | ||
| ac7ee941b8 | |||
| 5d005d5eee | |||
| b65d9bfe81 | |||
|
|
04f51167c2 | ||
|
|
2c8fd3960f | ||
|
|
e59058520c | ||
| 80122e7772 | |||
| f409d78ede | |||
|
|
24340eb810 | ||
| 41d1de77eb | |||
| 0aadc64cd3 | |||
|
|
1e6c36cbd4 | ||
| ae23a10d33 | |||
| 1c6678d8e4 | |||
|
|
ad5ff28be4 | ||
| 1fe2c77c70 | |||
| 45a51f2c24 | |||
|
|
0514548136 | ||
| 5b4cc0560f | |||
| e9c02811f7 | |||
| 8ab49c9a35 | |||
| f0cd4461a8 | |||
| 3205a89611 | |||
| 14bc075521 | |||
| 1fe66b2d91 | |||
| 033a853b51 | |||
| f05891a112 | |||
| 7641c56440 | |||
| ae83100ef4 | |||
| 9e2cf9bcdf | |||
| ced08fc1f7 | |||
| d525b8dd0d | |||
| b43f1a86dc | |||
|
|
5df39b502e | ||
| 49564cc1c6 | |||
|
|
29939eee20 | ||
| ae789323d8 | |||
|
|
6be9ff16e1 | ||
|
|
0c5a2fe38b | ||
| 5ef8310cb0 | |||
| d2cb9444f3 | |||
| 67471fb9ce | |||
| 569e7592ec | |||
|
|
e2a9232523 | ||
|
|
dc006735ba | ||
|
|
f92cee0575 | ||
|
|
493be68b80 | ||
|
|
edf027acd3 | ||
| 0698338d4c | |||
|
|
f3c7f6a7e0 | ||
| 607472cd89 | |||
|
|
65e7476642 | ||
| 9068765c25 | |||
| 99c819caeb | |||
| 8d396762a2 | |||
| f3686575ca | |||
|
|
b9fe1bfd52 | ||
| 6eca1f5dad | |||
|
|
6477778835 | ||
|
|
4552450fbc | ||
|
|
f7f7f4548d | ||
|
|
220b39ea5a | ||
| 4a045f564c | |||
|
|
d3249e9854 | ||
| 7e597db423 | |||
| 8303bf4467 | |||
| ebe8671bd3 | |||
| 5022178569 | |||
| 2fbf40ef19 | |||
| dca9e1f8d4 | |||
|
|
8f8ed8a94e | ||
|
|
3c7a656cd7 | ||
| 87ece0d89d | |||
| 7af3f3627a | |||
| 094d9048ba | |||
| 9afb6a8a6e | |||
|
|
523172da67 | ||
|
|
2fc6015213 | ||
|
|
cca3ed01a4 |
22
.env.example
22
.env.example
@@ -5,6 +5,10 @@ NODE_ENV =
|
||||
PORT =
|
||||
CLIENT_ID =
|
||||
CLIENT_NAME =
|
||||
FANAVARAN_CLIENT=parsian
|
||||
INSURANCE_CORP_ID='شرکت بيمه پارسيان(بيمه گر)'
|
||||
CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=false
|
||||
CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN=53000000
|
||||
# ---------------------------------------------
|
||||
# 🌐 Application URLs
|
||||
# ---------------------------------------------
|
||||
@@ -36,6 +40,7 @@ MONGO_URI = 'mongodb://${MONGO_USER}:${MONGO_PASS}@${MONGO_HOST}:${MONGO_PORT}/$
|
||||
# ---------------------------------------------
|
||||
JWT_SECRET =
|
||||
JWT_EXPIRY =
|
||||
CAPTCHA_ENABLED = true
|
||||
|
||||
# ---------------------------------------------
|
||||
# 🧩 SanHub Microservice
|
||||
@@ -62,6 +67,23 @@ AUTH_SMS_TEMPLATE =
|
||||
EXP_OTP_TIME =
|
||||
FAKE_OTP =
|
||||
|
||||
# ---------------------------------------------
|
||||
# 🌐 Proxy Configuration (Local Development Only)
|
||||
# ---------------------------------------------
|
||||
# NOTE: These proxy settings are for local development only.
|
||||
# When deploying to the server, comment out or remove these lines
|
||||
# as the server IP is already whitelisted by Fanavaran.
|
||||
# SOCKS_PROXY_HOST = localhost
|
||||
# SOCKS_PROXY_PORT = 6565
|
||||
|
||||
# ---------------------------------------------
|
||||
# 🏢 Fanavaran Insurance Corp
|
||||
# ---------------------------------------------
|
||||
# Caption from Fanavaran insurance-corp lookup used to resolve InsuranceCorpId
|
||||
# for damage-case payloads. Must match a Caption in the insurance-corp code-list.
|
||||
# Example: "شرکت بيمه تجارت نو"
|
||||
INSURANCE_CORP_ID =
|
||||
|
||||
# ---------------------------------------------
|
||||
# ⚙️ Application Features / Flags
|
||||
# ---------------------------------------------
|
||||
|
||||
142
.woodpecker.yml
Normal file
142
.woodpecker.yml
Normal file
@@ -0,0 +1,142 @@
|
||||
# yara724/api — development deployment (Deploy-Develop)
|
||||
# Manual tasks: see ci-cd/TASK.md
|
||||
# Requires: repo marked Trusted in Woodpecker (host volume mounts)
|
||||
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
- event: manual
|
||||
|
||||
skip_clone: true
|
||||
|
||||
variables:
|
||||
- &host_workspace /data/1-deploy/gitea/yara724/api
|
||||
- &workspace_volume /data/1-deploy/gitea/yara724/api:/workspace
|
||||
- &host_ssh /home/talieh/.ssh:/root/.ssh:ro
|
||||
- &pipeline_env
|
||||
WORKSPACE: *host_workspace
|
||||
PROJECT_NAME: Yara724 API
|
||||
ENVIRONMENT: Development
|
||||
APPLICATION_URL: https://y724-user.ittalie.ir/api
|
||||
GIT_COMMIT_URL: https://git.ittalie.com/Yara724/api/commit/
|
||||
GIT_BRANCH: main
|
||||
COMPOSE_FILE: docker-compose.yml
|
||||
SUCCESS_COLOR: "#36a64f"
|
||||
FAILURE_COLOR: "#dc3545"
|
||||
|
||||
steps:
|
||||
pull:
|
||||
image: docker.arvancloud.ir/alpine/git:latest
|
||||
environment:
|
||||
<<: *pipeline_env
|
||||
GIT_SSH_COMMAND: ssh -o UserKnownHostsFile=/tmp/known_hosts -o StrictHostKeyChecking=yes
|
||||
volumes:
|
||||
- *workspace_volume
|
||||
- *host_ssh
|
||||
commands:
|
||||
- git config --global --add safe.directory /workspace
|
||||
- mkdir -p /tmp && ssh-keyscan -H git.ittalie.com >> /tmp/known_hosts
|
||||
- cd /workspace
|
||||
- git pull origin main
|
||||
- date +%s > /workspace/.wp-deploy-start
|
||||
|
||||
deploy:
|
||||
image: docker.arvancloud.ir/docker:24-cli
|
||||
environment:
|
||||
<<: *pipeline_env
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- *workspace_volume
|
||||
commands:
|
||||
- cd /workspace
|
||||
- docker compose -f docker-compose.yml up -d --build
|
||||
|
||||
notify-success:
|
||||
image: docker.arvancloud.ir/alpine:3.20
|
||||
environment:
|
||||
<<: *pipeline_env
|
||||
ROCKETCHAT_WEBHOOK:
|
||||
from_secret: rocketchat_webhook
|
||||
volumes:
|
||||
- *workspace_volume
|
||||
when:
|
||||
- status: success
|
||||
commands:
|
||||
- apk add --no-cache curl jq git > /dev/null
|
||||
- |
|
||||
set -euo pipefail
|
||||
git config --global --add safe.directory /workspace
|
||||
cd /workspace
|
||||
|
||||
COMMIT_HASH="$(git rev-parse --short HEAD)"
|
||||
DEPLOY_START="$(cat /workspace/.wp-deploy-start)"
|
||||
DEPLOY_END="$(date +%s)"
|
||||
DEPLOY_DURATION="$((DEPLOY_END - DEPLOY_START))"
|
||||
|
||||
COMMIT_1="$(git log -1 --pretty=format:'%s')"
|
||||
COMMIT_2="$(git log -2 --pretty=format:'%s' | tail -n1)"
|
||||
COMMIT_3="$(git log -3 --pretty=format:'%s' | tail -n1)"
|
||||
|
||||
TITLE="✅ $PROJECT_NAME - $ENVIRONMENT ✅"
|
||||
TEXT="🌐 **URL**: $APPLICATION_URL
|
||||
|
||||
🔖 **Commit Hash**: [$COMMIT_HASH]($GIT_COMMIT_URL$COMMIT_HASH)
|
||||
|
||||
📝 **Recent Changes**:
|
||||
|
||||
• $COMMIT_1
|
||||
|
||||
• $COMMIT_2
|
||||
|
||||
• $COMMIT_3
|
||||
|
||||
⏱️ **Deployment Duration**: $${DEPLOY_DURATION}s"
|
||||
|
||||
payload="$(jq -n \
|
||||
--arg title "$TITLE" \
|
||||
--arg text "$TEXT" \
|
||||
--arg color "$SUCCESS_COLOR" \
|
||||
'{text: $title, attachments: [{text: $text, color: $color}]}')"
|
||||
|
||||
curl -fsS -H "Content-Type: application/json" -d "$payload" "$ROCKETCHAT_WEBHOOK" >/dev/null
|
||||
rm -f /workspace/.wp-deploy-start
|
||||
|
||||
notify-failure:
|
||||
image: docker.arvancloud.ir/alpine:3.20
|
||||
environment:
|
||||
<<: *pipeline_env
|
||||
ROCKETCHAT_WEBHOOK:
|
||||
from_secret: rocketchat_webhook
|
||||
volumes:
|
||||
- *workspace_volume
|
||||
when:
|
||||
- status: failure
|
||||
commands:
|
||||
- apk add --no-cache curl jq git > /dev/null
|
||||
- |
|
||||
set -euo pipefail
|
||||
git config --global --add safe.directory /workspace
|
||||
cd /workspace
|
||||
|
||||
COMMIT_HASH="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||
TITLE="💥 $PROJECT_NAME - $ENVIRONMENT 💥"
|
||||
TEXT="━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
🌐 **Application URL**
|
||||
|
||||
$APPLICATION_URL
|
||||
|
||||
🔖 **Commit**
|
||||
|
||||
\`$COMMIT_HASH\`
|
||||
|
||||
⚠️ **Pipeline failed** — check Woodpecker for the failing step.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
payload="$(jq -n \
|
||||
--arg title "$TITLE" \
|
||||
--arg text "$TEXT" \
|
||||
--arg color "$FAILURE_COLOR" \
|
||||
'{text: $title, attachments: [{text: $text, color: $color}]}')"
|
||||
|
||||
curl -fsS -H "Content-Type: application/json" -d "$payload" "$ROCKETCHAT_WEBHOOK" >/dev/null || true
|
||||
rm -f /workspace/.wp-deploy-start
|
||||
BIN
assets/Vazirmatn-Bold.ttf
Normal file
BIN
assets/Vazirmatn-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/Vazirmatn-Regular.ttf
Normal file
BIN
assets/Vazirmatn-Regular.ttf
Normal file
Binary file not shown.
742
docs/external-api-curls.md
Normal file
742
docs/external-api-curls.md
Normal file
@@ -0,0 +1,742 @@
|
||||
# External API Curl Guide
|
||||
|
||||
This file documents outbound HTTP calls made by the app or maintenance scripts.
|
||||
Client credentials that are hardcoded in the codebase are filled in below. Keep
|
||||
placeholders only for runtime data such as national codes, plate values, tokens
|
||||
returned by login calls, and payload files.
|
||||
|
||||
## Common Notes
|
||||
|
||||
- Internal app URLs in Swagger, localhost examples, and file download URLs are not listed.
|
||||
- Package/build-time network calls such as npm registry, Sonar, and Docker setup are not runtime app requests.
|
||||
- `firstValueFrom(this.httpService...)`, `lastValueFrom(this.httpService...)`, and `fetch(...)` call sites were checked.
|
||||
- Several integrations cache bearer tokens in memory for about 55 minutes.
|
||||
- Some Fanavaran credentials and the Map.ir API key are hardcoded in source. Treat them as sensitive and consider moving/rotating them.
|
||||
|
||||
## Fanavaran API Manager
|
||||
|
||||
Host:
|
||||
|
||||
```sh
|
||||
FANAVARAN_BASE_URL="https://apimanager.iraneit.com/BimeApiManager/api"
|
||||
FANAVARAN_BIME_URL="$FANAVARAN_BASE_URL/BimeApi/v2.0"
|
||||
```
|
||||
|
||||
Used by:
|
||||
|
||||
- `src/fanavaran/fanavaran-lookup.service.ts`
|
||||
- `src/fanavaran/fanavaran-lookup.config.ts`
|
||||
- `src/claim-request-management/claim-request-management.service.ts`
|
||||
|
||||
### Sequence
|
||||
|
||||
1. Get `appToken`.
|
||||
2. Login with `appToken` to get `authenticationToken`.
|
||||
3. Call lookup, policy inquiry, or submit endpoint with `authenticationToken`, `CorpId`, `ContractId`, and `Location`.
|
||||
|
||||
### Auth Script
|
||||
|
||||
Use this when you only need fresh Fanavaran tokens and do not want to copy the
|
||||
curl commands manually:
|
||||
|
||||
```sh
|
||||
scripts/fanavaran-auth.sh tejaratno
|
||||
scripts/fanavaran-auth.sh parsian
|
||||
```
|
||||
|
||||
The script stores raw responses and reusable variables under
|
||||
`files/fanavaran-auth/<client>/`. For example:
|
||||
|
||||
```sh
|
||||
source files/fanavaran-auth/tejaratno/tokens.env
|
||||
curl -X GET "$FANAVARAN_BIME_URL/car/base-info/accident-causes" \
|
||||
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
|
||||
-H "CorpId: $CORP_ID" \
|
||||
-H "ContractId: $CONTRACT_ID" \
|
||||
-H "Location: $LOCATION" \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
### Tenant Credentials
|
||||
|
||||
The app supports these Fanavaran client profiles:
|
||||
|
||||
| Client | appname | secret | userName | password | CorpId | ContractId | Location |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| tejaratno | `fanhab` | `5Fa@N#A2B` | `fanhabUser` | `Fan#@2U$3er` | `3539` | `263` | `100` |
|
||||
| parsian | `ParsianService` | `P@r30@n$erv!ce` | `ParsianServiceUser` | `P@r30@n123` | `543` | `28` | `210050` |
|
||||
|
||||
Set the client variables before running. These values come from
|
||||
`src/core/config/fanavaran-client.config.ts` and match
|
||||
`src/claim-request-management/claim-request-management.service.ts`.
|
||||
|
||||
Tejaratno:
|
||||
|
||||
```sh
|
||||
FANAVARAN_CLIENT="tejaratno"
|
||||
APP_NAME='fanhab'
|
||||
APP_SECRET='5Fa@N#A2B'
|
||||
FANAVARAN_USERNAME='fanhabUser'
|
||||
FANAVARAN_PASSWORD='Fan#@2U$3er'
|
||||
CORP_ID='3539'
|
||||
CONTRACT_ID='263'
|
||||
LOCATION='100'
|
||||
```
|
||||
|
||||
Parsian:
|
||||
|
||||
```sh
|
||||
FANAVARAN_CLIENT="parsian"
|
||||
APP_NAME='ParsianService'
|
||||
APP_SECRET='P@r30@n$erv!ce'
|
||||
FANAVARAN_USERNAME='ParsianServiceUser'
|
||||
FANAVARAN_PASSWORD='P@r30@n123'
|
||||
CORP_ID='543'
|
||||
CONTRACT_ID='28'
|
||||
LOCATION='210050'
|
||||
```
|
||||
|
||||
### 1. Get App Token
|
||||
|
||||
The app sends an empty string body, deletes `Content-Type`, and keeps
|
||||
`Content-Length: 0`.
|
||||
|
||||
```sh
|
||||
curl -i -X POST "$FANAVARAN_BASE_URL/EITAuthentication/GetAppToken" \
|
||||
-H "appname: $APP_NAME" \
|
||||
-H "secret: $APP_SECRET" \
|
||||
-H "Content-Length: 0"
|
||||
```
|
||||
|
||||
The token is returned in a response header named `appToken` or `apptoken`.
|
||||
|
||||
```sh
|
||||
curl -sS -D /tmp/fanavaran-app-token.headers -o /tmp/fanavaran-app-token.body \
|
||||
-X POST "$FANAVARAN_BASE_URL/EITAuthentication/GetAppToken" \
|
||||
-H "appname: $APP_NAME" \
|
||||
-H "secret: $APP_SECRET" \
|
||||
-H "Content-Length: 0"
|
||||
APP_TOKEN="$(awk -F': ' 'tolower($1)=="apptoken" {gsub(/\r/,"",$2); print $2}' /tmp/fanavaran-app-token.headers)"
|
||||
printf 'APP_TOKEN=%s\n' "$APP_TOKEN"
|
||||
```
|
||||
|
||||
### 2. Login
|
||||
|
||||
Use the `APP_TOKEN` returned by the immediately previous GetAppToken request.
|
||||
Do not reuse an old app token pasted from logs or another machine; the app does
|
||||
not do that.
|
||||
|
||||
```sh
|
||||
curl -i -X POST "$FANAVARAN_BASE_URL/EITAuthentication/Login" \
|
||||
-H "appToken: $APP_TOKEN" \
|
||||
-H "userName: $FANAVARAN_USERNAME" \
|
||||
-H "password: $FANAVARAN_PASSWORD" \
|
||||
-H "Content-Length: 0"
|
||||
```
|
||||
|
||||
The token is returned in a response header or body field named `authenticationToken`, `authenticationtoken`, or `authentication_token`.
|
||||
|
||||
```sh
|
||||
curl -sS -D /tmp/fanavaran-login.headers -o /tmp/fanavaran-login.body \
|
||||
-X POST "$FANAVARAN_BASE_URL/EITAuthentication/Login" \
|
||||
-H "appToken: $APP_TOKEN" \
|
||||
-H "userName: $FANAVARAN_USERNAME" \
|
||||
-H "password: $FANAVARAN_PASSWORD" \
|
||||
-H "Content-Length: 0"
|
||||
AUTHENTICATION_TOKEN="$(awk -F': ' 'tolower($1)=="authenticationtoken" {gsub(/\r/,"",$2); print $2}' /tmp/fanavaran-login.headers)"
|
||||
if [ -z "$AUTHENTICATION_TOKEN" ]; then
|
||||
AUTHENTICATION_TOKEN="$(node -e 'const fs=require("fs"); const body=fs.readFileSync("/tmp/fanavaran-login.body","utf8"); try { const json=JSON.parse(body); console.log(json.authenticationToken || json.authenticationtoken || json.authentication_token || ""); } catch { console.log(""); }')"
|
||||
fi
|
||||
printf 'AUTHENTICATION_TOKEN=%s\n' "$AUTHENTICATION_TOKEN"
|
||||
```
|
||||
|
||||
If login returns `نام کاربر یا رمز عبور صحیح نیست` for `tejaratno`, first check
|
||||
that `APP_TOKEN` was generated with `appname: fanhab` and `secret: 5Fa@N#A2B` in
|
||||
the same sequence. The runtime code calls `GetAppToken` first, then passes that
|
||||
fresh header value to `Login`; it does not use a static value such as
|
||||
`182197f6-7b41-47b4-9b18-5bfcbc027f2e`.
|
||||
|
||||
### Ready-To-Run Auth Sequences
|
||||
|
||||
Tejaratno:
|
||||
|
||||
```sh
|
||||
FANAVARAN_BASE_URL="https://apimanager.iraneit.com/BimeApiManager/api"
|
||||
FANAVARAN_BIME_URL="$FANAVARAN_BASE_URL/BimeApi/v2.0"
|
||||
FANAVARAN_CLIENT="tejaratno"
|
||||
APP_NAME='fanhab'
|
||||
APP_SECRET='5Fa@N#A2B'
|
||||
FANAVARAN_USERNAME='fanhabUser'
|
||||
FANAVARAN_PASSWORD='Fan#@2U$3er'
|
||||
CORP_ID='3539'
|
||||
CONTRACT_ID='263'
|
||||
LOCATION='100'
|
||||
|
||||
curl -sS -D /tmp/fanavaran-app-token.headers -o /tmp/fanavaran-app-token.body \
|
||||
-X POST "$FANAVARAN_BASE_URL/EITAuthentication/GetAppToken" \
|
||||
-H "appname: $APP_NAME" \
|
||||
-H "secret: $APP_SECRET" \
|
||||
-H "Content-Length: 0"
|
||||
APP_TOKEN="$(awk -F': ' 'tolower($1)=="apptoken" {gsub(/\r/,"",$2); print $2}' /tmp/fanavaran-app-token.headers)"
|
||||
|
||||
curl -sS -D /tmp/fanavaran-login.headers -o /tmp/fanavaran-login.body \
|
||||
-X POST "$FANAVARAN_BASE_URL/EITAuthentication/Login" \
|
||||
-H "appToken: $APP_TOKEN" \
|
||||
-H "userName: $FANAVARAN_USERNAME" \
|
||||
-H "password: $FANAVARAN_PASSWORD" \
|
||||
-H "Content-Length: 0"
|
||||
AUTHENTICATION_TOKEN="$(awk -F': ' 'tolower($1)=="authenticationtoken" {gsub(/\r/,"",$2); print $2}' /tmp/fanavaran-login.headers)"
|
||||
if [ -z "$AUTHENTICATION_TOKEN" ]; then
|
||||
AUTHENTICATION_TOKEN="$(node -e 'const fs=require("fs"); const body=fs.readFileSync("/tmp/fanavaran-login.body","utf8"); try { const json=JSON.parse(body); console.log(json.authenticationToken || json.authenticationtoken || json.authentication_token || ""); } catch { console.log(""); }')"
|
||||
fi
|
||||
|
||||
printf 'APP_TOKEN=%s\nAUTHENTICATION_TOKEN=%s\n' "$APP_TOKEN" "$AUTHENTICATION_TOKEN"
|
||||
```
|
||||
|
||||
Parsian:
|
||||
|
||||
```sh
|
||||
FANAVARAN_BASE_URL="https://apimanager.iraneit.com/BimeApiManager/api"
|
||||
FANAVARAN_BIME_URL="$FANAVARAN_BASE_URL/BimeApi/v2.0"
|
||||
FANAVARAN_CLIENT="parsian"
|
||||
APP_NAME='ParsianService'
|
||||
APP_SECRET='P@r30@n$erv!ce'
|
||||
FANAVARAN_USERNAME='ParsianServiceUser'
|
||||
FANAVARAN_PASSWORD='P@r30@n123'
|
||||
CORP_ID='543'
|
||||
CONTRACT_ID='28'
|
||||
LOCATION='210050'
|
||||
|
||||
curl -sS -D /tmp/fanavaran-app-token.headers -o /tmp/fanavaran-app-token.body \
|
||||
-X POST "$FANAVARAN_BASE_URL/EITAuthentication/GetAppToken" \
|
||||
-H "appname: $APP_NAME" \
|
||||
-H "secret: $APP_SECRET" \
|
||||
-H "Content-Length: 0"
|
||||
APP_TOKEN="$(awk -F': ' 'tolower($1)=="apptoken" {gsub(/\r/,"",$2); print $2}' /tmp/fanavaran-app-token.headers)"
|
||||
|
||||
curl -sS -D /tmp/fanavaran-login.headers -o /tmp/fanavaran-login.body \
|
||||
-X POST "$FANAVARAN_BASE_URL/EITAuthentication/Login" \
|
||||
-H "appToken: $APP_TOKEN" \
|
||||
-H "userName: $FANAVARAN_USERNAME" \
|
||||
-H "password: $FANAVARAN_PASSWORD" \
|
||||
-H "Content-Length: 0"
|
||||
AUTHENTICATION_TOKEN="$(awk -F': ' 'tolower($1)=="authenticationtoken" {gsub(/\r/,"",$2); print $2}' /tmp/fanavaran-login.headers)"
|
||||
if [ -z "$AUTHENTICATION_TOKEN" ]; then
|
||||
AUTHENTICATION_TOKEN="$(node -e 'const fs=require("fs"); const body=fs.readFileSync("/tmp/fanavaran-login.body","utf8"); try { const json=JSON.parse(body); console.log(json.authenticationToken || json.authenticationtoken || json.authentication_token || ""); } catch { console.log(""); }')"
|
||||
fi
|
||||
|
||||
printf 'APP_TOKEN=%s\nAUTHENTICATION_TOKEN=%s\n' "$APP_TOKEN" "$AUTHENTICATION_TOKEN"
|
||||
```
|
||||
|
||||
### 3A. Lookup Endpoints
|
||||
|
||||
These are fetched on demand and cached under `files/fanavaran-lookups/<client>/`.
|
||||
|
||||
```sh
|
||||
curl -X GET "$FANAVARAN_BIME_URL/car/base-info/accident-causes" \
|
||||
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
|
||||
-H "CorpId: $CORP_ID" \
|
||||
-H "ContractId: $CONTRACT_ID" \
|
||||
-H "Location: $LOCATION" \
|
||||
-H "Content-Type: application/json"
|
||||
|
||||
curl -X GET "$FANAVARAN_BIME_URL/car/code-list/accident-report-type" \
|
||||
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
|
||||
-H "CorpId: $CORP_ID" \
|
||||
-H "ContractId: $CONTRACT_ID" \
|
||||
-H "Location: $LOCATION" \
|
||||
-H "Content-Type: application/json"
|
||||
|
||||
curl -X GET "$FANAVARAN_BIME_URL/car/base-info/vehicle-use-types" \
|
||||
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
|
||||
-H "CorpId: $CORP_ID" \
|
||||
-H "ContractId: $CONTRACT_ID" \
|
||||
-H "Location: $LOCATION" \
|
||||
-H "Content-Type: application/json"
|
||||
|
||||
curl -X GET "$FANAVARAN_BIME_URL/car/code-list/dmg-pay-method" \
|
||||
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
|
||||
-H "CorpId: $CORP_ID" \
|
||||
-H "ContractId: $CONTRACT_ID" \
|
||||
-H "Location: $LOCATION" \
|
||||
-H "Content-Type: application/json"
|
||||
|
||||
curl -X GET "$FANAVARAN_BIME_URL/car/base-info/driving-licence-types" \
|
||||
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
|
||||
-H "CorpId: $CORP_ID" \
|
||||
-H "ContractId: $CONTRACT_ID" \
|
||||
-H "Location: $LOCATION" \
|
||||
-H "Content-Type: application/json"
|
||||
|
||||
curl -X GET "$FANAVARAN_BIME_URL/car/code-list/accident-culprit-type" \
|
||||
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
|
||||
-H "CorpId: $CORP_ID" \
|
||||
-H "ContractId: $CONTRACT_ID" \
|
||||
-H "Location: $LOCATION" \
|
||||
-H "Content-Type: application/json"
|
||||
|
||||
curl -X GET "$FANAVARAN_BIME_URL/car/base-info/vehicle-kinds" \
|
||||
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
|
||||
-H "CorpId: $CORP_ID" \
|
||||
-H "ContractId: $CONTRACT_ID" \
|
||||
-H "Location: $LOCATION" \
|
||||
-H "Content-Type: application/json"
|
||||
|
||||
curl -X GET "$FANAVARAN_BIME_URL/car/vehicles/inquiry-by-vin?vin=IRNKAEK4150012345" \
|
||||
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
|
||||
-H "CorpId: $CORP_ID" \
|
||||
-H "ContractId: $CONTRACT_ID" \
|
||||
-H "Location: $LOCATION" \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
### 3B. Policy Inquiry By National Code
|
||||
|
||||
Used before Fanavaran claim submit to resolve a `PolicyId` when possible.
|
||||
|
||||
```sh
|
||||
NATIONAL_CODE="<insurer national code>"
|
||||
|
||||
curl -X GET "$FANAVARAN_BIME_URL/common/Policies/inquiry-my-policies?InsuranceLineId=5&NationalCode=$NATIONAL_CODE" \
|
||||
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
|
||||
-H "CorpId: $CORP_ID" \
|
||||
-H "ContractId: $CONTRACT_ID" \
|
||||
-H "Location: $LOCATION" \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
### 3C. Third-Party Car Financial Claim Submit
|
||||
|
||||
`fanavaranData` is built internally from a claim case/request. The shape is large; capture an app log or preview output and save it as JSON before replaying.
|
||||
|
||||
```sh
|
||||
curl -X POST "$FANAVARAN_BIME_URL/car/third-party-car-financial-claims" \
|
||||
-H "authenticationToken: $AUTHENTICATION_TOKEN" \
|
||||
-H "CorpId: $CORP_ID" \
|
||||
-H "ContractId: $CONTRACT_ID" \
|
||||
-H "Location: $LOCATION" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data @fanavaran-claim-submit.json
|
||||
```
|
||||
|
||||
## Tejarat Inquiry Provider
|
||||
|
||||
Default base URL:
|
||||
|
||||
```sh
|
||||
TEJARAT_INQUIRY_BASE_URL="${TEJARAT_INQUIRY_BASE_URL:-http://82.99.202.245:3027}"
|
||||
TEJARAT_INQUIRY_EMAIL='xxx@example.com'
|
||||
TEJARAT_INQUIRY_PASSWORD='123321'
|
||||
```
|
||||
|
||||
Used by `src/sand-hub/sand-hub.service.ts` for third-party plate and car-body plate inquiries when ESG is not selected.
|
||||
|
||||
### Sequence
|
||||
|
||||
1. Login to `/user/login`.
|
||||
2. Use returned `accessToken` as `Authorization: Bearer ...`.
|
||||
3. Call inquiry endpoint.
|
||||
|
||||
### Login
|
||||
|
||||
```sh
|
||||
curl -X POST "$TEJARAT_INQUIRY_BASE_URL/user/login" \
|
||||
-H "Accept: */*" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{
|
||||
"email": "'"$TEJARAT_INQUIRY_EMAIL"'",
|
||||
"password": "'"$TEJARAT_INQUIRY_PASSWORD"'"
|
||||
}'
|
||||
```
|
||||
|
||||
```sh
|
||||
TEJARAT_ACCESS_TOKEN="<response accessToken>"
|
||||
```
|
||||
|
||||
### Third-Party Plate / Policy Block Inquiry
|
||||
|
||||
```sh
|
||||
curl -X POST "$TEJARAT_INQUIRY_BASE_URL/block-inquiry-tejarat" \
|
||||
-H "Authorization: Bearer $TEJARAT_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
--data '{
|
||||
"leftTwoDigits": "12",
|
||||
"serialLetter": "ب",
|
||||
"threeDigits": "345",
|
||||
"rightTwoDigits": "67",
|
||||
"nationalCode": "0012345678"
|
||||
}'
|
||||
```
|
||||
|
||||
### Car-Body Plate Inquiry
|
||||
|
||||
```sh
|
||||
curl -X POST "$TEJARAT_INQUIRY_BASE_URL/block-inquiry-tejarat/badane" \
|
||||
-H "Authorization: Bearer $TEJARAT_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
--data '{
|
||||
"part1": 12,
|
||||
"part2": "ب",
|
||||
"part3": 345,
|
||||
"part4": 67,
|
||||
"nationalCode": "0012345678"
|
||||
}'
|
||||
```
|
||||
|
||||
## ESG Inquiry Provider
|
||||
|
||||
Default/fallback base URL in some call sites:
|
||||
|
||||
```sh
|
||||
ESG_URL="${ESG_URL:-http://192.168.20.22:8085}"
|
||||
```
|
||||
|
||||
Used by `src/sand-hub/sand-hub.service.ts` for selected tenants, for example when `CLIENT_ID=8`.
|
||||
|
||||
### Sequence
|
||||
|
||||
1. Login to `/auth/login`.
|
||||
2. Use returned `accessToken` as `Authorization: Bearer ...`.
|
||||
3. Call the inquiry endpoint.
|
||||
|
||||
### Login
|
||||
|
||||
```sh
|
||||
curl -X POST "$ESG_URL/auth/login" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{
|
||||
"username": "'"$ESG_USERNAME"'",
|
||||
"password": "'"$ESG_PASSWORD"'"
|
||||
}'
|
||||
```
|
||||
|
||||
```sh
|
||||
ESG_ACCESS_TOKEN="<response accessToken>"
|
||||
```
|
||||
|
||||
### Policy By Plate
|
||||
|
||||
```sh
|
||||
curl -X POST "$ESG_URL/inquiry/policyByPlate" \
|
||||
-H "Authorization: Bearer $ESG_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
--data '{
|
||||
"nationalCode": "0012345678",
|
||||
"plk1": "12",
|
||||
"plk2": "ب",
|
||||
"plk3": "345",
|
||||
"plksrl": "67"
|
||||
}'
|
||||
```
|
||||
|
||||
### Person Inquiry
|
||||
|
||||
ESG expects Jalali birth date, normalized as `YYYY-MM-DD`.
|
||||
|
||||
```sh
|
||||
curl -X POST "$ESG_URL/inquiry/person" \
|
||||
-H "Authorization: Bearer $ESG_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
--data '{
|
||||
"nationalCode": "0012345678",
|
||||
"birthDate": "1378-11-24",
|
||||
"dateHasPostfix": 0
|
||||
}'
|
||||
```
|
||||
|
||||
### Sheba Validation
|
||||
|
||||
```sh
|
||||
curl -X POST "$ESG_URL/inquiry/sheba" \
|
||||
-H "Authorization: Bearer $ESG_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
--data '{
|
||||
"accountOwnerType": "1",
|
||||
"nationalCode": "0012345678",
|
||||
"legalId": "",
|
||||
"sheba": "IR000000000000000000000000"
|
||||
}'
|
||||
```
|
||||
|
||||
## SandHub Provider
|
||||
|
||||
Used by `src/sand-hub/sand-hub.service.ts` for legacy inquiry flows.
|
||||
|
||||
Environment:
|
||||
|
||||
```sh
|
||||
SANHUB_BASE_URL='http://82.99.202.245:3027'
|
||||
SANHUB_URL_LOGIN='http://82.99.202.245:3027/user/login'
|
||||
SANHUB_USERNAME='default@admin.com'
|
||||
SANHUB_PASSWORD='123321'
|
||||
```
|
||||
|
||||
### Sequence
|
||||
|
||||
1. Login through `SANHUB_URL_LOGIN`.
|
||||
2. Use returned `accessToken` as `Authorization: Bearer ...`.
|
||||
3. Call the required endpoint under `SANHUB_BASE_URL`.
|
||||
|
||||
### Login
|
||||
|
||||
```sh
|
||||
curl -X POST "$SANHUB_URL_LOGIN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{
|
||||
"email": "'"$SANHUB_USERNAME"'",
|
||||
"password": "'"$SANHUB_PASSWORD"'"
|
||||
}'
|
||||
```
|
||||
|
||||
```sh
|
||||
SANHUB_ACCESS_TOKEN="<response accessToken>"
|
||||
```
|
||||
|
||||
### Third-Party Plate / Policy Block Inquiry
|
||||
|
||||
```sh
|
||||
curl -X POST "$SANHUB_BASE_URL/block-inquiry-tejarat" \
|
||||
-H "Authorization: Bearer $SANHUB_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
--data '{
|
||||
"leftTwoDigits": "12",
|
||||
"serialLetter": "ب",
|
||||
"threeDigits": "345",
|
||||
"rightTwoDigits": "67",
|
||||
"nationalCode": "0012345678"
|
||||
}'
|
||||
```
|
||||
|
||||
### Personal Inquiry
|
||||
|
||||
The app converts Jalali birth dates to Gregorian before sending to SandHub.
|
||||
|
||||
```sh
|
||||
curl -X POST "$SANHUB_BASE_URL/personal-inquiry/tejarat-no" \
|
||||
-H "Authorization: Bearer $SANHUB_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
--data '{
|
||||
"nationalCode": "0012345678",
|
||||
"birthdate": "1999-02-13"
|
||||
}'
|
||||
```
|
||||
|
||||
### Driving License Check
|
||||
|
||||
```sh
|
||||
curl -X POST "$SANHUB_BASE_URL/driver-license-check" \
|
||||
-H "Authorization: Bearer $SANHUB_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
--data '{
|
||||
"driverLicenseNumber": "1234567890",
|
||||
"nationalCode": "0012345678"
|
||||
}'
|
||||
```
|
||||
|
||||
### Car Ownership
|
||||
|
||||
```sh
|
||||
curl -X POST "$SANHUB_BASE_URL/ownership" \
|
||||
-H "Authorization: Bearer $SANHUB_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
--data '{
|
||||
"Plk1": "12",
|
||||
"Plk2": "ب",
|
||||
"Plk3": "345",
|
||||
"plkSrl": "67",
|
||||
"nationalCode": "0012345678"
|
||||
}'
|
||||
```
|
||||
|
||||
### Sheba Validation
|
||||
|
||||
```sh
|
||||
curl -X POST "$SANHUB_BASE_URL/sheba/sheba-tejaratno" \
|
||||
-H "Authorization: Bearer $SANHUB_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
--data '{
|
||||
"AccountOwnerType": "1",
|
||||
"NationalId": "0012345678",
|
||||
"ShebaId": "IR000000000000000000000000"
|
||||
}'
|
||||
```
|
||||
|
||||
## Map.ir Reverse Geocoding
|
||||
|
||||
Used by `src/claim-request-management/claim-request-management.service.ts`.
|
||||
|
||||
```sh
|
||||
MAP_IR_API_KEY='eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2In0.eyJhdWQiOiIyMTcxOCIsImp0aSI6ImI5ZDZjMThkNDRjZjc2OWI2Yjk1ODcyMGFjYmEzMmRiN2NhZjg0Zjk4OTRlMjZiZDg0Yzg3YjVlMzhlMTAyZDlkMWYxOGM5NjNmOTk4YjY2IiwiaWF0IjoxNjgwNjA4NTkxLCJuYmYiOjE2ODA2MDg1OTEsImV4cCI6MTY4MzIwMDU5MSwic3ViIjoiIiwic2NvcGVzIjpbImJhc2ljIl19.rTviLd8b5yTHUDa3ODZyva593eMnL0d3XPg3sKkZxMOf_jNIH6lFQyIfbId-wsd1EAdsOdsL3CME_Y8t332PWJbxMNgnEq4Rf2IkClkvkSx6Sb5_4bmlhBM75zw2SmccvgbFUn4xkTOw0FT4vABC2Y3-MKctjMpmO8QOrVULSKt4psrmQhr7hBu7YRDnAAEc6muZ1VpRvdB1kqNKddoSIrfDaq6aDRJ-BNbGRAaFFvP_kH4cgSCKV4dU0TknL3mRKUiVy6_TDkjtzAN8fE2wsdvNo2pGTJPzKFsR2ipgGNTvB__g3bOnVpKsgFXPBH0e_Qa7ff1tZ3VGWy3jRNh9Lg'
|
||||
LAT="35.6892"
|
||||
LON="51.3890"
|
||||
|
||||
curl -X GET "https://map.ir/fast-reverse?lat=$LAT&lon=$LON" \
|
||||
-H "accept: application/json" \
|
||||
-H "x-api-key: $MAP_IR_API_KEY"
|
||||
```
|
||||
|
||||
## SMS Providers
|
||||
|
||||
### Kavenegar
|
||||
|
||||
Used by `src/sms-orchestration/provider/kavenegar.service.ts`.
|
||||
|
||||
```sh
|
||||
SMS_API_KEY='75776C717969412B4B52306A5956462F4A714E6F6C65544D6A2B654B7566786E'
|
||||
KAVENEGAR_BASE_URL="https://api.kavenegar.com/v1/$SMS_API_KEY"
|
||||
```
|
||||
|
||||
Send SMS:
|
||||
|
||||
```sh
|
||||
curl -X POST -G "$KAVENEGAR_BASE_URL/sms/send.json" \
|
||||
--data-urlencode "receptor=09120000000" \
|
||||
--data-urlencode "message=Hello" \
|
||||
--data-urlencode "sender=<optional sender>"
|
||||
```
|
||||
|
||||
Verify lookup:
|
||||
|
||||
```sh
|
||||
curl -G "$KAVENEGAR_BASE_URL/verify/lookup.json" \
|
||||
--data-urlencode "receptor=09120000000" \
|
||||
--data-urlencode "token=123456" \
|
||||
--data-urlencode "template=<template>" \
|
||||
--data-urlencode "token2=<optional token2>" \
|
||||
--data-urlencode "token3=<optional token3>"
|
||||
```
|
||||
|
||||
### Parsian SMS Gateway
|
||||
|
||||
Used by `src/sms-orchestration/provider/parsian-sms.gateway.ts`.
|
||||
|
||||
`PARSIAN_SMS_URL` is expected to already include the provider URL prefix and query key before receptor. The app appends `=<receptor>&Message=<encoded message>`.
|
||||
|
||||
```sh
|
||||
PARSIAN_SMS_URL='https://apigateway.parsianinsurance.com/api/SendSMS?ReceiverNumbers'
|
||||
PARSIAN_API_KEY='be988c9c-dbd6-494e-9c04-944ecc6426bf'
|
||||
PARSIAN_BASIC_TOKEN='UGFyc2lhbkFQSTpQYXJzaWFuQHBpMjI='
|
||||
RECEPTOR="09120000000"
|
||||
MESSAGE="Hello"
|
||||
|
||||
curl -X GET "$PARSIAN_SMS_URL=$RECEPTOR&Message=$(printf %s "$MESSAGE" | jq -sRr @uri)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-PACKAGE-API-KEY: $PARSIAN_API_KEY" \
|
||||
-H "Authorization: Basic $PARSIAN_BASIC_TOKEN"
|
||||
```
|
||||
|
||||
## Car Price Provider
|
||||
|
||||
Used by `src/expert-claim/expert-claim.service.ts` to fetch car prices from `CW_URL`.
|
||||
|
||||
```sh
|
||||
CW_URL="<base URL ending with slash if required by provider>"
|
||||
|
||||
curl -X GET "${CW_URL}price?akharin"
|
||||
curl -X GET "${CW_URL}price?hamrah"
|
||||
```
|
||||
|
||||
## AI Service Calls Currently Disabled
|
||||
|
||||
`src/ai/ai.service.ts` contains configured URLs but the actual axios calls are commented out. If re-enabled, the sequence is:
|
||||
|
||||
1. `POST $AI_URL_V2/auth/login`
|
||||
2. `GET $AI_URL_V2/auth/profile`
|
||||
3. `POST $AI_URL_V2/services/car-damage/detector?version=ai-v7`
|
||||
|
||||
```sh
|
||||
AI_URL_V2='https://ai-gw.ittalie.ir'
|
||||
AI_USERNAME='yara@gmail.io'
|
||||
AI_PASSWORD='123321'
|
||||
|
||||
curl -X POST "$AI_URL_V2/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{
|
||||
"username": "'"$AI_USERNAME"'",
|
||||
"password": "'"$AI_PASSWORD"'"
|
||||
}'
|
||||
|
||||
AI_ACCESS_TOKEN="<response accessToken>"
|
||||
|
||||
curl -X GET "$AI_URL_V2/auth/profile" \
|
||||
-H "Authorization: Bearer $AI_ACCESS_TOKEN"
|
||||
|
||||
GATEWAY_API_KEY="<profile apiKey.key>"
|
||||
|
||||
curl -X POST "$AI_URL_V2/services/car-damage/detector?version=ai-v7" \
|
||||
-H "Authorization: Bearer $AI_ACCESS_TOKEN" \
|
||||
-H "gateway-api-key: $GATEWAY_API_KEY" \
|
||||
-F "images=@/path/to/car-image.jpg"
|
||||
```
|
||||
|
||||
## Refresh Blame Inquiries Script
|
||||
|
||||
Used by `scripts/refresh-blame-inquiries.js`. This script does not login; it expects pre-provided bearer tokens:
|
||||
|
||||
- `TEJARAT_THIRD_PARTY_TOKEN` or `TEJARAT_TOKEN`
|
||||
- `TEJARAT_CAR_BODY_TOKEN` or `TEJARAT_TOKEN`
|
||||
- `TEJARAT_PERSON_TOKEN` or `TEJARAT_TOKEN`
|
||||
|
||||
Default URLs:
|
||||
|
||||
```sh
|
||||
TEJARAT_THIRD_PARTY_URL="${TEJARAT_THIRD_PARTY_URL:-http://82.99.202.245:3027/block-inquiry-tejarat}"
|
||||
TEJARAT_CAR_BODY_URL="${TEJARAT_CAR_BODY_URL:-http://82.99.202.245:3027/block-inquiry-tejarat/badane}"
|
||||
TEJARAT_PERSON_URL="${TEJARAT_PERSON_URL:-http://82.99.202.245:3027/personal-inquiry/tejarat-no}"
|
||||
```
|
||||
|
||||
Third-party inquiry:
|
||||
|
||||
```sh
|
||||
curl -X POST "$TEJARAT_THIRD_PARTY_URL" \
|
||||
-H "authorization: Bearer $TEJARAT_THIRD_PARTY_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-H "accept: application/json" \
|
||||
--data '{
|
||||
"leftTwoDigits": "12",
|
||||
"serialLetter": "ب",
|
||||
"threeDigits": "345",
|
||||
"rightTwoDigits": "67",
|
||||
"nationalCode": "0012345678"
|
||||
}'
|
||||
```
|
||||
|
||||
Car-body inquiry:
|
||||
|
||||
```sh
|
||||
curl -X POST "$TEJARAT_CAR_BODY_URL" \
|
||||
-H "authorization: Bearer $TEJARAT_CAR_BODY_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-H "accept: application/json" \
|
||||
--data '{
|
||||
"part1": 12,
|
||||
"part2": "ب",
|
||||
"part3": 345,
|
||||
"part4": 67,
|
||||
"nationalCode": "0012345678"
|
||||
}'
|
||||
```
|
||||
|
||||
Personal inquiry:
|
||||
|
||||
```sh
|
||||
curl -X POST "$TEJARAT_PERSON_URL" \
|
||||
-H "authorization: Bearer $TEJARAT_PERSON_TOKEN" \
|
||||
-H "content-type: application/json" \
|
||||
-H "accept: application/json" \
|
||||
--data '{
|
||||
"nationalCode": "0012345678",
|
||||
"birthdate": "1999-02-13"
|
||||
}'
|
||||
```
|
||||
@@ -3,6 +3,13 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{
|
||||
"include": "../assets/fonts/**/*",
|
||||
"outDir": "dist",
|
||||
"watchAssets": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
188
package-lock.json
generated
188
package-lock.json
generated
@@ -23,8 +23,10 @@
|
||||
"class-validator": "^0.15.1",
|
||||
"express": "^5.2.1",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"form-data": "^4.0.6",
|
||||
"joi": "^18.2.1",
|
||||
"mongoose": "^8.9.2",
|
||||
"pdfkit": "^0.19.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"svg-captcha": "^1.4.0"
|
||||
@@ -2107,9 +2109,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2127,9 +2126,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2147,9 +2143,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2167,9 +2160,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2187,9 +2177,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2957,11 +2944,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
@@ -3253,9 +3251,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3273,9 +3268,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3293,9 +3285,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3313,9 +3302,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 AND MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3417,6 +3403,15 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/types": {
|
||||
"version": "0.1.26",
|
||||
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
|
||||
@@ -5144,7 +5139,6 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5400,6 +5394,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserify-zlib": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "~1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
@@ -6238,6 +6250,12 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
@@ -6842,7 +6860,6 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
@@ -7102,6 +7119,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit/node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fork-ts-checker-webpack-plugin": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz",
|
||||
@@ -8734,6 +8777,12 @@
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/js-md5": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -8939,6 +8988,25 @@
|
||||
"integrity": "sha512-xMkdAMqcyG7iN2WZZmGIfWbYxW4orRkny+0/AXIbwL0xll2zkDX0Vzo/BXFa6+7mh2UvJl9MbcTtHk0YXkFtBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
@@ -9821,6 +9889,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -9959,6 +10033,20 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
|
||||
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^1.0.0",
|
||||
"@noble/hashes": "^1.6.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"js-md5": "^0.8.3",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
@@ -10085,6 +10173,14 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
|
||||
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
|
||||
"dependencies": {
|
||||
"browserify-zlib": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -10438,6 +10534,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
@@ -11859,6 +11961,32 @@
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie/node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
|
||||
|
||||
@@ -39,10 +39,13 @@
|
||||
"class-validator": "^0.15.1",
|
||||
"express": "^5.2.1",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"form-data": "^4.0.6",
|
||||
"joi": "^18.2.1",
|
||||
"mongoose": "^8.9.2",
|
||||
"pdfkit": "^0.19.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socks-proxy-agent": "^8.0.4",
|
||||
"svg-captcha": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
143
scripts/fanavaran-auth.sh
Executable file
143
scripts/fanavaran-auth.sh
Executable file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
scripts/fanavaran-auth.sh <tejaratno|parsian>
|
||||
|
||||
Calls Fanavaran GetAppToken, then Login with the returned appToken.
|
||||
Outputs the appToken and authenticationToken, and writes raw responses to:
|
||||
files/fanavaran-auth/<client>/
|
||||
|
||||
Optional:
|
||||
FANAVARAN_BASE_URL can override the default API Manager base URL.
|
||||
USAGE
|
||||
}
|
||||
|
||||
client="${1:-}"
|
||||
if [[ -z "$client" || "$client" == "-h" || "$client" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$client" in
|
||||
tejaratno)
|
||||
app_name='fanhab'
|
||||
app_secret='5Fa@N#A2B'
|
||||
fanavaran_username='fanhabUser'
|
||||
fanavaran_password='Fan#@2U$3er'
|
||||
corp_id='3539'
|
||||
contract_id='263'
|
||||
location='100'
|
||||
;;
|
||||
parsian)
|
||||
app_name='ParsianService'
|
||||
app_secret='P@r30@n$erv!ce'
|
||||
fanavaran_username='ParsianServiceUser'
|
||||
fanavaran_password='P@r30@n123'
|
||||
corp_id='543'
|
||||
contract_id='28'
|
||||
location='210050'
|
||||
;;
|
||||
*)
|
||||
printf 'Unknown Fanavaran client: %s\n\n' "$client" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
base_url="${FANAVARAN_BASE_URL:-https://apimanager.iraneit.com/BimeApiManager/api}"
|
||||
auth_dir="files/fanavaran-auth/$client"
|
||||
mkdir -p "$auth_dir"
|
||||
|
||||
app_token_headers="$auth_dir/get-app-token.headers"
|
||||
app_token_body="$auth_dir/get-app-token.body.json"
|
||||
login_headers="$auth_dir/login.headers"
|
||||
login_body="$auth_dir/login.body.json"
|
||||
tokens_file="$auth_dir/tokens.env"
|
||||
|
||||
extract_header() {
|
||||
local header_name="$1"
|
||||
local header_file="$2"
|
||||
awk -F': ' -v wanted="$header_name" '
|
||||
tolower($1) == tolower(wanted) {
|
||||
gsub(/\r/, "", $2)
|
||||
print $2
|
||||
exit
|
||||
}
|
||||
' "$header_file"
|
||||
}
|
||||
|
||||
extract_authentication_token_from_body() {
|
||||
local body_file="$1"
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const path = process.argv[1];
|
||||
const body = fs.existsSync(path) ? fs.readFileSync(path, "utf8") : "";
|
||||
try {
|
||||
const json = JSON.parse(body || "{}");
|
||||
console.log(json.authenticationToken || json.authenticationtoken || json.authentication_token || "");
|
||||
} catch {
|
||||
console.log("");
|
||||
}
|
||||
' "$body_file"
|
||||
}
|
||||
|
||||
printf 'Fanavaran client: %s\n' "$client"
|
||||
printf 'Base URL: %s\n\n' "$base_url"
|
||||
|
||||
printf '1. Calling GetAppToken...\n'
|
||||
curl -sS -D "$app_token_headers" -o "$app_token_body" \
|
||||
-X POST "$base_url/EITAuthentication/GetAppToken" \
|
||||
-H "appname: $app_name" \
|
||||
-H "secret: $app_secret" \
|
||||
-H "Content-Length: 0"
|
||||
|
||||
app_token="$(extract_header "apptoken" "$app_token_headers")"
|
||||
if [[ -z "$app_token" ]]; then
|
||||
printf 'Failed to extract appToken from %s\n' "$app_token_headers" >&2
|
||||
printf 'Response body is saved at %s\n' "$app_token_body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '2. Calling Login...\n'
|
||||
curl -sS -D "$login_headers" -o "$login_body" \
|
||||
-X POST "$base_url/EITAuthentication/Login" \
|
||||
-H "appToken: $app_token" \
|
||||
-H "userName: $fanavaran_username" \
|
||||
-H "password: $fanavaran_password" \
|
||||
-H "Content-Length: 0"
|
||||
|
||||
authentication_token="$(extract_header "authenticationtoken" "$login_headers")"
|
||||
if [[ -z "$authentication_token" ]]; then
|
||||
authentication_token="$(extract_authentication_token_from_body "$login_body")"
|
||||
fi
|
||||
|
||||
if [[ -z "$authentication_token" ]]; then
|
||||
printf 'Failed to extract authenticationToken from login response.\n' >&2
|
||||
printf 'Headers: %s\n' "$login_headers" >&2
|
||||
printf 'Body: %s\n' "$login_body" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$tokens_file" <<TOKENS
|
||||
FANAVARAN_CLIENT='$client'
|
||||
FANAVARAN_BASE_URL='$base_url'
|
||||
FANAVARAN_BIME_URL='$base_url/BimeApi/v2.0'
|
||||
APP_TOKEN='$app_token'
|
||||
AUTHENTICATION_TOKEN='$authentication_token'
|
||||
CORP_ID='$corp_id'
|
||||
CONTRACT_ID='$contract_id'
|
||||
LOCATION='$location'
|
||||
TOKENS
|
||||
|
||||
printf '\nDone.\n'
|
||||
printf 'APP_TOKEN=%s\n' "$app_token"
|
||||
printf 'AUTHENTICATION_TOKEN=%s\n' "$authentication_token"
|
||||
printf 'CORP_ID=%s\n' "$corp_id"
|
||||
printf 'CONTRACT_ID=%s\n' "$contract_id"
|
||||
printf 'LOCATION=%s\n' "$location"
|
||||
printf '\nSaved token variables: %s\n' "$tokens_file"
|
||||
printf 'Saved raw GetAppToken response: %s, %s\n' "$app_token_headers" "$app_token_body"
|
||||
printf 'Saved raw Login response: %s, %s\n' "$login_headers" "$login_body"
|
||||
86
scripts/verify-plate-normalization.mjs
Normal file
86
scripts/verify-plate-normalization.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Quick manual verification for plate normalization.
|
||||
*
|
||||
* Usage: node scripts/verify-plate-normalization.mjs
|
||||
*
|
||||
* Demonstrates that:
|
||||
* 339ه77 (ه + U+200D) -> 339ه77 -> letter ه -> code 15
|
||||
* 339ي77 (Arabic ي) -> 339ی77 -> letter ی -> code 16
|
||||
*/
|
||||
|
||||
// ---------- paste the pure function here so no build needed ----------
|
||||
function normalizePlateText(text) {
|
||||
if (!text) return text;
|
||||
let result = String(text);
|
||||
result = result.normalize("NFKC");
|
||||
result = result.replace(/[\u200C\u200D\u200E\u200F\uFEFF]/g, "");
|
||||
result = result.replace(/\u064A/g, "\u06CC"); // Arabic ي → Persian ی
|
||||
result = result.trim();
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------- the canonical plate-letter → code mapping ----------
|
||||
const PLATE_LETTER_MAP = {
|
||||
الف: 1, ب: 2, پ: 3, ج: 4, د: 5,
|
||||
س: 6, ص: 7, ط: 8, ع: 9, ق: 10,
|
||||
ل: 11, م: 12, ن: 13, و: 14, ه: 15,
|
||||
ی: 16, ک: 17, ژ: 18, ت: 19, ث: 20,
|
||||
ز: 21, ش: 22, ف: 23, گ: 24,
|
||||
};
|
||||
|
||||
function extractAndMap(plateString) {
|
||||
const normalized = normalizePlateText(plateString);
|
||||
|
||||
// Simple parser: digits | letter | digits (left+right digits, 1 letter)
|
||||
const match = normalized.match(/^(\d+)([^\d]+)(\d+)$/);
|
||||
if (!match) return { input: plateString, normalized, error: "no match" };
|
||||
|
||||
const [, leftDigits, letterRaw, rightDigits] = match;
|
||||
const letter = normalizePlateText(letterRaw);
|
||||
const code = PLATE_LETTER_MAP[letter];
|
||||
|
||||
return {
|
||||
input: plateString,
|
||||
normalized,
|
||||
leftDigits,
|
||||
letter,
|
||||
rightDigits,
|
||||
code,
|
||||
ok: code !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- test cases ----------
|
||||
const cases = [
|
||||
{ desc: "ه + ZWJ (the original bug)", plate: "339ه\u200D77" },
|
||||
{ desc: "Arabic ي → Persian ی", plate: "339ي77" },
|
||||
{ desc: "ه + ZWNJ", plate: "339ه\u200C77" },
|
||||
{ desc: "RTL mark around letter", plate: "339\u200Fه\u200E77" },
|
||||
{ desc: "BOM + ZWJ combined", plate: "\uFEFF339ه\u200D77" },
|
||||
{ desc: "clean letter (no junk)", plate: "339ه77" },
|
||||
{ desc: "full plate: الف", plate: "11الف22" },
|
||||
{ desc: "full plate: ی", plate: "55ی99" },
|
||||
];
|
||||
|
||||
console.log("Plate Normalization Verification\n");
|
||||
console.log("=".repeat(72));
|
||||
|
||||
let allPassed = true;
|
||||
for (const { desc, plate } of cases) {
|
||||
const r = extractAndMap(plate);
|
||||
const status = r.ok ? "PASS" : "FAIL";
|
||||
if (!r.ok) allPassed = false;
|
||||
|
||||
console.log(`\n[${status}] ${desc}`);
|
||||
console.log(` Input: ${JSON.stringify(plate)}`);
|
||||
console.log(` Normalized: ${JSON.stringify(r.normalized)}`);
|
||||
if (r.letter) {
|
||||
console.log(` Letter: ${r.letter} (U+${r.letter.charCodeAt(0).toString(16).toUpperCase().padStart(4, "0")})`);
|
||||
}
|
||||
console.log(` Code: ${r.code ?? "undefined"}`);
|
||||
}
|
||||
|
||||
console.log("\n" + "=".repeat(72));
|
||||
console.log(allPassed ? "\n All tests PASSED ✓" : "\n Some tests FAILED ✗");
|
||||
process.exit(allPassed ? 0 : 1);
|
||||
@@ -11,6 +11,24 @@ export enum CaseStatus {
|
||||
|
||||
WAITING_FOR_SIGNATURES = "WAITING_FOR_SIGNATURES",
|
||||
|
||||
/**
|
||||
* FileMaker has collected all signatures; the file is sealed and waiting
|
||||
* for a FileReviewer to complete it (accident fields → capture → video).
|
||||
*/
|
||||
WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER",
|
||||
|
||||
/**
|
||||
* V5 flow only. FileReviewer has completed the claim and the owner has signed;
|
||||
* the FileMaker who created the file must now approve before fanavaran submission.
|
||||
*/
|
||||
WAITING_FOR_FILE_MAKER_APPROVAL = "WAITING_FOR_FILE_MAKER_APPROVAL",
|
||||
|
||||
/**
|
||||
* V5 flow only. FileMaker rejected the file back to FileReviewer
|
||||
* for correction (adjust pricing / back-and-forth with user and re-submit).
|
||||
*/
|
||||
FILE_MAKER_REJECTED = "FILE_MAKER_REJECTED",
|
||||
|
||||
COMPLETED = "COMPLETED",
|
||||
|
||||
CANCELLED = "CANCELLED",
|
||||
|
||||
@@ -35,6 +35,26 @@ export enum ClaimCaseStatus {
|
||||
/** All required factor files are uploaded; damage expert validates factors (`UNDER_REVIEW` @ `EXPERT_COST_EVALUATION`). */
|
||||
EXPERT_VALIDATING_REPAIR_FACTORS = "EXPERT_VALIDATING_REPAIR_FACTORS",
|
||||
|
||||
/**
|
||||
* V4 split flow only. FileMaker has uploaded all initial required documents;
|
||||
* the file is sealed and waiting for a FileReviewer to pick it up (accident fields,
|
||||
* capture, and final blame video). Transitions to SELECTING_OUTER_PARTS when the
|
||||
* FileReviewer calls select-outer-parts after submitting accident fields.
|
||||
*/
|
||||
WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER",
|
||||
|
||||
/**
|
||||
* V5 split flow only. The claim is fully evaluated and owner has signed;
|
||||
* the FileMaker who created the file must approve before fanavaran submission.
|
||||
*/
|
||||
WAITING_FOR_FILE_MAKER_APPROVAL = "WAITING_FOR_FILE_MAKER_APPROVAL",
|
||||
|
||||
/**
|
||||
* V5 split flow only. FileMaker rejected the completed claim back to FileReviewer
|
||||
* for correction (adjust pricing, re-do expert review, back-and-forth with user).
|
||||
*/
|
||||
FILE_MAKER_REJECTED = "FILE_MAKER_REJECTED",
|
||||
|
||||
// Final states
|
||||
COMPLETED = "COMPLETED",
|
||||
CANCELLED = "CANCELLED",
|
||||
|
||||
@@ -6,4 +6,8 @@ export enum RoleEnum {
|
||||
COMPANY = "company",
|
||||
ADMIN = "admin",
|
||||
USER = "user",
|
||||
FILE_MAKER = "file_maker",
|
||||
FILE_REVIEWER = "file_reviewer",
|
||||
SUPER_ADMIN = "super_admin",
|
||||
CALL_CENTER = "call_center",
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AiService } from "./ai.service";
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
imports: [],
|
||||
providers: [AiService],
|
||||
exports: [AiService],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { join } from "node:path";
|
||||
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { UnicodeDigitsNormalizeInterceptor } from "./common/interceptors/unicode-digits-normalize.interceptor";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { ServeStaticModule } from "@nestjs/serve-static";
|
||||
@@ -13,6 +14,7 @@ import { ExpertBlameModule } from "./expert-blame/expert-blame.module";
|
||||
import { FanavaranModule } from "./fanavaran/fanavaran.module";
|
||||
import { ExpertClaimModule } from "./expert-claim/expert-claim.module";
|
||||
import { ExpertInsurerModule } from "./expert-insurer/expert-insurer.module";
|
||||
import { CaseExpertReportModule } from "./case-expert-report/case-expert-report.module";
|
||||
import { LookupsModule } from "./lookups/lookups.module";
|
||||
import { PlatesModule } from "./plates/plates.module";
|
||||
import { ProfileModule } from "./profile/profile.module";
|
||||
@@ -26,9 +28,16 @@ import { CronModule } from "./utils/cron/cron.module";
|
||||
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
|
||||
import { DatabaseModule } from "./core/database/database.module";
|
||||
import { AppConfigModule } from "./core/config/config.module";
|
||||
import { SuperAdminModule } from "./super-admin/super-admin.module";
|
||||
import { createHttpModuleOptions } from "./core/config/http-proxy.factory";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: createHttpModuleOptions,
|
||||
}),
|
||||
AppConfigModule,
|
||||
DatabaseModule,
|
||||
CronModule,
|
||||
@@ -52,11 +61,13 @@ import { AppConfigModule } from "./core/config/config.module";
|
||||
ClaimRequestManagementModule,
|
||||
FanavaranModule,
|
||||
ExpertClaimModule,
|
||||
CaseExpertReportModule,
|
||||
AiModule,
|
||||
ReportsModule,
|
||||
ExpertInsurerModule,
|
||||
LookupsModule,
|
||||
WorkflowStepManagementModule,
|
||||
SuperAdminModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
LegalRegisterDto,
|
||||
} from "src/auth/dto/actor/register.actor.dto";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { SuperAdminGuard } from "src/super-admin/guards/super-admin.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
|
||||
@@ -95,6 +96,7 @@ export class ActorAuthController {
|
||||
* will be removed in a future release.
|
||||
*/
|
||||
@Post("register/genuine")
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiOperation({
|
||||
deprecated: true,
|
||||
summary: "[DEPRECATED] Genuine actor registration",
|
||||
@@ -111,6 +113,7 @@ export class ActorAuthController {
|
||||
* will be removed in a future release.
|
||||
*/
|
||||
@Post("register/legal")
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiOperation({
|
||||
deprecated: true,
|
||||
summary: "[DEPRECATED] Legal actor registration",
|
||||
@@ -123,21 +126,24 @@ export class ActorAuthController {
|
||||
}
|
||||
|
||||
@Post("register/insurer")
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiBody({ type: InsurerRegisterDto })
|
||||
async registerInsurer(@Body() body: InsurerRegisterDto) {
|
||||
return await this.actorAuthService.insurerRegister(body);
|
||||
}
|
||||
|
||||
/** Mock: create a field expert for testing. Make private later. */
|
||||
/** Requires super-admin token. */
|
||||
@Post("create-field-expert")
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiBody({ type: CreateFieldExpertDto })
|
||||
@ApiAcceptedResponse()
|
||||
async createFieldExpert(@Body() body: CreateFieldExpertDto) {
|
||||
return await this.actorAuthService.createFieldExpertMock(body);
|
||||
}
|
||||
|
||||
/** Mock: create a registrar for testing. Make private later. */
|
||||
/** Requires super-admin token. */
|
||||
@Post("create-registrar")
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiBody({ type: CreateRegistrarDto })
|
||||
@ApiAcceptedResponse()
|
||||
async createRegistrar(@Body() body: CreateRegistrarDto) {
|
||||
|
||||
@@ -27,8 +27,12 @@ import { DamageExpertDbService } from "src/users/entities/db-service/damage-expe
|
||||
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||||
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
|
||||
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
|
||||
import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service";
|
||||
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
|
||||
import { CallCenterAgentDbService } from "src/users/entities/db-service/call-center-agent.db.service";
|
||||
import { HashService } from "src/utils/hash/hash.service";
|
||||
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
|
||||
import { SuperAdminDbService } from "src/super-admin/entities/db-service/super-admin.db.service";
|
||||
|
||||
function pick(obj: Record<string, any>, keys: string[]) {
|
||||
const out: Record<string, any> = {};
|
||||
@@ -52,6 +56,10 @@ export class ActorAuthService {
|
||||
private readonly clientDbService: ClientDbService,
|
||||
private readonly otpService: OtpGeneratorService,
|
||||
private readonly captchaChallengeService: CaptchaChallengeService,
|
||||
private readonly fileMakerDbService: FileMakerDbService,
|
||||
private readonly fileReviewerDbService: FileReviewerDbService,
|
||||
private readonly superAdminDbService: SuperAdminDbService,
|
||||
private readonly callCenterAgentDbService: CallCenterAgentDbService,
|
||||
) {}
|
||||
|
||||
// TODO convrt to class for dynamic controller
|
||||
@@ -94,6 +102,37 @@ export class ActorAuthService {
|
||||
case RoleEnum.COMPANY:
|
||||
res = await this.insurerExpertDbService.findOne({ email: username });
|
||||
break;
|
||||
case RoleEnum.FILE_MAKER:
|
||||
if (username == null && userId)
|
||||
res = await this.fileMakerDbService.findOne({
|
||||
_id: new Types.ObjectId(userId),
|
||||
});
|
||||
else res = await this.fileMakerDbService.findByLoginIdentifier(username);
|
||||
break;
|
||||
case RoleEnum.FILE_REVIEWER:
|
||||
if (username == null && userId)
|
||||
res = await this.fileReviewerDbService.findOne({
|
||||
_id: new Types.ObjectId(userId),
|
||||
});
|
||||
else
|
||||
res =
|
||||
await this.fileReviewerDbService.findByLoginIdentifier(username);
|
||||
break;
|
||||
case RoleEnum.SUPER_ADMIN:
|
||||
if (username == null && userId)
|
||||
res = await this.superAdminDbService.findOne({
|
||||
_id: new Types.ObjectId(userId),
|
||||
});
|
||||
else res = await this.superAdminDbService.findByLoginIdentifier(username);
|
||||
break;
|
||||
case RoleEnum.CALL_CENTER:
|
||||
if (username == null && userId)
|
||||
res = await this.callCenterAgentDbService.findOne({
|
||||
_id: new Types.ObjectId(userId),
|
||||
});
|
||||
else
|
||||
res = await this.callCenterAgentDbService.findByLoginIdentifier(username);
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ import { UsersModule } from "src/users/users.module";
|
||||
import { HashModule } from "src/utils/hash/hash.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
import { CaptchaModule } from "src/captcha/captcha.module";
|
||||
import { SuperAdminDbService } from "src/super-admin/entities/db-service/super-admin.db.service";
|
||||
import {
|
||||
SuperAdminModel,
|
||||
SuperAdminSchema,
|
||||
} from "src/super-admin/entities/schema/super-admin.schema";
|
||||
|
||||
/** Auth services and guards are app-wide (avoids importing AuthModule in every feature module). */
|
||||
@Global()
|
||||
@@ -47,6 +52,7 @@ import { CaptchaModule } from "src/captcha/captcha.module";
|
||||
schema: ClaimRequestManagementSchema,
|
||||
},
|
||||
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
||||
{ name: SuperAdminModel.name, schema: SuperAdminSchema },
|
||||
]),
|
||||
JwtModule.register({
|
||||
signOptions: { expiresIn: "1h" }, // TODO: MAKE IT ENV
|
||||
@@ -61,6 +67,7 @@ import { CaptchaModule } from "src/captcha/captcha.module";
|
||||
JwtService,
|
||||
LocalActorAuthGuard,
|
||||
LocalUserAuthGuard,
|
||||
SuperAdminDbService,
|
||||
],
|
||||
exports: [
|
||||
UserAuthService,
|
||||
@@ -68,6 +75,7 @@ import { CaptchaModule } from "src/captcha/captcha.module";
|
||||
JwtService,
|
||||
LocalActorAuthGuard,
|
||||
LocalUserAuthGuard,
|
||||
SuperAdminDbService,
|
||||
],
|
||||
controllers: [UserAuthController, ActorAuthController],
|
||||
})
|
||||
|
||||
@@ -43,6 +43,9 @@ export class LocalActorAuthGuard implements CanActivate {
|
||||
RoleEnum.COMPANY,
|
||||
RoleEnum.FIELD_EXPERT,
|
||||
RoleEnum.REGISTRAR,
|
||||
RoleEnum.FILE_MAKER,
|
||||
RoleEnum.FILE_REVIEWER,
|
||||
RoleEnum.SUPER_ADMIN,
|
||||
].includes(payload.role)
|
||||
) {
|
||||
throw new UnauthorizedException("User role is not authorized");
|
||||
|
||||
@@ -12,6 +12,8 @@ const GLOBAL_GUARD_ROLES = new Set<string>([
|
||||
RoleEnum.USER,
|
||||
RoleEnum.FIELD_EXPERT,
|
||||
RoleEnum.REGISTRAR,
|
||||
RoleEnum.FILE_MAKER,
|
||||
RoleEnum.FILE_REVIEWER
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HashService } from "src/utils/hash/hash.service";
|
||||
@Injectable()
|
||||
export class CaptchaChallengeService {
|
||||
private readonly isDev: boolean;
|
||||
private readonly captchaEnabled: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly captchaService: CaptchaService,
|
||||
@@ -21,6 +22,7 @@ export class CaptchaChallengeService {
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.isDev = this.configService.get<string>("NODE_ENV") === "development";
|
||||
this.captchaEnabled = this.configService.get<string>("CAPTCHA_ENABLED") !== "false";
|
||||
}
|
||||
|
||||
async issue(): Promise<CaptchaResponseDto> {
|
||||
@@ -32,12 +34,17 @@ export class CaptchaChallengeService {
|
||||
this.captchaService.normalizeAnswer(generated.text),
|
||||
);
|
||||
|
||||
// expireAt is the MongoDB TTL sentinel. The TTL reaper fires every ~60 s, so
|
||||
// setting it equal to expiresAt means Mongo can delete the document up to 60 s
|
||||
// BEFORE the application-level expiry check runs — causing the intermittent
|
||||
// "captchaId not found" error under load. Adding a 120 s grace buffer ensures
|
||||
// the document is always present when verify() runs its own expiresAt check.
|
||||
await this.captchaChallengeDbService.create({
|
||||
captchaId,
|
||||
answerHash,
|
||||
image: generated.image,
|
||||
expiresAt: generated.expiresAt,
|
||||
expireAt: new Date(generated.expiresAt),
|
||||
expireAt: new Date(generated.expiresAt + 120_000),
|
||||
usedAt: null,
|
||||
});
|
||||
|
||||
@@ -62,6 +69,11 @@ export class CaptchaChallengeService {
|
||||
captchaId: string | undefined,
|
||||
answer: string | undefined,
|
||||
): Promise<void> {
|
||||
// Skip captcha verification if disabled via environment variable
|
||||
if (!this.captchaEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!captchaId?.trim()) {
|
||||
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ export class CaptchaChallenge {
|
||||
@Prop({ required: true, unique: true, index: true })
|
||||
captchaId: string;
|
||||
|
||||
@Prop()
|
||||
answerHash?: string;
|
||||
@Prop({ required: true })
|
||||
answerHash: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
image: string;
|
||||
|
||||
36
src/case-expert-report/case-expert-report-pdf.service.ts
Normal file
36
src/case-expert-report/case-expert-report-pdf.service.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import {
|
||||
createPersianPdfDocument,
|
||||
persianPdfToBuffer,
|
||||
} from "src/helpers/persian-pdf-document";
|
||||
import {
|
||||
InsurerFileReportPdfResult,
|
||||
InsurerFileReportViewModel,
|
||||
} from "./case-expert-report.types";
|
||||
import { PR } from "./persian-report-labels";
|
||||
|
||||
@Injectable()
|
||||
export class CaseExpertReportPdfService {
|
||||
async render(model: InsurerFileReportViewModel): Promise<InsurerFileReportPdfResult> {
|
||||
const pdf = createPersianPdfDocument();
|
||||
pdf.addTitle(model.title);
|
||||
pdf.addKeyValue(PR.publicId, model.publicId);
|
||||
pdf.addKeyValue(PR.requestNo, model.requestNo);
|
||||
pdf.addBlank();
|
||||
|
||||
for (const section of model.sections) {
|
||||
pdf.addSection(section.title);
|
||||
for (const field of section.fields) {
|
||||
pdf.addKeyValue(field.label, field.value);
|
||||
}
|
||||
pdf.addBlank();
|
||||
}
|
||||
|
||||
const buffer = await persianPdfToBuffer(pdf);
|
||||
const safeId = (model.publicId || "file").replace(/[^\w.-]+/g, "_");
|
||||
return {
|
||||
buffer,
|
||||
filename: `insurer-file-report-${safeId}.pdf`,
|
||||
};
|
||||
}
|
||||
}
|
||||
518
src/case-expert-report/case-expert-report.builder.ts
Normal file
518
src/case-expert-report/case-expert-report.builder.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
import { resolveDamagedPartyRow } from "src/helpers/blame-damaged-party";
|
||||
import { toJalaliDateAndTime } from "src/helpers/date-jalali";
|
||||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||
import {
|
||||
InsurerFileReportField,
|
||||
InsurerFileReportSection,
|
||||
InsurerFileReportViewModel,
|
||||
} from "./case-expert-report.types";
|
||||
import { PR, persianFieldPath, persianStatus } from "./persian-report-labels";
|
||||
|
||||
const SKIP_FLATTEN_KEYS = new Set([
|
||||
"_id",
|
||||
"__v",
|
||||
"history",
|
||||
"workflow",
|
||||
"evidence",
|
||||
"confirmation",
|
||||
]);
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
if (value instanceof Date) {
|
||||
const [d, t] = toJalaliDateAndTime(value);
|
||||
return `${d} ${t}`;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
if (typeof (value as { toString?: () => string }).toString === "function") {
|
||||
const s = String(value);
|
||||
if (s !== "[object Object]") return s;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatBirthDate(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const raw = String(value);
|
||||
if (/^\d{8}$/.test(raw)) {
|
||||
return `${raw.slice(0, 4)}/${raw.slice(4, 6)}/${raw.slice(6, 8)}`;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function flattenObject(
|
||||
obj: unknown,
|
||||
prefix = "",
|
||||
depth = 0,
|
||||
): InsurerFileReportField[] {
|
||||
if (obj == null) return [];
|
||||
if (depth > 4) {
|
||||
return [{ label: persianFieldPath(prefix), value: asString(obj) }];
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
if (!obj.length) return [];
|
||||
return [
|
||||
{
|
||||
label: persianFieldPath(prefix || "items"),
|
||||
value: obj.map((item) => asString(item) ?? JSON.stringify(item)).join("، "),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (typeof obj !== "object") {
|
||||
return [{ label: persianFieldPath(prefix || "value"), value: asString(obj) }];
|
||||
}
|
||||
|
||||
const rows: InsurerFileReportField[] = [];
|
||||
const objRecord = obj as Record<string, unknown>;
|
||||
const hasMapped =
|
||||
objRecord.mapped != null && typeof objRecord.mapped === "object";
|
||||
for (const [key, value] of Object.entries(objRecord)) {
|
||||
if (SKIP_FLATTEN_KEYS.has(key)) continue;
|
||||
if (key === "raw" && hasMapped) continue;
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
!(value instanceof Date)
|
||||
) {
|
||||
rows.push(...flattenObject(value, path, depth + 1));
|
||||
} else {
|
||||
rows.push({ label: persianFieldPath(path), value: asString(value) });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function expertNameFromSnapshot(snapshot?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}): string | undefined {
|
||||
if (!snapshot) return undefined;
|
||||
const name = [snapshot.firstName, snapshot.lastName].filter(Boolean).join(" ");
|
||||
return name || undefined;
|
||||
}
|
||||
|
||||
function collectExpertNames(
|
||||
blame?: Record<string, unknown> | null,
|
||||
claim?: Record<string, unknown> | null,
|
||||
): string | undefined {
|
||||
const names = new Set<string>();
|
||||
|
||||
const blameDecision = (
|
||||
blame?.expert as Record<string, unknown> | undefined
|
||||
)?.decision as Record<string, unknown> | undefined;
|
||||
const blameExpert = expertNameFromSnapshot(
|
||||
blameDecision?.expertProfileSnapshot as
|
||||
| { firstName?: string; lastName?: string }
|
||||
| undefined,
|
||||
);
|
||||
if (blameExpert) names.add(blameExpert);
|
||||
|
||||
const evaluation = claim?.evaluation as Record<string, unknown> | undefined;
|
||||
for (const key of ["damageExpertReplyFinal", "damageExpertReply"] as const) {
|
||||
const reply = evaluation?.[key] as Record<string, unknown> | undefined;
|
||||
if (!reply) continue;
|
||||
const actor = (reply.actorDetail as { actorName?: string } | undefined)
|
||||
?.actorName;
|
||||
if (actor) names.add(actor);
|
||||
const snap = expertNameFromSnapshot(
|
||||
reply.expertProfileSnapshot as
|
||||
| { firstName?: string; lastName?: string }
|
||||
| undefined,
|
||||
);
|
||||
if (snap) names.add(snap);
|
||||
}
|
||||
|
||||
return names.size ? [...names].join(", ") : undefined;
|
||||
}
|
||||
|
||||
function inquiryRoleData(
|
||||
inquiries: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
role?: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const block = inquiries?.[key] as Record<string, unknown> | undefined;
|
||||
const data = block?.data as Record<string, unknown> | undefined;
|
||||
if (!data) return undefined;
|
||||
if (role && data[role] && typeof data[role] === "object") {
|
||||
return data[role] as Record<string, unknown>;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Prefer mapped Tejarat fields; avoid duplicating the entire raw blob in PDF. */
|
||||
function pickInquiryReportPayload(
|
||||
inquiry?: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!inquiry) return undefined;
|
||||
const mapped = inquiry.mapped;
|
||||
if (mapped && typeof mapped === "object" && !Array.isArray(mapped)) {
|
||||
return mapped as Record<string, unknown>;
|
||||
}
|
||||
const { raw: _raw, ...rest } = inquiry;
|
||||
return Object.keys(rest).length ? rest : inquiry;
|
||||
}
|
||||
|
||||
function licenseFieldsFromInquiry(
|
||||
inquiry?: Record<string, unknown>,
|
||||
): { licenseType?: string; licenseDate?: string } {
|
||||
if (!inquiry) return {};
|
||||
const candidates = [
|
||||
inquiry.LicenseType,
|
||||
inquiry.licenseType,
|
||||
inquiry.Type,
|
||||
inquiry.type,
|
||||
inquiry.LicenseCategory,
|
||||
inquiry.licenseCategory,
|
||||
];
|
||||
const licenseType = candidates.find((v) => v != null && v !== "");
|
||||
const dateCandidates = [
|
||||
inquiry.IssueDate,
|
||||
inquiry.issueDate,
|
||||
inquiry.LicenseIssueDate,
|
||||
inquiry.licenseIssueDate,
|
||||
inquiry.ExpireDate,
|
||||
inquiry.expireDate,
|
||||
];
|
||||
const licenseDate = dateCandidates.find((v) => v != null && v !== "");
|
||||
return {
|
||||
licenseType: asString(licenseType),
|
||||
licenseDate: asString(licenseDate),
|
||||
};
|
||||
}
|
||||
|
||||
function buildOwnerSection(
|
||||
damagedParty: ReturnType<typeof resolveDamagedPartyRow>,
|
||||
claim?: Record<string, unknown> | null,
|
||||
): InsurerFileReportSection {
|
||||
const person = damagedParty?.person;
|
||||
const money = claim?.money as
|
||||
| { sheba?: string; nationalCodeOfInsurer?: string }
|
||||
| undefined;
|
||||
const claimOwner = claim?.owner as { fullName?: string } | undefined;
|
||||
|
||||
return {
|
||||
title: PR.ownerSection,
|
||||
fields: [
|
||||
{ label: PR.name, value: person?.fullName ?? claimOwner?.fullName },
|
||||
{ label: PR.phone, value: person?.phoneNumber },
|
||||
{
|
||||
label: PR.nationalCode,
|
||||
value: person?.nationalCodeOfInsurer ?? money?.nationalCodeOfInsurer,
|
||||
},
|
||||
{
|
||||
label: PR.birthDate,
|
||||
value: formatBirthDate(person?.insurerBirthday ?? person?.birthday),
|
||||
},
|
||||
{ label: PR.sheba, value: money?.sheba },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildDriverSection(
|
||||
damagedParty: ReturnType<typeof resolveDamagedPartyRow>,
|
||||
blame?: Record<string, unknown> | null,
|
||||
): InsurerFileReportSection | undefined {
|
||||
const person = damagedParty?.person;
|
||||
if (!person || person.driverIsInsurer !== false) return undefined;
|
||||
|
||||
const role = damagedParty?.role ?? PartyRole.FIRST;
|
||||
const licenseInquiry = inquiryRoleData(
|
||||
blame?.inquiries as Record<string, unknown> | undefined,
|
||||
"drivingLicence",
|
||||
role,
|
||||
);
|
||||
const { licenseType, licenseDate } = licenseFieldsFromInquiry(licenseInquiry);
|
||||
|
||||
return {
|
||||
title: PR.driverSection,
|
||||
fields: [
|
||||
{ label: PR.name, value: person.fullName },
|
||||
{
|
||||
label: PR.licenseType,
|
||||
value: licenseType ?? (person.driverLicense ? PR.driverLicense : undefined),
|
||||
},
|
||||
{
|
||||
label: PR.licenseDate,
|
||||
value: licenseDate ?? person.driverLicense,
|
||||
},
|
||||
{ label: PR.phone, value: person.phoneNumber },
|
||||
{ label: PR.nationalCode, value: person.nationalCodeOfDriver },
|
||||
{ label: PR.birthDate, value: formatBirthDate(person.driverBirthday) },
|
||||
{ label: PR.licenseNumber, value: person.driverLicense },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildInsuranceSection(
|
||||
damagedParty: ReturnType<typeof resolveDamagedPartyRow>,
|
||||
blame?: Record<string, unknown> | null,
|
||||
claim?: Record<string, unknown> | null,
|
||||
): InsurerFileReportSection {
|
||||
const role = damagedParty?.role ?? PartyRole.FIRST;
|
||||
const insurance = damagedParty?.insurance ?? {};
|
||||
const carBodyLegacy = blame?.carBodyInsuranceDetail as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
const thirdPartyInquiry = inquiryRoleData(
|
||||
blame?.inquiries as Record<string, unknown> | undefined,
|
||||
"thirdParty",
|
||||
role,
|
||||
);
|
||||
const carBodyInquiry = inquiryRoleData(
|
||||
blame?.inquiries as Record<string, unknown> | undefined,
|
||||
"carBody",
|
||||
role,
|
||||
);
|
||||
const claimThirdParty = inquiryRoleData(
|
||||
claim?.inquiries as Record<string, unknown> | undefined,
|
||||
"thirdParty",
|
||||
role,
|
||||
);
|
||||
const claimCarBody = inquiryRoleData(
|
||||
claim?.inquiries as Record<string, unknown> | undefined,
|
||||
"carBody",
|
||||
role,
|
||||
);
|
||||
|
||||
const fields: InsurerFileReportField[] = [
|
||||
...flattenObject(insurance, "party.insurance"),
|
||||
...flattenObject(
|
||||
(insurance as { carBodyInsurance?: unknown }).carBodyInsurance,
|
||||
"party.insurance.carBodyInsurance",
|
||||
),
|
||||
...flattenObject(
|
||||
pickInquiryReportPayload(thirdPartyInquiry),
|
||||
"inquiry.thirdParty",
|
||||
),
|
||||
...flattenObject(pickInquiryReportPayload(carBodyInquiry), "inquiry.carBody"),
|
||||
...flattenObject(
|
||||
pickInquiryReportPayload(claimThirdParty),
|
||||
"claim.inquiry.thirdParty",
|
||||
),
|
||||
...flattenObject(
|
||||
pickInquiryReportPayload(claimCarBody),
|
||||
"claim.inquiry.carBody",
|
||||
),
|
||||
...flattenObject(carBodyLegacy, "blame.carBodyInsuranceDetail"),
|
||||
];
|
||||
|
||||
const deduped = dedupeFields(fields);
|
||||
return {
|
||||
title: PR.insuranceSection,
|
||||
fields: deduped.length ? deduped : [{ label: PR.data, value: PR.empty }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildVehicleSection(
|
||||
damagedParty: ReturnType<typeof resolveDamagedPartyRow>,
|
||||
claim?: Record<string, unknown> | null,
|
||||
): InsurerFileReportSection {
|
||||
const partyVehicle = damagedParty?.vehicle;
|
||||
const claimVehicle = claim?.vehicle as Record<string, unknown> | undefined;
|
||||
|
||||
const fields = dedupeFields([
|
||||
...flattenObject(claimVehicle, "claim.vehicle"),
|
||||
...flattenObject(partyVehicle, "party.vehicle"),
|
||||
]);
|
||||
|
||||
return {
|
||||
title: PR.vehicleSection,
|
||||
fields: fields.length ? fields : [{ label: PR.data, value: PR.empty }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildAccidentReportSection(
|
||||
blame?: Record<string, unknown> | null,
|
||||
claim?: Record<string, unknown> | null,
|
||||
damagedParty?: ReturnType<typeof resolveDamagedPartyRow>,
|
||||
): InsurerFileReportSection {
|
||||
const statement = damagedParty?.statement as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const location = damagedParty?.location;
|
||||
const snapshotAccident = (
|
||||
claim?.snapshot as { accident?: Record<string, unknown> } | undefined
|
||||
)?.accident;
|
||||
const blameDecision = (
|
||||
blame?.expert as Record<string, unknown> | undefined
|
||||
)?.decision as Record<string, unknown> | undefined;
|
||||
const decisionFields = blameDecision?.fields as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
const accidentDate =
|
||||
asString(statement?.accidentDate) ??
|
||||
asString(snapshotAccident?.date) ??
|
||||
asString(blame?.createdAtFormatted) ??
|
||||
asString(blame?.createdAt);
|
||||
|
||||
const accidentTime =
|
||||
asString(statement?.accidentTime) ?? asString(snapshotAccident?.time);
|
||||
|
||||
const fields: InsurerFileReportField[] = [
|
||||
{ label: PR.date, value: accidentDate },
|
||||
{ label: PR.time, value: accidentTime },
|
||||
{
|
||||
label: PR.experts,
|
||||
value: collectExpertNames(blame, claim),
|
||||
},
|
||||
{
|
||||
label: PR.location,
|
||||
value:
|
||||
location?.lat != null && location?.lon != null
|
||||
? `${location.lat}، ${location.lon}`
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
label: PR.weather,
|
||||
value:
|
||||
asString(statement?.weatherCondition) ??
|
||||
asString(snapshotAccident?.weatherCondition),
|
||||
},
|
||||
{
|
||||
label: PR.road,
|
||||
value:
|
||||
asString(statement?.roadCondition) ??
|
||||
asString(snapshotAccident?.roadCondition),
|
||||
},
|
||||
{
|
||||
label: PR.light,
|
||||
value:
|
||||
asString(statement?.lightCondition) ??
|
||||
asString(snapshotAccident?.lightCondition),
|
||||
},
|
||||
{
|
||||
label: PR.blameStatus,
|
||||
value: persianStatus(blame?.blameStatus),
|
||||
},
|
||||
{
|
||||
label: PR.claimStatus,
|
||||
value: persianStatus(claim?.claimStatus),
|
||||
},
|
||||
{ label: PR.expertDecision, value: asString(blameDecision?.description) },
|
||||
{
|
||||
label: PR.accidentWay,
|
||||
value: asString(
|
||||
(decisionFields?.accidentWay as { label?: string } | undefined)?.label ??
|
||||
(
|
||||
snapshotAccident?.classification as {
|
||||
accidentWay?: { label?: string };
|
||||
}
|
||||
)?.accidentWay?.label,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: PR.accidentReason,
|
||||
value: asString(
|
||||
(decisionFields?.accidentReason as { label?: string } | undefined)
|
||||
?.label ??
|
||||
(
|
||||
snapshotAccident?.classification as {
|
||||
accidentReason?: { label?: string };
|
||||
}
|
||||
)?.accidentReason?.label,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: PR.accidentType,
|
||||
value: asString(
|
||||
(decisionFields?.accidentType as { label?: string } | undefined)?.label ??
|
||||
(
|
||||
snapshotAccident?.classification as {
|
||||
accidentType?: { label?: string };
|
||||
}
|
||||
)?.accidentType?.label,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: PR.damageExpertDate,
|
||||
value: asString(
|
||||
(
|
||||
(claim?.evaluation as Record<string, unknown> | undefined)
|
||||
?.damageExpertReplyFinal as Record<string, unknown> | undefined
|
||||
)?.submittedAt ??
|
||||
(
|
||||
(claim?.evaluation as Record<string, unknown> | undefined)
|
||||
?.damageExpertReply as Record<string, unknown> | undefined
|
||||
)?.submittedAt,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: PR.damageExpertNotes,
|
||||
value: asString(
|
||||
(
|
||||
(claim?.evaluation as Record<string, unknown> | undefined)
|
||||
?.damageExpertReplyFinal as Record<string, unknown> | undefined
|
||||
)?.description ??
|
||||
(
|
||||
(claim?.evaluation as Record<string, unknown> | undefined)
|
||||
?.damageExpertReply as Record<string, unknown> | undefined
|
||||
)?.description,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: PR.partyDescription,
|
||||
value: asString(statement?.description),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
title: PR.accidentSection,
|
||||
fields: dedupeFields(fields),
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeFields(fields: InsurerFileReportField[]): InsurerFileReportField[] {
|
||||
const seen = new Set<string>();
|
||||
const out: InsurerFileReportField[] = [];
|
||||
for (const field of fields) {
|
||||
const value = asString(field.value);
|
||||
if (!value) continue;
|
||||
const key = `${field.label}::${value}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ label: field.label, value });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildInsurerFileReport(
|
||||
file: {
|
||||
overview?: Record<string, unknown>;
|
||||
blame?: Record<string, unknown>;
|
||||
claim?: Record<string, unknown>;
|
||||
},
|
||||
): InsurerFileReportViewModel {
|
||||
const overview = file.overview ?? {};
|
||||
const blame = file.blame ?? null;
|
||||
const claim = file.claim ?? null;
|
||||
const damagedParty = blame ? resolveDamagedPartyRow(blame) : null;
|
||||
|
||||
const sections: InsurerFileReportSection[] = [
|
||||
buildOwnerSection(damagedParty, claim),
|
||||
];
|
||||
|
||||
const driverSection = buildDriverSection(damagedParty, blame);
|
||||
if (driverSection) sections.push(driverSection);
|
||||
|
||||
sections.push(
|
||||
buildInsuranceSection(damagedParty, blame, claim),
|
||||
buildVehicleSection(damagedParty, claim),
|
||||
buildAccidentReportSection(blame, claim, damagedParty),
|
||||
);
|
||||
|
||||
return {
|
||||
title: PR.reportTitle,
|
||||
publicId: asString(overview.publicId) ?? PR.empty,
|
||||
requestNo: asString(overview.requestNo),
|
||||
sections,
|
||||
};
|
||||
}
|
||||
68
src/case-expert-report/case-expert-report.controller.ts
Normal file
68
src/case-expert-report/case-expert-report.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
InternalServerErrorException,
|
||||
Param,
|
||||
StreamableFile,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiProduces,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { CaseExpertReportService } from "./case-expert-report.service";
|
||||
|
||||
@ApiTags("expert-insurer-panel")
|
||||
@Controller("expert-insurer")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.COMPANY)
|
||||
export class CaseExpertReportInsurerController {
|
||||
constructor(
|
||||
private readonly caseExpertReportService: CaseExpertReportService,
|
||||
) {}
|
||||
|
||||
@Get("files/:publicId/report.pdf")
|
||||
@ApiOperation({
|
||||
summary: "Download insurer file report PDF",
|
||||
description:
|
||||
"Generates a PDF for the shared publicId (blame + claim combined): damaged owner, driver when different, insurance, vehicle, and accident report sections.",
|
||||
})
|
||||
@ApiParam({ name: "publicId" })
|
||||
@ApiProduces("application/pdf")
|
||||
@ApiResponse({ status: 200, description: "PDF file" })
|
||||
@ApiResponse({ status: 404, description: "File not found for this publicId" })
|
||||
async downloadInsurerReport(
|
||||
@CurrentUser() insurer: { clientKey?: string },
|
||||
@Param("publicId") publicId: string,
|
||||
): Promise<StreamableFile> {
|
||||
try {
|
||||
const { buffer, filename } =
|
||||
await this.caseExpertReportService.generateForInsurer(
|
||||
publicId,
|
||||
insurer,
|
||||
);
|
||||
return new StreamableFile(buffer, {
|
||||
type: "application/pdf",
|
||||
disposition: `attachment; filename="${filename}"`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to generate insurer file report PDF",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/case-expert-report/case-expert-report.module.ts
Normal file
13
src/case-expert-report/case-expert-report.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ExpertInsurerModule } from "src/expert-insurer/expert-insurer.module";
|
||||
import { CaseExpertReportInsurerController } from "./case-expert-report.controller";
|
||||
import { CaseExpertReportPdfService } from "./case-expert-report-pdf.service";
|
||||
import { CaseExpertReportService } from "./case-expert-report.service";
|
||||
|
||||
@Module({
|
||||
imports: [ExpertInsurerModule],
|
||||
controllers: [CaseExpertReportInsurerController],
|
||||
providers: [CaseExpertReportService, CaseExpertReportPdfService],
|
||||
exports: [CaseExpertReportService],
|
||||
})
|
||||
export class CaseExpertReportModule {}
|
||||
30
src/case-expert-report/case-expert-report.service.ts
Normal file
30
src/case-expert-report/case-expert-report.service.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { ExpertInsurerService } from "src/expert-insurer/expert-insurer.service";
|
||||
import { buildInsurerFileReport } from "./case-expert-report.builder";
|
||||
import { CaseExpertReportPdfService } from "./case-expert-report-pdf.service";
|
||||
import { InsurerFileReportPdfResult } from "./case-expert-report.types";
|
||||
|
||||
@Injectable()
|
||||
export class CaseExpertReportService {
|
||||
constructor(
|
||||
private readonly expertInsurerService: ExpertInsurerService,
|
||||
private readonly pdfService: CaseExpertReportPdfService,
|
||||
) {}
|
||||
|
||||
async generateForInsurer(
|
||||
publicId: string,
|
||||
actor: { clientKey?: string },
|
||||
): Promise<InsurerFileReportPdfResult> {
|
||||
const clientKey = actor?.clientKey;
|
||||
if (!clientKey) {
|
||||
throw new NotFoundException("Insurer context not found");
|
||||
}
|
||||
|
||||
const file = await this.expertInsurerService.retrieveFileDetailsByPublicId(
|
||||
clientKey,
|
||||
publicId,
|
||||
);
|
||||
const model = buildInsurerFileReport(file);
|
||||
return this.pdfService.render(model);
|
||||
}
|
||||
}
|
||||
21
src/case-expert-report/case-expert-report.types.ts
Normal file
21
src/case-expert-report/case-expert-report.types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type InsurerFileReportField = {
|
||||
label: string;
|
||||
value?: string | number | null;
|
||||
};
|
||||
|
||||
export type InsurerFileReportSection = {
|
||||
title: string;
|
||||
fields: InsurerFileReportField[];
|
||||
};
|
||||
|
||||
export type InsurerFileReportViewModel = {
|
||||
title: string;
|
||||
publicId: string;
|
||||
requestNo?: string;
|
||||
sections: InsurerFileReportSection[];
|
||||
};
|
||||
|
||||
export type InsurerFileReportPdfResult = {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
};
|
||||
196
src/case-expert-report/persian-report-labels.ts
Normal file
196
src/case-expert-report/persian-report-labels.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
export const PR = {
|
||||
reportTitle: "گزارش پرونده بیمه گر",
|
||||
publicId: "شناسه عمومی",
|
||||
requestNo: "شماره درخواست",
|
||||
empty: "-",
|
||||
ownerSection: "مالک خودروی زیان دیده",
|
||||
driverSection: "راننده خودروی زیان دیده",
|
||||
insuranceSection: "اطلاعات بیمه (بدنه و شخص ثالث)",
|
||||
vehicleSection: "اطلاعات خودرو",
|
||||
accidentSection: "گزارش حادثه",
|
||||
name: "نام",
|
||||
phone: "شماره تلفن",
|
||||
nationalCode: "کد ملی",
|
||||
birthDate: "تاریخ تولد",
|
||||
sheba: "شماره شبا",
|
||||
licenseType: "نوع گواهینامه",
|
||||
licenseDate: "تاریخ گواهینامه",
|
||||
licenseNumber: "شماره گواهینامه",
|
||||
driverLicense: "گواهینامه راننده",
|
||||
data: "اطلاعات",
|
||||
date: "تاریخ",
|
||||
time: "زمان",
|
||||
experts: "کارشناس(ان)",
|
||||
location: "موقعیت (عرض و طول جغرافیایی)",
|
||||
weather: "وضعیت آب و هوا",
|
||||
road: "وضعیت جاده",
|
||||
light: "وضعیت نور",
|
||||
blameStatus: "وضعیت مقصر",
|
||||
claimStatus: "وضعیت خسارت",
|
||||
expertDecision: "نظر کارشناس مقصر",
|
||||
accidentWay: "نحوه برخورد",
|
||||
accidentReason: "علت حادثه",
|
||||
accidentType: "نوع حادثه",
|
||||
damageExpertDate: "تاریخ ارزیابی کارشناس خسارت",
|
||||
damageExpertNotes: "توضیحات کارشناس خسارت",
|
||||
partyDescription: "توضیحات طرف",
|
||||
} as const;
|
||||
|
||||
const KEY_LABELS: Record<string, string> = {
|
||||
policyNumber: "شماره بیمهنامه",
|
||||
company: "شرکت بیمه",
|
||||
insurerCompany: "شرکت بیمهگر",
|
||||
startDate: "تاریخ شروع",
|
||||
endDate: "تاریخ پایان",
|
||||
financialCeiling: "سقف مالی",
|
||||
coverages: "پوششها",
|
||||
plateId: "پلاک",
|
||||
name: "نام",
|
||||
model: "مدل",
|
||||
type: "نوع",
|
||||
carName: "نام خودرو",
|
||||
carModel: "مدل خودرو",
|
||||
carType: "نوع خودرو",
|
||||
isNew: "خودرو نو",
|
||||
isNewCar: "خودرو نو",
|
||||
leftDigits: "دو رقم چپ پلاک",
|
||||
centerAlphabet: "حرف پلاک",
|
||||
centerDigits: "سه رقم وسط پلاک",
|
||||
ir: "کد ایران",
|
||||
plate: "پلاک",
|
||||
CompanyName: "نام شرکت",
|
||||
PolicyNumber: "شماره بیمهنامه",
|
||||
PolicyStartDate: "تاریخ شروع بیمه",
|
||||
PolicyEndDate: "تاریخ پایان بیمه",
|
||||
LicenseType: "نوع گواهینامه",
|
||||
licenseType: "نوع گواهینامه",
|
||||
IssueDate: "تاریخ صدور",
|
||||
issueDate: "تاریخ صدور",
|
||||
ExpireDate: "تاریخ انقضا",
|
||||
expireDate: "تاریخ انقضا",
|
||||
party: "طرف",
|
||||
insurance: "بیمه",
|
||||
carBodyInsurance: "بیمه بدنه",
|
||||
inquiry: "استعلام",
|
||||
thirdParty: "شخص ثالث",
|
||||
carBody: "بدنه",
|
||||
claim: "خسارت",
|
||||
vehicle: "خودرو",
|
||||
blame: "مقصر",
|
||||
items: "موارد",
|
||||
value: "مقدار",
|
||||
mapped: "نتیجه استعلام",
|
||||
has: "موجود",
|
||||
updatedAt: "بهروزرسانی",
|
||||
source: "منبع",
|
||||
PrntPlcyCmpDocNo: "شماره سند شرکت",
|
||||
MapTypNam: "نام نوع خودرو",
|
||||
MtrNum: "شماره موتور",
|
||||
ShsNum: "شماره شاسی",
|
||||
vin: "VIN",
|
||||
VinNumberField: "VIN",
|
||||
DisFnYrPrcnt: "درصد تخفیف مالی",
|
||||
DisLfYrPrcnt: "درصد تخفیف جانی",
|
||||
DisPrsnYrPrcnt: "درصد تخفیف شخص ثالث",
|
||||
MapVehicleSystemName: "سیستم خودرو",
|
||||
LfCvrCptl: "سرمایه پوشش جانی",
|
||||
FnCvrCptl: "سرمایه پوشش مالی",
|
||||
PrsnCvrCptl: "سرمایه پوشش شخص ثالث",
|
||||
PersonCvrCptl: "سرمایه پوشش شخص",
|
||||
LifeCvrCptl: "سرمایه پوشش حیات",
|
||||
FinancialCvrCptl: "سرمایه پوشش مالی",
|
||||
VehicleSystemCode: "کد سیستم خودرو",
|
||||
CarGroupCode: "کد گروه خودرو",
|
||||
CylCnt: "تعداد سیلندر",
|
||||
LastCompanyDocumentNumber: "شماره سند آخرین شرکت",
|
||||
UsageCode: "کد کاربری",
|
||||
MapUsageCode: "کد کاربری نگاشتشده",
|
||||
MapUsageName: "نام کاربری",
|
||||
Plk1: "دو رقم چپ پلاک",
|
||||
Plk2: "حرف پلاک",
|
||||
Plk3: "سه رقم وسط پلاک",
|
||||
PlkSrl: "کد ایران پلاک",
|
||||
SystemField: "سیستم",
|
||||
TypeField: "تیپ",
|
||||
UsageField: "کاربری",
|
||||
MainColorField: "رنگ اصلی",
|
||||
SecondColorField: "رنگ فرعی",
|
||||
ModelField: "مدل",
|
||||
CapacityField: "ظرفیت",
|
||||
CacityField: "ظرفیت",
|
||||
CylinderNumberField: "تعداد سیلندر",
|
||||
EngineNumberField: "شماره موتور",
|
||||
ChassisNumberField: "شماره شاسی",
|
||||
InstallDateField: "تاریخ نصب",
|
||||
AxelNumberField: "تعداد محور",
|
||||
WheelNumberField: "تعداد چرخ",
|
||||
CompanyCode: "کد شرکت",
|
||||
SatrtDate: "تاریخ شروع",
|
||||
EndDate: "تاریخ پایان",
|
||||
PolicyHealthLossCount: "تعداد خسارت جانی",
|
||||
PolicyFinancialLossCount: "تعداد خسارت مالی",
|
||||
PolicyPersonLossCount: "تعداد خسارت شخص ثالث",
|
||||
Tonage: "تناژ",
|
||||
ThirdPolicyCode: "کد بیمهنامه ثالث",
|
||||
SystemCodeCii: "کد سیستم",
|
||||
SystemNameCii: "نام سیستم",
|
||||
TypeCodeCii: "کد نوع",
|
||||
TypeNameCii: "نام نوع",
|
||||
UsageNameCii: "نام کاربری",
|
||||
UsageCodeCii: "کد کاربری",
|
||||
ModelCii: "مدل",
|
||||
StatusTypeCode: "کد وضعیت",
|
||||
label_fa: "برچسب فارسی",
|
||||
catalogKey: "کلید کاتالوگ",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
AGREED: "توافق",
|
||||
DISAGREEMENT: "اختلاف نظر",
|
||||
UNKNOWN: "نامشخص",
|
||||
APPROVED: "تأیید شده",
|
||||
REJECTED: "رد شده",
|
||||
NEEDS_REVISION: "نیاز به بازبینی",
|
||||
UNDER_REVIEW: "در حال بررسی",
|
||||
PENDING: "در انتظار",
|
||||
true: "بله",
|
||||
false: "خیر",
|
||||
};
|
||||
|
||||
/** Turn `party.insurance.policyNumber` into a Persian label. */
|
||||
export function persianFieldPath(path: string): string {
|
||||
if (!path) return PR.data;
|
||||
const parts = path.split(".").filter(Boolean);
|
||||
const last = parts[parts.length - 1] ?? path;
|
||||
const translatedLast = KEY_LABELS[last] ?? last;
|
||||
|
||||
const inquiryRoot = parts.find(
|
||||
(p) => p === "thirdParty" || p === "carBody" || p === "mapped",
|
||||
);
|
||||
if (inquiryRoot === "thirdParty") {
|
||||
return `بیمه شخص ثالث / ${translatedLast}`;
|
||||
}
|
||||
if (inquiryRoot === "carBody") {
|
||||
return `بیمه بدنه / ${translatedLast}`;
|
||||
}
|
||||
if (parts[0] === "party" && parts[1] === "insurance") {
|
||||
return `بیمه / ${translatedLast}`;
|
||||
}
|
||||
if (parts[0] === "claim" && parts[1] === "vehicle") {
|
||||
return `خودرو / ${translatedLast}`;
|
||||
}
|
||||
if (parts[0] === "party" && parts[1] === "vehicle") {
|
||||
return `خودرو / ${translatedLast}`;
|
||||
}
|
||||
|
||||
if (parts.length === 1) return translatedLast;
|
||||
|
||||
const translated = parts.map((part) => KEY_LABELS[part] ?? part);
|
||||
return translated.join(" / ");
|
||||
}
|
||||
|
||||
export function persianStatus(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const key = String(value);
|
||||
return STATUS_LABELS[key] ?? key;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
|
||||
import { AiModule } from "src/ai/ai.module";
|
||||
import { SandHubModule } from "src/sand-hub/sand-hub.module";
|
||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||
@@ -52,13 +55,20 @@ import { ClientModule } from "src/client/client.module";
|
||||
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { MediaPolicyModule } from "src/media-policy/media-policy.module";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
|
||||
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
||||
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule,
|
||||
HttpModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: createHttpModuleOptions,
|
||||
}),
|
||||
FanavaranAuditModule,
|
||||
FanavaranLookupModule,
|
||||
PlateNormalizerModule,
|
||||
PublicIdModule,
|
||||
UsersModule,
|
||||
RequestManagementModule,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,10 +13,19 @@ import {
|
||||
Get,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
} from "@nestjs/common";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { ApiBearerAuth, ApiParam, ApiTags, ApiOperation, ApiResponse, ApiBody, ApiConsumes } from "@nestjs/swagger";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
} from "@nestjs/swagger";
|
||||
import { FileInterceptor, FilesInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { extname } from "node:path";
|
||||
import { Types } from "mongoose";
|
||||
@@ -33,9 +42,15 @@ import {
|
||||
SelectOuterPartsV2Dto,
|
||||
SelectOuterPartsV2ResponseDto,
|
||||
} from "./dto/select-outer-parts-v2.dto";
|
||||
import { SelectOtherPartsV2Dto, SelectOtherPartsV2ResponseDto } from "./dto/select-other-parts-v2.dto";
|
||||
import {
|
||||
SelectOtherPartsV2Dto,
|
||||
SelectOtherPartsV2ResponseDto,
|
||||
} from "./dto/select-other-parts-v2.dto";
|
||||
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||
import { UploadRequiredDocumentV2Dto, UploadRequiredDocumentV2ResponseDto } from "./dto/upload-document-v2.dto";
|
||||
import {
|
||||
UploadRequiredDocumentV2Dto,
|
||||
UploadRequiredDocumentV2ResponseDto,
|
||||
} from "./dto/upload-document-v2.dto";
|
||||
import {
|
||||
CapturePartV2Dto,
|
||||
CapturePartV2ResponseDto,
|
||||
@@ -52,7 +67,13 @@ import { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
@Controller("v2/claim-request-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(GlobalGuard, RolesGuard)
|
||||
@Roles(RoleEnum.USER, RoleEnum.FIELD_EXPERT, RoleEnum.REGISTRAR)
|
||||
@Roles(
|
||||
RoleEnum.USER,
|
||||
RoleEnum.FIELD_EXPERT,
|
||||
RoleEnum.REGISTRAR,
|
||||
RoleEnum.FILE_MAKER,
|
||||
RoleEnum.FILE_REVIEWER,
|
||||
)
|
||||
export class ClaimRequestManagementV2Controller {
|
||||
constructor(
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
@@ -142,7 +163,10 @@ export class ClaimRequestManagementV2Controller {
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiResponse({ status: 200, description: "Claim returned to expert queue" })
|
||||
@ApiResponse({ status: 400, description: "Resend requires uploads or wrong step" })
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: "Resend requires uploads or wrong step",
|
||||
})
|
||||
async acknowledgeExpertResend(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() user: any,
|
||||
@@ -156,15 +180,19 @@ export class ClaimRequestManagementV2Controller {
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to acknowledge expert resend",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to acknowledge expert resend",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: User objection after expert resend (same intent as v1 PUT …/request/resend/:id/objection).
|
||||
* Accepts multipart/form-data so optional supporting invoices can be attached in the same request.
|
||||
*/
|
||||
@Put("request/:claimRequestId/objection")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Submit user objection (V2)",
|
||||
description:
|
||||
@@ -172,30 +200,75 @@ export class ClaimRequestManagementV2Controller {
|
||||
"(2) **Legacy resend:** active expert resend (`WAITING_FOR_USER_RESEND` @ `USER_EXPERT_RESEND`).\n\n" +
|
||||
"`objectionParts` may only reference **priced** repair lines (`factorNeeded=false`). Factor-only lines cannot be disputed until they have expert pricing.\n\n" +
|
||||
"After **`damageExpertReplyFinal`** exists (final reply following a prior objection), **no second objection** — owner uses **owner-insurer-approval/sign** to accept/reject and close the case.\n\n" +
|
||||
"Stores `evaluation.objection`, clears partial/final owner approval fields, merges `newParts` into `damage.selectedParts`, returns case to `WAITING_FOR_DAMAGE_EXPERT`.",
|
||||
"Stores `evaluation.objection`, clears partial/final owner approval fields, merges `newParts` into `damage.selectedParts`, returns case to `WAITING_FOR_DAMAGE_EXPERT`.\n\n" +
|
||||
"**Invoices:** optionally attach up to 5 supporting documents (images/PDFs) as `invoices` file fields. Stored in `evaluation.objection.invoices[]` and visible to the reviewing expert.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiBody({ type: UserObjectionV2Dto })
|
||||
@ApiBody({
|
||||
description:
|
||||
"Objection payload as multipart form fields. `objectionParts` and `newParts` are JSON-encoded strings.",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
objectionParts: {
|
||||
type: "string",
|
||||
description:
|
||||
'JSON-encoded array of disputed priced parts. Example: `[{"partId":201,"reason":"Price too high"}]`',
|
||||
},
|
||||
newParts: {
|
||||
type: "string",
|
||||
description:
|
||||
'JSON-encoded array of new parts to add. Example: `[{"partName":"سپر جلو","side":"front"}]`',
|
||||
},
|
||||
invoices: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "binary" },
|
||||
description: "Up to 5 supporting invoice or document files (image or PDF).",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Objection stored" })
|
||||
@ApiResponse({ status: 400, description: "No active resend or empty payload" })
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: "No active resend or empty payload",
|
||||
})
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Objection already submitted" })
|
||||
@UseInterceptors(
|
||||
FilesInterceptor("invoices", 5, {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-objection-invoices",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now() + "-" + Math.round(Math.random() * 1e6);
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `objection-invoice-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
async submitUserObjectionV2(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UserObjectionV2Dto,
|
||||
@CurrentUser() user: any,
|
||||
@UploadedFiles() invoices?: Express.Multer.File[],
|
||||
) {
|
||||
for (const file of invoices ?? []) {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
}
|
||||
try {
|
||||
return await this.claimRequestManagementService.handleUserObjectionV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
user.sub,
|
||||
user,
|
||||
invoices,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
@@ -222,7 +295,10 @@ export class ClaimRequestManagementV2Controller {
|
||||
})
|
||||
@ApiBody({ type: UserRatingDto })
|
||||
@ApiResponse({ status: 200, description: "Rating saved" })
|
||||
@ApiResponse({ status: 400, description: "Claim not completed or invalid scores" })
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: "Claim not completed or invalid scores",
|
||||
})
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Rating already submitted" })
|
||||
@@ -270,21 +346,32 @@ export class ClaimRequestManagementV2Controller {
|
||||
type: "object",
|
||||
required: ["sign", "agree", "branchId"],
|
||||
properties: {
|
||||
sign: { type: "string", format: "binary", description: "Signature image" },
|
||||
sign: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
description: "Signature image",
|
||||
},
|
||||
agree: {
|
||||
type: "boolean",
|
||||
description: "true to accept expert pricing and complete the claim",
|
||||
},
|
||||
branchId: {
|
||||
type: "string",
|
||||
description: "Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
|
||||
description:
|
||||
"Insurer branch id (must belong to the claim owner's insurer; if pricing lists branch options, must match one of them)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Signature stored; claim completed or rejected" })
|
||||
@ApiResponse({ status: 400, description: "Wrong step/status or missing file" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Signature stored; claim completed or rejected",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: "Wrong step/status or missing file",
|
||||
})
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Already signed" })
|
||||
@@ -314,7 +401,9 @@ export class ClaimRequestManagementV2Controller {
|
||||
}
|
||||
await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image");
|
||||
const agreed =
|
||||
typeof agree === "string" ? agree === "true" || agree === "1" : Boolean(agree);
|
||||
typeof agree === "string"
|
||||
? agree === "true" || agree === "1"
|
||||
: Boolean(agree);
|
||||
try {
|
||||
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
|
||||
claimRequestId,
|
||||
@@ -453,8 +542,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
})
|
||||
@ApiBody({
|
||||
type: SelectOuterPartsV2Dto,
|
||||
description:
|
||||
"Selected vehicle type + selected outer part IDs from catalog",
|
||||
description: "Selected vehicle type + selected outer part IDs from catalog",
|
||||
examples: {
|
||||
example1: {
|
||||
summary: "Sedan - minor front damage",
|
||||
@@ -522,9 +610,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to select outer parts",
|
||||
error instanceof Error ? error.message : "Failed to select outer parts",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -638,9 +724,7 @@ Optional: upload car green card file in the same step.
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to select other parts",
|
||||
error instanceof Error ? error.message : "Failed to select other parts",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -862,7 +946,7 @@ Returns status of each item (uploaded/captured or not).
|
||||
type: "string",
|
||||
example: "front",
|
||||
description:
|
||||
'For angle: front/back/left/right. For part: hood/front_bumper/etc.',
|
||||
"For angle: front/back/left/right. For part: hood/front_bumper/etc.",
|
||||
},
|
||||
file: {
|
||||
type: "string",
|
||||
@@ -1005,7 +1089,10 @@ Returns status of each item (uploaded/captured or not).
|
||||
description: "Video uploaded successfully",
|
||||
type: VideoCaptureV2ResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Wrong workflow step or missing file" })
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: "Wrong workflow step or missing file",
|
||||
})
|
||||
@ApiResponse({ status: 403, description: "Not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim not found" })
|
||||
@ApiResponse({ status: 409, description: "Video already uploaded" })
|
||||
@@ -1025,9 +1112,10 @@ Returns status of each item (uploaded/captured or not).
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to upload car capture video",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to upload car capture video",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
/**
|
||||
* V2 DTO for capturing car angle or damaged part
|
||||
*/
|
||||
export class CapturePartV2Dto {
|
||||
@ApiProperty({
|
||||
description: 'Type of capture: angle or part',
|
||||
example: 'angle',
|
||||
enum: ['angle', 'part'],
|
||||
description: "Type of capture: angle or part",
|
||||
example: "angle",
|
||||
enum: ["angle", "part"],
|
||||
})
|
||||
@IsNotEmpty({ message: 'Capture type is required' })
|
||||
@IsEnum(['angle', 'part'], {
|
||||
@IsNotEmpty({ message: "Capture type is required" })
|
||||
@IsEnum(["angle", "part"], {
|
||||
message: 'Capture type must be either "angle" or "part"',
|
||||
})
|
||||
captureType: 'angle' | 'part';
|
||||
captureType: "angle" | "part";
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'When captureType is angle: front | back | left | right. When part: catalog id as string (e.g. "101"), 0-based index (e.g. "0"), or full catalog key (e.g. left_backfender). Prefer id or index for parts.',
|
||||
example: 'front',
|
||||
example: "front",
|
||||
})
|
||||
@IsNotEmpty({ message: 'Capture key is required' })
|
||||
@IsString({ message: 'Capture key must be a string' })
|
||||
@IsNotEmpty({ message: "Capture key is required" })
|
||||
@IsString({ message: "Capture key must be a string" })
|
||||
captureKey: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
description: 'Image file (JPG, PNG)',
|
||||
type: "string",
|
||||
format: "binary",
|
||||
description: "Image file (JPG, PNG)",
|
||||
})
|
||||
file: Express.Multer.File;
|
||||
}
|
||||
@@ -38,51 +38,58 @@ export class CapturePartV2Dto {
|
||||
*/
|
||||
export class CapturePartV2ResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Claim request ID',
|
||||
example: '507f1f77bcf86cd799439011',
|
||||
description: "Claim request ID",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
claimRequestId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Type of capture',
|
||||
example: 'angle',
|
||||
description: "Type of capture",
|
||||
example: "angle",
|
||||
})
|
||||
captureType: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Key of what was captured',
|
||||
example: 'front',
|
||||
description: "Key of what was captured",
|
||||
example: "front",
|
||||
})
|
||||
captureKey: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'File URL',
|
||||
example: 'http://localhost:3000/files/captures/front-1234567890.jpg',
|
||||
description: "File URL",
|
||||
example: "http://localhost:3000/files/captures/front-1234567890.jpg",
|
||||
})
|
||||
fileUrl: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Whether all captures are now complete',
|
||||
description: "Whether all captures are now complete",
|
||||
example: false,
|
||||
})
|
||||
allCapturesComplete: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Current workflow step',
|
||||
example: 'CAPTURE_PART_DAMAGES',
|
||||
description: "Current workflow step",
|
||||
example: "CAPTURE_PART_DAMAGES",
|
||||
})
|
||||
currentStep: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Success message',
|
||||
example: 'Angle captured successfully. 6 captures remaining.',
|
||||
description: "Success message",
|
||||
example: "Angle captured successfully. 6 captures remaining.",
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'True when expert-requested part resends are complete and the claim returned to the expert queue.',
|
||||
description:
|
||||
"True when expert-requested part resends are complete and the claim returned to the expert queue.",
|
||||
})
|
||||
expertResendComplete?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Best-effort Fanavaran attachment upload result. Local capture still succeeds when this contains a warning.",
|
||||
})
|
||||
fanavaranAttachment?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,20 +97,20 @@ export class CapturePartV2ResponseDto {
|
||||
*/
|
||||
export class VideoCaptureV2ResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Claim case ID',
|
||||
example: '507f1f77bcf86cd799439011',
|
||||
description: "Claim case ID",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
claimRequestId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'ID of the stored video document (claim-video-capture)',
|
||||
example: '507f1f77bcf86cd799439012',
|
||||
description: "ID of the stored video document (claim-video-capture)",
|
||||
example: "507f1f77bcf86cd799439012",
|
||||
})
|
||||
videoId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Success message',
|
||||
example: 'Video capture uploaded successfully.',
|
||||
description: "Success message",
|
||||
example: "Video capture uploaded successfully.",
|
||||
})
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
export class CreateClaimFromBlameResponseDto {
|
||||
@ApiProperty({
|
||||
@@ -23,4 +23,10 @@ export class CreateClaimFromBlameResponseDto {
|
||||
example: "Claim request created successfully",
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Best-effort Fanavaran early submit result. Claim creation still succeeds when this contains a warning.",
|
||||
})
|
||||
fanavaran?: unknown;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsEnum, IsNotEmpty, ArrayMinSize, ArrayUnique, IsOptional, IsInt } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
} from "class-validator";
|
||||
import {
|
||||
ClaimVehicleTypeV2,
|
||||
OuterPartSideV2,
|
||||
@@ -12,27 +20,27 @@ import { DamageSelectedPartV2BodyDto } from "./damage-selected-part-v2.dto";
|
||||
*/
|
||||
export enum OuterCarPart {
|
||||
// Hood
|
||||
HOOD = 'hood',
|
||||
HOOD = "hood",
|
||||
|
||||
// Doors
|
||||
FRONT_RIGHT_DOOR = 'front_right_door',
|
||||
FRONT_LEFT_DOOR = 'front_left_door',
|
||||
REAR_RIGHT_DOOR = 'rear_right_door',
|
||||
REAR_LEFT_DOOR = 'rear_left_door',
|
||||
FRONT_RIGHT_DOOR = "front_right_door",
|
||||
FRONT_LEFT_DOOR = "front_left_door",
|
||||
REAR_RIGHT_DOOR = "rear_right_door",
|
||||
REAR_LEFT_DOOR = "rear_left_door",
|
||||
|
||||
// Bumpers
|
||||
FRONT_BUMPER = 'front_bumper',
|
||||
REAR_BUMPER = 'rear_bumper',
|
||||
FRONT_BUMPER = "front_bumper",
|
||||
REAR_BUMPER = "rear_bumper",
|
||||
|
||||
// Fenders
|
||||
FRONT_RIGHT_FENDER = 'front_right_fender',
|
||||
FRONT_LEFT_FENDER = 'front_left_fender',
|
||||
REAR_RIGHT_FENDER = 'rear_right_fender',
|
||||
REAR_LEFT_FENDER = 'rear_left_fender',
|
||||
FRONT_RIGHT_FENDER = "front_right_fender",
|
||||
FRONT_LEFT_FENDER = "front_left_fender",
|
||||
REAR_RIGHT_FENDER = "rear_right_fender",
|
||||
REAR_LEFT_FENDER = "rear_left_fender",
|
||||
|
||||
// Trunk & Roof
|
||||
TRUNK = 'trunk',
|
||||
ROOF = 'roof',
|
||||
TRUNK = "trunk",
|
||||
ROOF = "roof",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,38 +49,38 @@ export enum OuterCarPart {
|
||||
*/
|
||||
export class SelectOuterPartsV2Dto {
|
||||
@ApiProperty({
|
||||
description: 'Array of selected damaged outer car parts',
|
||||
example: ['hood', 'front_right_door', 'rear_bumper', 'roof'],
|
||||
description: "Array of selected damaged outer car parts",
|
||||
example: ["hood", "front_right_door", "rear_bumper", "roof"],
|
||||
enum: OuterCarPart,
|
||||
isArray: true,
|
||||
minItems: 1,
|
||||
maxItems: 13,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'selectedParts must be an array' })
|
||||
@ArrayMinSize(1, { message: 'At least one damaged part must be selected' })
|
||||
@ArrayUnique({ message: 'Duplicate parts are not allowed' })
|
||||
@IsArray({ message: "selectedParts must be an array" })
|
||||
@ArrayMinSize(1, { message: "At least one damaged part must be selected" })
|
||||
@ArrayUnique({ message: "Duplicate parts are not allowed" })
|
||||
@IsEnum(OuterCarPart, {
|
||||
each: true,
|
||||
message: 'Invalid part name. Must be one of the valid outer car parts',
|
||||
message: "Invalid part name. Must be one of the valid outer car parts",
|
||||
})
|
||||
selectedParts?: OuterCarPart[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Selected outer part IDs from catalog',
|
||||
description: "Selected outer part IDs from catalog",
|
||||
example: [9, 10, 4],
|
||||
type: [Number],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray({ message: 'selectedPartIds must be an array' })
|
||||
@ArrayMinSize(1, { message: 'At least one part ID must be selected' })
|
||||
@ArrayUnique({ message: 'Duplicate part IDs are not allowed' })
|
||||
@IsInt({ each: true, message: 'Each selected part ID must be an integer' })
|
||||
@IsArray({ message: "selectedPartIds must be an array" })
|
||||
@ArrayMinSize(1, { message: "At least one part ID must be selected" })
|
||||
@ArrayUnique({ message: "Duplicate part IDs are not allowed" })
|
||||
@IsInt({ each: true, message: "Each selected part ID must be an integer" })
|
||||
selectedPartIds?: number[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Vehicle type for validating available outer parts',
|
||||
description: "Vehicle type for validating available outer parts",
|
||||
enum: ClaimVehicleTypeV2,
|
||||
required: true,
|
||||
})
|
||||
@@ -86,14 +94,14 @@ export class SelectOuterPartsV2Dto {
|
||||
*/
|
||||
export class SelectOuterPartsV2ResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Claim request ID',
|
||||
example: '507f1f77bcf86cd799439011',
|
||||
description: "Claim request ID",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
claimRequestId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Public ID shared across blame and claim',
|
||||
example: 'A14235',
|
||||
description: "Public ID shared across blame and claim",
|
||||
example: "A14235",
|
||||
})
|
||||
publicId: string;
|
||||
|
||||
@@ -105,29 +113,36 @@ export class SelectOuterPartsV2ResponseDto {
|
||||
selectedParts: DamageSelectedPartV2BodyDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Selected part IDs',
|
||||
description: "Selected part IDs",
|
||||
example: [9, 7, 11],
|
||||
type: [Number],
|
||||
})
|
||||
selectedPartIds: number[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Current workflow step',
|
||||
example: 'SELECT_OUTER_PARTS',
|
||||
description: "Current workflow step",
|
||||
example: "SELECT_OUTER_PARTS",
|
||||
})
|
||||
currentStep: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Next possible workflow step',
|
||||
example: 'SELECT_OTHER_PARTS',
|
||||
description: "Next possible workflow step",
|
||||
example: "SELECT_OTHER_PARTS",
|
||||
})
|
||||
nextStep: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Success message',
|
||||
example: 'Outer parts selected successfully. Please proceed to select other parts.',
|
||||
description: "Success message",
|
||||
example:
|
||||
"Outer parts selected successfully. Please proceed to select other parts.",
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Best-effort Fanavaran damage-case submit result. Selecting parts still succeeds when this contains a warning.",
|
||||
})
|
||||
fanavaranDamageCase?: unknown;
|
||||
}
|
||||
|
||||
export class SetClaimVehicleTypeV2Dto {
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { ClaimRequiredDocumentType } from 'src/Types&Enums/claim-request-management/required-document-type.enum';
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
|
||||
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||
|
||||
/**
|
||||
* V2 DTO for uploading required document
|
||||
*/
|
||||
export class UploadRequiredDocumentV2Dto {
|
||||
@ApiProperty({
|
||||
description: 'Document type/key',
|
||||
example: 'car_green_card',
|
||||
description: "Document type/key",
|
||||
example: "car_green_card",
|
||||
enum: ClaimRequiredDocumentType,
|
||||
})
|
||||
@IsNotEmpty({ message: 'Document key is required' })
|
||||
@IsNotEmpty({ message: "Document key is required" })
|
||||
@IsEnum(ClaimRequiredDocumentType, {
|
||||
message: 'Invalid document type',
|
||||
message: "Invalid document type",
|
||||
})
|
||||
documentKey: ClaimRequiredDocumentType;
|
||||
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
description: 'Image file (JPG, PNG, PDF)',
|
||||
type: "string",
|
||||
format: "binary",
|
||||
description: "Image file (JPG, PNG, PDF)",
|
||||
})
|
||||
file: Express.Multer.File;
|
||||
}
|
||||
@@ -30,43 +30,51 @@ export class UploadRequiredDocumentV2Dto {
|
||||
*/
|
||||
export class UploadRequiredDocumentV2ResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Claim request ID',
|
||||
example: '507f1f77bcf86cd799439011',
|
||||
description: "Claim request ID",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
claimRequestId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Document key that was uploaded',
|
||||
example: 'car_green_card',
|
||||
description: "Document key that was uploaded",
|
||||
example: "car_green_card",
|
||||
})
|
||||
documentKey: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'File URL',
|
||||
example: 'http://localhost:3000/files/documents/car-green-card-1234567890.jpg',
|
||||
description: "File URL",
|
||||
example:
|
||||
"http://localhost:3000/files/documents/car-green-card-1234567890.jpg",
|
||||
})
|
||||
fileUrl: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Whether all required documents are now uploaded',
|
||||
description: "Whether all required documents are now uploaded",
|
||||
example: false,
|
||||
})
|
||||
allDocumentsUploaded: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Current workflow step',
|
||||
example: 'UPLOAD_REQUIRED_DOCUMENTS',
|
||||
description: "Current workflow step",
|
||||
example: "UPLOAD_REQUIRED_DOCUMENTS",
|
||||
})
|
||||
currentStep: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Success message',
|
||||
example: 'Document uploaded successfully. 12 documents remaining.',
|
||||
description: "Success message",
|
||||
example: "Document uploaded successfully. 12 documents remaining.",
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'True when the owner finished every damage-expert resend requirement and the claim is back in the expert queue.',
|
||||
description:
|
||||
"True when the owner finished every damage-expert resend requirement and the claim is back in the expert queue.",
|
||||
})
|
||||
expertResendComplete?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Best-effort Fanavaran attachment upload result. Local document upload still succeeds when this contains a warning.",
|
||||
})
|
||||
fanavaranAttachment?: unknown;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { Type, Transform } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsOptional,
|
||||
@@ -10,23 +10,52 @@ import { HasObjectionEntriesConstraint } from "src/common/validators/has-objecti
|
||||
import { NewPartDto, UserObjectionPartDto } from "./user-objection.dto";
|
||||
|
||||
/**
|
||||
* V2 user objection body — same shape as v1 {@link UserObjectionDto}
|
||||
* with nested validation enabled for the v2 controller pipeline.
|
||||
* V2 user objection body — submitted as multipart/form-data.
|
||||
* `objectionParts` and `newParts` are JSON-encoded strings in the form fields.
|
||||
* Optional `invoices` files are uploaded as a `invoices` file array.
|
||||
*/
|
||||
export class UserObjectionV2Dto {
|
||||
@ApiPropertyOptional({ type: [UserObjectionPartDto] })
|
||||
@ApiPropertyOptional({
|
||||
type: [UserObjectionPartDto],
|
||||
description:
|
||||
"JSON-encoded array of disputed priced parts. Pass as a JSON string in the multipart field.",
|
||||
})
|
||||
@Validate(HasObjectionEntriesConstraint)
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UserObjectionPartDto)
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
})
|
||||
objectionParts?: UserObjectionPartDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [NewPartDto] })
|
||||
@ApiPropertyOptional({
|
||||
type: [NewPartDto],
|
||||
description:
|
||||
"JSON-encoded array of new parts to add. Pass as a JSON string in the multipart field.",
|
||||
})
|
||||
@Validate(HasObjectionEntriesConstraint)
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => NewPartDto)
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
})
|
||||
newParts?: NewPartDto[];
|
||||
}
|
||||
|
||||
@@ -180,6 +180,27 @@ export class ClaimUserObjectionNewPart {
|
||||
export const ClaimUserObjectionNewPartSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjectionNewPart);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Objection invoice (supporting document uploaded alongside the objection)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class ClaimObjectionInvoice {
|
||||
@Prop({ type: Types.ObjectId, default: () => new Types.ObjectId() })
|
||||
fileId: Types.ObjectId;
|
||||
|
||||
@Prop({ type: String, required: true })
|
||||
path: string;
|
||||
|
||||
@Prop({ type: String, required: true })
|
||||
fileName: string;
|
||||
|
||||
@Prop({ type: Date, default: () => new Date() })
|
||||
uploadedAt: Date;
|
||||
}
|
||||
export const ClaimObjectionInvoiceSchema =
|
||||
SchemaFactory.createForClass(ClaimObjectionInvoice);
|
||||
|
||||
/**
|
||||
* Full user objection payload (matches v1 DTO: objectionParts + newParts).
|
||||
* Stored on {@link ClaimEvaluation.objection}.
|
||||
@@ -194,6 +215,10 @@ export class ClaimUserObjectionPayload {
|
||||
|
||||
@Prop({ type: Date, default: () => new Date() })
|
||||
submittedAt?: Date;
|
||||
|
||||
/** Optional supporting invoices uploaded at objection time. */
|
||||
@Prop({ type: [ClaimObjectionInvoiceSchema], default: [] })
|
||||
invoices?: ClaimObjectionInvoice[];
|
||||
}
|
||||
export const ClaimUserObjectionPayloadSchema =
|
||||
SchemaFactory.createForClass(ClaimUserObjectionPayload);
|
||||
|
||||
@@ -57,7 +57,7 @@ export class ClaimWorkflow {
|
||||
@Prop({ type: Date })
|
||||
lockedAt?: Date;
|
||||
|
||||
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
||||
/** Lock expiry used by UI countdown (typically lockedAt + 30m). */
|
||||
@Prop({ type: Date })
|
||||
expiredAt?: Date;
|
||||
|
||||
@@ -68,7 +68,7 @@ export class ClaimWorkflow {
|
||||
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
|
||||
|
||||
/**
|
||||
* First damage expert who called review assign; kept after the 15m lock expires
|
||||
* First damage expert who called review assign; kept after the 30m lock expires
|
||||
* so no other expert can take the case while it remains in the expert queue.
|
||||
*/
|
||||
@Prop({ type: ClaimActorLockSchema })
|
||||
|
||||
@@ -58,6 +58,64 @@ export class RequiredDocumentRef {
|
||||
export const RequiredDocumentRefSchema =
|
||||
SchemaFactory.createForClass(RequiredDocumentRef);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class FanavaranSyncStage {
|
||||
@Prop({ type: String })
|
||||
status?: "pending" | "success" | "failed" | "skipped";
|
||||
|
||||
@Prop({ type: Date })
|
||||
lastTriedAt?: Date;
|
||||
|
||||
@Prop({ type: String })
|
||||
lastError?: string;
|
||||
|
||||
@Prop({ type: Number })
|
||||
claimId?: number;
|
||||
|
||||
@Prop({ type: Number })
|
||||
claimNo?: number;
|
||||
|
||||
@Prop({ type: Number })
|
||||
dmgCaseId?: number;
|
||||
|
||||
@Prop({ type: Number })
|
||||
expertiseId?: number;
|
||||
|
||||
@Prop({ type: [MongooseSchema.Types.Mixed], default: [] })
|
||||
files?: unknown[];
|
||||
|
||||
@Prop({ type: MongooseSchema.Types.Mixed })
|
||||
response?: unknown;
|
||||
|
||||
@Prop({ type: Number, default: 0 })
|
||||
retryCount?: number;
|
||||
|
||||
@Prop({ type: Number, default: 2 })
|
||||
maxRetries?: number;
|
||||
|
||||
@Prop({ type: Date })
|
||||
nextRetryAt?: Date;
|
||||
}
|
||||
export const FanavaranSyncStageSchema =
|
||||
SchemaFactory.createForClass(FanavaranSyncStage);
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class FanavaranSyncState {
|
||||
@Prop({ type: FanavaranSyncStageSchema })
|
||||
baseClaim?: FanavaranSyncStage;
|
||||
|
||||
@Prop({ type: FanavaranSyncStageSchema })
|
||||
damageCase?: FanavaranSyncStage;
|
||||
|
||||
@Prop({ type: FanavaranSyncStageSchema })
|
||||
attachments?: FanavaranSyncStage;
|
||||
|
||||
@Prop({ type: FanavaranSyncStageSchema })
|
||||
expertise?: FanavaranSyncStage;
|
||||
}
|
||||
export const FanavaranSyncStateSchema =
|
||||
SchemaFactory.createForClass(FanavaranSyncState);
|
||||
|
||||
@Schema({
|
||||
collection: "claimCases",
|
||||
timestamps: true,
|
||||
@@ -142,6 +200,15 @@ export class ClaimCase {
|
||||
@Prop({ type: Number })
|
||||
claimId?: number;
|
||||
|
||||
@Prop({ type: Number })
|
||||
dmgCaseId?: number;
|
||||
|
||||
@Prop({ type: Number })
|
||||
expertiseId?: number;
|
||||
|
||||
@Prop({ type: FanavaranSyncStateSchema, default: () => ({}) })
|
||||
fanavaranSync?: FanavaranSyncState;
|
||||
|
||||
@Prop({ type: ClaimDamageSelectionSchema, default: () => ({}) })
|
||||
damage?: ClaimDamageSelection;
|
||||
|
||||
@@ -184,6 +251,20 @@ export class ClaimCase {
|
||||
@Prop({ type: Types.ObjectId, index: true })
|
||||
createdByRegistrarId?: Types.ObjectId;
|
||||
|
||||
/**
|
||||
* V5 split flow: when true, the claim must be approved by the FileMaker
|
||||
* who created the file before fanavaran submission is allowed.
|
||||
*/
|
||||
@Prop({ type: Boolean, default: false })
|
||||
requiresFileMakerApproval?: boolean;
|
||||
|
||||
/**
|
||||
* V5 split flow: ObjectId of the FileMaker who must approve this claim.
|
||||
* Set when the FileReviewer uploads the blame accident video in the V5 flow.
|
||||
*/
|
||||
@Prop({ type: Types.ObjectId, index: true })
|
||||
fileMakerApprovalActorId?: Types.ObjectId;
|
||||
|
||||
/**
|
||||
* Legacy fields kept optional to simplify progressive migration.
|
||||
* If you choose to migrate later, we can remove these.
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
InternalServerErrorException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
@@ -516,6 +519,73 @@ Returns status of each item (uploaded/captured or not).
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Owner signature on expert pricing ───────────────────────────────────────
|
||||
|
||||
@Put("claim-sign/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiBody({
|
||||
description: "Signature file, agreement, and branch",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["sign", "agree", "branchId"],
|
||||
properties: {
|
||||
sign: { type: "string", format: "binary", description: "Signature image" },
|
||||
agree: { type: "boolean", description: "true to accept, false to reject" },
|
||||
branchId: { type: "string", description: "Insurer branch ID" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Owner signature on expert pricing (Flow 3 — expert acts on behalf of user)",
|
||||
description:
|
||||
"Field expert submits the damaged party's signature during the final approval stage. " +
|
||||
"Delegates to the same service method as the user sign endpoint; the expert's " +
|
||||
"identity is resolved to the claim owner via `resolveClaimEffectiveUserId`.",
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("sign", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-sign",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
const base = file.originalname.split(/[.,\s-]/)[0] || "sign";
|
||||
callback(null, `${base}-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
async submitOwnerSign(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body("agree") agree: string | boolean,
|
||||
@Body("branchId") branchId: string,
|
||||
@CurrentUser() expert: any,
|
||||
@UploadedFile() sign: Express.Multer.File,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image");
|
||||
const agreed =
|
||||
typeof agree === "string"
|
||||
? agree === "true" || agree === "1"
|
||||
: Boolean(agree);
|
||||
try {
|
||||
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
|
||||
claimRequestId,
|
||||
agreed,
|
||||
typeof branchId === "string" ? branchId : "",
|
||||
sign,
|
||||
expert.sub,
|
||||
expert,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to submit signature",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Patch("car-capture/:claimRequestId")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { selectLatestActiveFanavaranPolicy } from "./fanavaran-policy-selection";
|
||||
|
||||
describe("selectLatestActiveFanavaranPolicy", () => {
|
||||
const today = "2026-06-30";
|
||||
|
||||
it("selects the policy with the latest EndDate when newest is first", () => {
|
||||
const selected = selectLatestActiveFanavaranPolicy(
|
||||
[
|
||||
{ PolicyId: 4826286, EndDate: "1405/09/23" },
|
||||
{ PolicyId: 3719458, EndDate: "1404/09/06" },
|
||||
{ PolicyId: 2800731, EndDate: "1403/09/05" },
|
||||
],
|
||||
today,
|
||||
);
|
||||
|
||||
expect(selected.policyId).toBe(4826286);
|
||||
expect(selected.endDate).toBe("1405/09/23");
|
||||
});
|
||||
|
||||
it("selects the policy with the latest EndDate when newest is last", () => {
|
||||
const selected = selectLatestActiveFanavaranPolicy(
|
||||
[
|
||||
{ PolicyId: 2800731, EndDate: "1403/09/05" },
|
||||
{ PolicyId: 3719458, EndDate: "1404/09/06" },
|
||||
{ PolicyId: 4826286, EndDate: "1405/09/23" },
|
||||
],
|
||||
today,
|
||||
);
|
||||
|
||||
expect(selected.policyId).toBe(4826286);
|
||||
});
|
||||
|
||||
it("rejects when no policies are returned", () => {
|
||||
expect(() => selectLatestActiveFanavaranPolicy([], today)).toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when the latest policy is expired", () => {
|
||||
expect(() =>
|
||||
selectLatestActiveFanavaranPolicy(
|
||||
[
|
||||
{ PolicyId: 3719458, EndDate: "1404/09/06" },
|
||||
{ PolicyId: 2800731, EndDate: "1403/09/05" },
|
||||
],
|
||||
today,
|
||||
),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("rejects when the latest policy has no valid PolicyId", () => {
|
||||
expect(() =>
|
||||
selectLatestActiveFanavaranPolicy(
|
||||
[
|
||||
{ PolicyId: 3719458, EndDate: "1404/09/06" },
|
||||
{ PolicyId: null, EndDate: "1405/09/23" },
|
||||
],
|
||||
today,
|
||||
),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
95
src/claim-request-management/fanavaran-policy-selection.ts
Normal file
95
src/claim-request-management/fanavaran-policy-selection.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { jalaliToGregorianDate } from "src/helpers/date-jalali";
|
||||
import { gregorianDateInIran } from "src/helpers/iran-datetime";
|
||||
|
||||
export type FanavaranPolicyInquiryRow = {
|
||||
PolicyId?: unknown;
|
||||
EndDate?: unknown;
|
||||
};
|
||||
|
||||
export type SelectedFanavaranPolicy = {
|
||||
policy: FanavaranPolicyInquiryRow;
|
||||
policyId: number;
|
||||
endDate: string;
|
||||
endDateGregorian: string;
|
||||
};
|
||||
|
||||
type DatedFanavaranPolicy = {
|
||||
policy: FanavaranPolicyInquiryRow;
|
||||
endDate: string;
|
||||
endDateGregorian: string;
|
||||
};
|
||||
|
||||
const NO_POLICY_MESSAGE =
|
||||
"No Fanavaran policies were found for the insurer national code. PolicyId is required; contact the administrator.";
|
||||
|
||||
const EXPIRED_POLICY_MESSAGE =
|
||||
"The latest insurance policy is expired and cannot be sent to Fanavaran. Contact the administrator.";
|
||||
|
||||
const INVALID_POLICY_MESSAGE =
|
||||
"Fanavaran policy inquiry returned policies without a valid PolicyId or EndDate. PolicyId is required; contact the administrator.";
|
||||
|
||||
function parsePolicyId(value: unknown): number | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value === "string" && value.trim() === "") return null;
|
||||
const id = Number(value);
|
||||
return Number.isFinite(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
function normalizeEndDate(value: unknown): {
|
||||
endDate: string;
|
||||
endDateGregorian: string;
|
||||
} | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const endDate = String(value).trim();
|
||||
if (!endDate) return null;
|
||||
|
||||
const endDateGregorian = jalaliToGregorianDate(endDate);
|
||||
if (!endDateGregorian) return null;
|
||||
|
||||
return { endDate, endDateGregorian };
|
||||
}
|
||||
|
||||
export function selectLatestActiveFanavaranPolicy(
|
||||
policies: unknown,
|
||||
todayGregorian: string = gregorianDateInIran(new Date()),
|
||||
): SelectedFanavaranPolicy {
|
||||
if (!Array.isArray(policies) || policies.length === 0) {
|
||||
throw new BadRequestException(NO_POLICY_MESSAGE);
|
||||
}
|
||||
|
||||
const candidates = policies
|
||||
.map((policy) => {
|
||||
if (!policy || typeof policy !== "object") return null;
|
||||
const row = policy as FanavaranPolicyInquiryRow;
|
||||
const endDate = normalizeEndDate(row.EndDate);
|
||||
if (!endDate) return null;
|
||||
return {
|
||||
policy: row,
|
||||
endDate: endDate.endDate,
|
||||
endDateGregorian: endDate.endDateGregorian,
|
||||
};
|
||||
})
|
||||
.filter((policy): policy is DatedFanavaranPolicy => policy !== null);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new BadRequestException(INVALID_POLICY_MESSAGE);
|
||||
}
|
||||
|
||||
const latest = candidates.reduce((currentLatest, candidate) =>
|
||||
candidate.endDateGregorian > currentLatest.endDateGregorian
|
||||
? candidate
|
||||
: currentLatest,
|
||||
);
|
||||
|
||||
if (latest.endDateGregorian < todayGregorian) {
|
||||
throw new BadRequestException(EXPIRED_POLICY_MESSAGE);
|
||||
}
|
||||
|
||||
const policyId = parsePolicyId(latest.policy.PolicyId);
|
||||
if (policyId === null) {
|
||||
throw new BadRequestException(INVALID_POLICY_MESSAGE);
|
||||
}
|
||||
|
||||
return { ...latest, policyId };
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
import { Body, Controller, Get, Patch, Post } from "@nestjs/common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { SuperAdminGuard } from "src/super-admin/guards/super-admin.guard";
|
||||
import { SystemSettingsResponseDto } from "src/system-settings/dto/system-settings.dto";
|
||||
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||
import { ClientService } from "./client.service";
|
||||
@@ -21,26 +30,35 @@ export class ClientController {
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: "Create a new insurer client (super-admin only)" })
|
||||
async addClient(@Body() client: ClientDto) {
|
||||
return await this.clientService.addClient(client);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async getClient(@CurrentUser() user) {
|
||||
return await this.clientService.getClients();
|
||||
}
|
||||
|
||||
@Get("list")
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiBearerAuth()
|
||||
async getClientList(@CurrentUser() user) {
|
||||
return await this.clientService.getClientList();
|
||||
}
|
||||
|
||||
/** Toggle SandHub/Tejarat live HTTP vs mock inquiries (`system_settings.externalApis.sandHubUseLiveApi`). */
|
||||
@Patch("external-inquiries-live")
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: "Enable or disable live external inquiries",
|
||||
summary: "Enable or disable live external inquiries (super-admin only)",
|
||||
description:
|
||||
"Updates `system_settings.externalApis.sandHubUseLiveApi`. No auth required. Use the request examples below to switch between live Tejarat/SandHub HTTP and offline mock mode.",
|
||||
"Updates `system_settings.externalApis.sandHubUseLiveApi`. Use the request examples below to switch between live Tejarat/SandHub HTTP and offline mock mode.",
|
||||
})
|
||||
@ApiBody({
|
||||
type: SetExternalInquiriesLiveDto,
|
||||
@@ -52,7 +70,8 @@ export class ClientController {
|
||||
},
|
||||
disableLive: {
|
||||
summary: "Disable live inquiries (mock mode)",
|
||||
description: "Use mocked inquiry responses; flows continue without external connectivity.",
|
||||
description:
|
||||
"Use mocked inquiry responses; flows continue without external connectivity.",
|
||||
value: { enabled: false },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -49,6 +49,14 @@ export class ExternalInquiryFlagsDto implements ExternalInquiryFlags {
|
||||
})
|
||||
@IsBoolean()
|
||||
carOwnership: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"ESG VIN/chassis-number inquiry (`/inquiry/policyByChassis`). Required for the VIN initial-form path.",
|
||||
example: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
vinChassis: boolean;
|
||||
}
|
||||
|
||||
export class UpdateClientExternalInquiriesDto extends PartialType(
|
||||
|
||||
@@ -20,7 +20,9 @@ export class AuthGuard implements CanActivate {
|
||||
}
|
||||
try {
|
||||
const payload: JwtPayload =
|
||||
await this.jwtService.verifyAsync<JwtPayload>(token);
|
||||
await this.jwtService.verifyAsync<JwtPayload>(token, {
|
||||
secret: `${process.env.JWT_SECRET}`,
|
||||
});
|
||||
request["user"] = payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException("Invalid or expired token");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Type } from "class-transformer";
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
@@ -102,4 +103,20 @@ export class ListQueryV2Dto {
|
||||
@IsOptional()
|
||||
@IsIn([...LIST_FILE_TYPE_V2])
|
||||
fileType?: ListFileTypeV2;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Filter start date (ISO 8601). Only files created on or after this date are returned.",
|
||||
example: "2025-01-01T00:00:00.000Z",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: false })
|
||||
startDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Filter end date (ISO 8601). Only files created on or before this date are returned.",
|
||||
example: "2025-12-31T23:59:59.999Z",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: false })
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const EXTERNAL_INQUIRY_TYPES = [
|
||||
"sheba",
|
||||
"drivingLicense",
|
||||
"carOwnership",
|
||||
"vinChassis",
|
||||
] as const;
|
||||
|
||||
export type ExternalInquiryType = (typeof EXTERNAL_INQUIRY_TYPES)[number];
|
||||
@@ -21,6 +22,7 @@ export const DEFAULT_EXTERNAL_INQUIRY_FLAGS: Record<
|
||||
sheba: false,
|
||||
drivingLicense: false,
|
||||
carOwnership: false,
|
||||
vinChassis: false,
|
||||
};
|
||||
|
||||
export type ExternalInquiryFlags = Record<ExternalInquiryType, boolean>;
|
||||
|
||||
@@ -2,12 +2,10 @@ import {
|
||||
IsBooleanString,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsNumberString,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Length,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
|
||||
@@ -5,7 +5,9 @@ export const FANAVARAN_CLIENT_KEYS: readonly FanavaranClientKey[] = [
|
||||
"tejaratno",
|
||||
] as const;
|
||||
|
||||
export function isFanavaranClientKey(value: string): value is FanavaranClientKey {
|
||||
export function isFanavaranClientKey(
|
||||
value: string,
|
||||
): value is FanavaranClientKey {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "parsian" || normalized === "tejaratno";
|
||||
}
|
||||
@@ -35,9 +37,17 @@ export interface FanavaranPayloadDefaults {
|
||||
AccidentReportTypeId: number;
|
||||
AccidentVehicleUsedId: number;
|
||||
ClaimExpertId: number;
|
||||
ExpertiseClaimExpertId: number;
|
||||
CompensationReferenceId: number;
|
||||
CulpritLicenceTypeId: number;
|
||||
CulpritTypeId: number;
|
||||
DmgCaseTypeId: number;
|
||||
DmgHistoryStatus: number;
|
||||
PlaqueKindId: number;
|
||||
PlaqueSampleId: number;
|
||||
DriverIsOwner: number;
|
||||
FaultPercent: number;
|
||||
ClaimFileTypeId: number;
|
||||
}
|
||||
|
||||
export interface FanavaranClientProfile {
|
||||
@@ -65,10 +75,18 @@ const FANAVARAN_CLIENT_PROFILES: Record<
|
||||
AccidentCityId: 701,
|
||||
AccidentReportTypeId: 155,
|
||||
AccidentVehicleUsedId: 1,
|
||||
ClaimExpertId: 1589,
|
||||
ClaimExpertId: 4543092,
|
||||
ExpertiseClaimExpertId: 4543092,
|
||||
CompensationReferenceId: 167,
|
||||
CulpritLicenceTypeId: 2,
|
||||
CulpritTypeId: 337,
|
||||
DmgCaseTypeId: 175,
|
||||
DmgHistoryStatus: 5214,
|
||||
PlaqueKindId: 8,
|
||||
PlaqueSampleId: 10,
|
||||
DriverIsOwner: 0,
|
||||
FaultPercent: 100,
|
||||
ClaimFileTypeId: 23,
|
||||
},
|
||||
},
|
||||
parsian: {
|
||||
@@ -87,9 +105,17 @@ const FANAVARAN_CLIENT_PROFILES: Record<
|
||||
AccidentReportTypeId: 155,
|
||||
AccidentVehicleUsedId: 1,
|
||||
ClaimExpertId: 154,
|
||||
ExpertiseClaimExpertId: 29,
|
||||
CompensationReferenceId: 167,
|
||||
CulpritLicenceTypeId: 2,
|
||||
CulpritTypeId: 337,
|
||||
DmgCaseTypeId: 175,
|
||||
DmgHistoryStatus: 5214,
|
||||
PlaqueKindId: 8,
|
||||
PlaqueSampleId: 10,
|
||||
DriverIsOwner: 0,
|
||||
FaultPercent: 100,
|
||||
ClaimFileTypeId: 70,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -127,14 +153,14 @@ export function fanavaranPreviewPath(
|
||||
clientKey: FanavaranClientKey,
|
||||
claimCaseId: string,
|
||||
): string {
|
||||
return `/v2/fanavaran/${clientKey}/claim-cases/${claimCaseId}`;
|
||||
return `/v2/fanavaran/${clientKey}/claim-cases/${claimCaseId}/base-claim/preview`;
|
||||
}
|
||||
|
||||
export function fanavaranSubmitPath(
|
||||
clientKey: FanavaranClientKey,
|
||||
claimCaseId: string,
|
||||
): string {
|
||||
return `/v2/fanavaran/${clientKey}/claim-cases/${claimCaseId}`;
|
||||
return `/v2/fanavaran/${clientKey}/claim-cases/${claimCaseId}/base-claim/submit`;
|
||||
}
|
||||
|
||||
export function fanavaranManualSubmitPath(claimCaseId: string): string {
|
||||
|
||||
26
src/core/config/http-proxy.factory.ts
Normal file
26
src/core/config/http-proxy.factory.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { HttpModuleOptions } from "@nestjs/axios";
|
||||
import { SocksProxyAgent } from "socks-proxy-agent";
|
||||
|
||||
/**
|
||||
* Shared HttpModule.registerAsync factory that applies the SOCKS proxy
|
||||
* when SOCKS_PROXY_HOST + SOCKS_PROXY_PORT env vars are set.
|
||||
* Used by every module that imports HttpModule.
|
||||
*/
|
||||
export function createHttpModuleOptions(
|
||||
configService: ConfigService,
|
||||
): HttpModuleOptions | Promise<HttpModuleOptions> {
|
||||
const socksHost = configService.get<string>("SOCKS_PROXY_HOST");
|
||||
const socksPort = configService.get<string>("SOCKS_PROXY_PORT");
|
||||
|
||||
if (socksHost && socksPort) {
|
||||
const proxyUrl = `socks5://${socksHost}:${socksPort}`;
|
||||
const agent = new SocksProxyAgent(proxyUrl);
|
||||
return {
|
||||
httpsAgent: agent,
|
||||
httpAgent: agent,
|
||||
proxy: false,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -121,6 +121,12 @@ export class AllRequestDtoV2 {
|
||||
unifiedFileStatus?: string;
|
||||
partiesInitialForms: { firstParty: string; secondParty: string };
|
||||
partiesVehicles: { firstPartyVehicle: string; secondPartyVehicle: string };
|
||||
/**
|
||||
* True when the file is in WAITING_FOR_FILE_REVIEWER status — the FileReviewer
|
||||
* must complete accident fields, capture, and upload-video via v4 endpoints
|
||||
* before the file enters the normal expert-blame review queue.
|
||||
*/
|
||||
needsFileReviewerCompletion?: boolean;
|
||||
}
|
||||
|
||||
export class AllRequestDtoRsV2 {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
assertBlameCaseForExpertTenant,
|
||||
blameCaseAccessibleToExpert,
|
||||
blameCaseInitiatedByFieldExpert,
|
||||
blameCaseTouchesClient,
|
||||
requireActorClientKey,
|
||||
} from "src/helpers/tenant-scope";
|
||||
import {
|
||||
@@ -46,6 +47,10 @@ import {
|
||||
buildExpertAssignConflictForOtherReviewer,
|
||||
resolvePersistentReviewAssigneeId,
|
||||
} from "src/helpers/expert-workflow-review-assignee";
|
||||
import {
|
||||
EXPERT_WORKFLOW_LOCK_TTL_MS,
|
||||
expertWorkflowLockExpiredAt,
|
||||
} from "src/helpers/expert-workflow-lock";
|
||||
import { RequestManagementDbService } from "src/request-management/entities/db-service/request-management.db.service";
|
||||
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
|
||||
import {
|
||||
@@ -111,9 +116,6 @@ function statementToFormKey(statement?: {
|
||||
export class ExpertBlameService {
|
||||
private readonly logger = new Logger(ExpertBlameService.name);
|
||||
|
||||
/** Blame V2 `workflow.locked` TTL (must match checks in lock/reply/resend). */
|
||||
private readonly blameV2LockTtlMs = 15 * 60 * 1000;
|
||||
|
||||
constructor(
|
||||
private readonly requestManagementDbService: RequestManagementDbService,
|
||||
private readonly blameRequestDbService: BlameRequestDbService,
|
||||
@@ -437,6 +439,9 @@ export class ExpertBlameService {
|
||||
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
||||
return this.getFieldExpertBlameListV2(actor, query);
|
||||
}
|
||||
if (actor.role === RoleEnum.FILE_REVIEWER) {
|
||||
return this.getFileReviewerBlameListV2(actor, query);
|
||||
}
|
||||
requireActorClientKey(actor);
|
||||
const expertId = actor.sub;
|
||||
|
||||
@@ -578,6 +583,44 @@ export class ExpertBlameService {
|
||||
return pagedResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blame inbox for FILE_REVIEWER — all expert-initiated IN_PERSON files that
|
||||
* have been sealed by a FileMaker (WAITING_FOR_FILE_REVIEWER) and belong to
|
||||
* this reviewer's insurance company (clientKey).
|
||||
*
|
||||
* Once the FileReviewer completes their steps the file moves to
|
||||
* WAITING_FOR_EXPERT (via upload-video), after which it is visible in the
|
||||
* standard expert-blame panel as usual.
|
||||
*/
|
||||
private async getFileReviewerBlameListV2(
|
||||
actor: { sub: string; clientKey?: string },
|
||||
query: ListQueryV2Dto = {},
|
||||
): Promise<AllRequestDtoRsV2> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
|
||||
const allSealed = (await this.blameRequestDbService.find(
|
||||
{
|
||||
expertInitiated: true,
|
||||
status: {
|
||||
$in: [
|
||||
CaseStatus.WAITING_FOR_FILE_REVIEWER,
|
||||
CaseStatus.WAITING_FOR_EXPERT,
|
||||
CaseStatus.COMPLETED,
|
||||
],
|
||||
},
|
||||
},
|
||||
{ lean: true },
|
||||
)) as Record<string, unknown>[];
|
||||
|
||||
// Scope to this reviewer's insurance company via blame party clientId
|
||||
const visibleCases = allSealed.filter((doc) =>
|
||||
blameCaseTouchesClient(doc, clientKey),
|
||||
);
|
||||
|
||||
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
|
||||
return pagedResult;
|
||||
}
|
||||
|
||||
private async paginateBlameListV2(
|
||||
visibleCases: Record<string, unknown>[],
|
||||
query: ListQueryV2Dto,
|
||||
@@ -587,11 +630,21 @@ export class ExpertBlameService {
|
||||
String((d as { publicId?: string }).publicId ?? ""),
|
||||
),
|
||||
);
|
||||
const filtered = this.filterBlameDocsByUnifiedStatus(
|
||||
let filtered = this.filterBlameDocsByUnifiedStatus(
|
||||
visibleCases,
|
||||
claimByPublicId,
|
||||
query.unifiedStatus,
|
||||
);
|
||||
const { fromDate, toDate } = parseListDateRange(query.startDate, query.endDate);
|
||||
if (fromDate || toDate) {
|
||||
filtered = filtered.filter((doc) =>
|
||||
isInListDateRange(
|
||||
(doc as { createdAt?: Date }).createdAt,
|
||||
fromDate,
|
||||
toDate,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const paged = applyListQueryV2(
|
||||
filtered,
|
||||
@@ -726,6 +779,10 @@ export class ExpertBlameService {
|
||||
secondParty?.vehicle?.inquiry?.mapped?.CarName ||
|
||||
"",
|
||||
},
|
||||
needsFileReviewerCompletion:
|
||||
String(doc.status ?? "") === CaseStatus.WAITING_FOR_FILE_REVIEWER ||
|
||||
(!!(doc as any).isMadeByFileMaker &&
|
||||
String(doc.status ?? "") === CaseStatus.WAITING_FOR_EXPERT),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -745,11 +802,11 @@ export class ExpertBlameService {
|
||||
}
|
||||
const la = doc.workflow.lockedAt;
|
||||
if (!la) return true;
|
||||
return Date.now() < new Date(la as Date).getTime() + this.blameV2LockTtlMs;
|
||||
return Date.now() < new Date(la as Date).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist unlock when blame V2 workflow lock is older than {@link blameV2LockTtlMs}.
|
||||
* Persist unlock when blame V2 workflow lock is older than {@link EXPERT_WORKFLOW_LOCK_TTL_MS}.
|
||||
* V1 uses in-memory `scheduleUnlock`; V2 only stored `workflow.lockedAt`, so locks never cleared until now.
|
||||
*/
|
||||
private async expireBlameCaseWorkflowLockV2IfStale(
|
||||
@@ -767,7 +824,7 @@ export class ExpertBlameService {
|
||||
}
|
||||
if (
|
||||
lockedAt &&
|
||||
Date.now() < new Date(lockedAt as Date).getTime() + this.blameV2LockTtlMs
|
||||
Date.now() < new Date(lockedAt as Date).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -1246,7 +1303,7 @@ export class ExpertBlameService {
|
||||
throw new BadRequestException("Request is locked by another expert");
|
||||
}
|
||||
|
||||
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
|
||||
const lockExpiresAt = expertWorkflowLockExpiredAt();
|
||||
const lockSnapshot = await this.snapshotFieldExpert(actorDetail.sub);
|
||||
|
||||
const updateResult = await this.requestManagementDbService.findOneAndUpdate(
|
||||
@@ -1258,7 +1315,7 @@ export class ExpertBlameService {
|
||||
$set: {
|
||||
lockFile: true,
|
||||
blameStatus: ReqBlameStatus.ReviewRequest,
|
||||
unlockTime: fifteenMinutes,
|
||||
unlockTime: lockExpiresAt,
|
||||
lockTime: new Date(),
|
||||
actorLocked: {
|
||||
fullName: actorDetail.fullName,
|
||||
@@ -1435,7 +1492,7 @@ export class ExpertBlameService {
|
||||
$set: {
|
||||
"workflow.locked": true,
|
||||
"workflow.lockedAt": now,
|
||||
"workflow.expiredAt": new Date(now.getTime() + this.blameV2LockTtlMs),
|
||||
"workflow.expiredAt": new Date(now.getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS),
|
||||
"workflow.lockedBy": lockedByPayload,
|
||||
},
|
||||
$push: {
|
||||
@@ -1566,7 +1623,7 @@ export class ExpertBlameService {
|
||||
}
|
||||
|
||||
/**
|
||||
* V2: Lock blame case for 15 minutes (blameCases collection).
|
||||
* V2: Lock blame case for 30 minutes (blameCases collection).
|
||||
* Delegates to {@link assignBlameCaseForReviewV2}; maps conflict to BadRequest for legacy clients.
|
||||
*/
|
||||
async lockRequestV2(
|
||||
@@ -1651,7 +1708,7 @@ export class ExpertBlameService {
|
||||
const lockedAt = request.workflow?.lockedAt;
|
||||
if (lockedAt) {
|
||||
const lockExpiryTime =
|
||||
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||
new Date(lockedAt).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||
if (Date.now() > lockExpiryTime) {
|
||||
throw new ForbiddenException(
|
||||
"Your lock time has expired. Please lock the request again.",
|
||||
@@ -1814,6 +1871,7 @@ export class ExpertBlameService {
|
||||
link: this.smsOrchestrationService.buildBlamePartyLink(
|
||||
requestIdToken,
|
||||
role,
|
||||
"v1",
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -1893,11 +1951,11 @@ export class ExpertBlameService {
|
||||
const lockedAt = request.workflow?.lockedAt;
|
||||
const isLockedByCurrentActor = lockedByActorId === actorId;
|
||||
|
||||
// Check if lock has expired (15 minutes)
|
||||
// Check if lock has expired (30 minutes)
|
||||
let isLockExpired = false;
|
||||
if (lockedAt) {
|
||||
const lockExpiryTime =
|
||||
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||
new Date(lockedAt).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||
isLockExpired = Date.now() > lockExpiryTime;
|
||||
}
|
||||
|
||||
@@ -2008,6 +2066,7 @@ export class ExpertBlameService {
|
||||
link: this.smsOrchestrationService.buildBlamePartyLink(
|
||||
requestIdToken,
|
||||
linkRole,
|
||||
"v1",
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -2059,11 +2118,11 @@ export class ExpertBlameService {
|
||||
const lockedAt = request.workflow?.lockedAt;
|
||||
const isLockedByCurrentActor = lockedByActorId === actorId;
|
||||
|
||||
// Check if lock has expired (15 minutes)
|
||||
// Check if lock has expired (30 minutes)
|
||||
let isLockExpired = false;
|
||||
if (lockedAt) {
|
||||
const lockExpiryTime =
|
||||
new Date(lockedAt).getTime() + this.blameV2LockTtlMs;
|
||||
new Date(lockedAt).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||
isLockExpired = Date.now() > lockExpiryTime;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,16 +131,26 @@ export class ClaimDetailV2ResponseDto {
|
||||
})
|
||||
awaitingFactorValidation?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"True for V5 files — the FileMaker must approve or reject the claim before it is submitted to fanavaran. False (or absent) for V4 files which go straight through.",
|
||||
example: true,
|
||||
})
|
||||
requiresFileMakerApproval?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Slice of `claim.evaluation` exposed to the damage expert. " +
|
||||
"`damageExpertReply` / `damageExpertReplyFinal` are returned only while " +
|
||||
"awaiting factor validation. `ownerInsurerApproval` and " +
|
||||
"`ownerPricedPartsApproval` are returned whenever they exist on the " +
|
||||
"claim — each carries `signLink` (resolved from `signDetailId`) so the " +
|
||||
"front-end can render the user signature directly. Likewise, " +
|
||||
"`damageExpertReply.userComment` / `damageExpertReplyFinal.userComment` " +
|
||||
"expose `signLink` when a user comment signature is present.",
|
||||
"`damageExpertReply` / `damageExpertReplyFinal` are returned whenever " +
|
||||
"present, regardless of the current claim status — historical assessment " +
|
||||
"data remains visible once the expert has submitted it. " +
|
||||
"`ownerInsurerApproval` and `ownerPricedPartsApproval` are returned " +
|
||||
"whenever they exist on the claim — each carries `signLink` (resolved " +
|
||||
"from `signDetailId`) so the front-end can render the user signature " +
|
||||
"directly. Likewise, `damageExpertReply.userComment` / " +
|
||||
"`damageExpertReplyFinal.userComment` expose `signLink` when a user " +
|
||||
"comment signature is present. `priceDrop` is included whenever it " +
|
||||
"has been saved, not only during the expert review phase.",
|
||||
})
|
||||
evaluation?: {
|
||||
damageExpertReply?: unknown;
|
||||
|
||||
@@ -56,6 +56,12 @@ export class ClaimListItemV2Dto {
|
||||
})
|
||||
carBodyFirstForm?: { car?: boolean; object?: boolean };
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Linked blame file ID (present when the claim originates from a blame case)',
|
||||
example: '6a3a6f171aadfa0cc313c582',
|
||||
})
|
||||
blameRequestId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Submission date', example: '2026-02-22T10:00:00.000Z' })
|
||||
createdAt: string;
|
||||
|
||||
@@ -64,6 +70,13 @@ export class ClaimListItemV2Dto {
|
||||
"True in the expert repair-factor validation queue: `status=EXPERT_VALIDATING_REPAIR_FACTORS` (or legacy `WAITING_FOR_INSURER_APPROVAL`) with `claimStatus=UNDER_REVIEW` and `currentStep=EXPERT_COST_EVALUATION`.",
|
||||
})
|
||||
awaitingFactorValidation?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"True for V5 files — the FileMaker must approve or reject the claim before it is submitted to fanavaran. False (or absent) for V4 files which go straight through.",
|
||||
example: true,
|
||||
})
|
||||
requiresFileMakerApproval?: boolean;
|
||||
}
|
||||
|
||||
export class GetClaimListV2ResponseDto {
|
||||
|
||||
25
src/expert-claim/exceptions/business-rule.exception.ts
Normal file
25
src/expert-claim/exceptions/business-rule.exception.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { HttpException, HttpStatus } from "@nestjs/common";
|
||||
|
||||
/**
|
||||
* Thrown when a well-formed request violates a business rule that cannot be
|
||||
* expressed as a generic validation error. Returns HTTP 422 with a structured
|
||||
* body so the frontend can branch on `errorCode` without string-matching the
|
||||
* human-readable `message`.
|
||||
*
|
||||
* Response body shape:
|
||||
* ```json
|
||||
* { "errorCode": "SOME_CODE", "message": "Human-readable explanation." }
|
||||
* ```
|
||||
*/
|
||||
export class BusinessRuleException extends HttpException {
|
||||
constructor(
|
||||
public readonly errorCode: string,
|
||||
message: string,
|
||||
) {
|
||||
super({ errorCode, message }, HttpStatus.UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
export const BusinessErrorCode = {
|
||||
DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED: "DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED",
|
||||
} as const;
|
||||
@@ -1,5 +1,7 @@
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { AiModule } from "src/ai/ai.module";
|
||||
import { AuthModule } from "src/auth/auth.module";
|
||||
@@ -21,7 +23,11 @@ import { ExpertClaimService } from "./expert-claim.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule,
|
||||
HttpModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: createHttpModuleOptions,
|
||||
}),
|
||||
SandHubModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: ClaimFactorsImage.name, schema: ClaimFactorsImageSchema },
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
BusinessErrorCode,
|
||||
BusinessRuleException,
|
||||
} from "./exceptions/business-rule.exception";
|
||||
import { distance as stringDistance } from "fastest-levenshtein"; // حتما نصب بشه
|
||||
import { Types } from "mongoose";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
@@ -19,6 +23,7 @@ import { ClaimRequestManagementDbService } from "src/claim-request-management/en
|
||||
import {
|
||||
ClaimRequestManagementService,
|
||||
FanavaranAutoSubmitResult,
|
||||
FanavaranExpertiseSubmitResult,
|
||||
} from "src/claim-request-management/claim-request-management.service";
|
||||
import { ClaimSignDbService } from "src/claim-request-management/entites/db-service/claim-sign.db.service";
|
||||
import { DamageImageDbService } from "src/claim-request-management/entites/db-service/damage-image.db.service";
|
||||
@@ -45,6 +50,10 @@ import {
|
||||
buildExpertAssignConflictForOtherReviewer,
|
||||
resolvePersistentReviewAssigneeId,
|
||||
} from "src/helpers/expert-workflow-review-assignee";
|
||||
import {
|
||||
EXPERT_WORKFLOW_LOCK_TTL_MS,
|
||||
expertWorkflowLockExpiredAt,
|
||||
} from "src/helpers/expert-workflow-lock";
|
||||
import { ClaimPerIdRs } from "./dto/claim-list-perId-rs.dto";
|
||||
import { ClaimListDtoRs } from "./dto/claim-list-rs.dto";
|
||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||
@@ -54,6 +63,7 @@ import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import {
|
||||
assertClaimCaseForTenant,
|
||||
assertClaimCaseForExpertActor,
|
||||
blameCaseTouchesClient,
|
||||
claimCaseInitiatedByFieldExpert,
|
||||
claimCaseTouchesClient,
|
||||
requireActorClientKey,
|
||||
@@ -162,9 +172,6 @@ import { canonicalizeResendDocumentKey } from "src/helpers/claim-resend-document
|
||||
export class ExpertClaimService {
|
||||
private readonly logger = new Logger(ExpertClaimService.name);
|
||||
|
||||
/** Matches blame v2 `workflow` lock TTL: locker may re-enter until expired, then others may open. */
|
||||
private readonly claimV2WorkflowLockTtlMs = 15 * 60 * 1000;
|
||||
|
||||
private readonly priceDropPart = {
|
||||
backFender: {
|
||||
Minor: 2,
|
||||
@@ -288,7 +295,7 @@ export class ExpertClaimService {
|
||||
|
||||
private appendFanavaranAutoSubmitToMessage(
|
||||
baseMessage: string,
|
||||
fanavaran: FanavaranAutoSubmitResult,
|
||||
fanavaran: FanavaranAutoSubmitResult | FanavaranExpertiseSubmitResult,
|
||||
): string {
|
||||
if (fanavaran.submitted) {
|
||||
return `${baseMessage} The claim was sent to Fanavaran successfully.`;
|
||||
@@ -365,6 +372,21 @@ export class ExpertClaimService {
|
||||
await enrichApproval("ownerInsurerApproval");
|
||||
await enrichApproval("ownerPricedPartsApproval");
|
||||
|
||||
// Resolve objection invoice paths to downloadable URLs.
|
||||
const objection = ev.objection as Record<string, unknown> | undefined;
|
||||
if (objection) {
|
||||
const invoices = objection.invoices as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(invoices) && invoices.length > 0) {
|
||||
ev.objection = {
|
||||
...objection,
|
||||
invoices: invoices.map((inv) => ({
|
||||
...inv,
|
||||
url: inv.path ? buildFileLink(inv.path as string) : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return ev;
|
||||
}
|
||||
|
||||
@@ -377,7 +399,16 @@ export class ExpertClaimService {
|
||||
return this.blameRequestDbService.findById(id);
|
||||
}
|
||||
|
||||
private async assertExpertActorOnClaim(claim: any, actor: any): Promise<void> {
|
||||
private async assertExpertActorOnClaim(
|
||||
claim: any,
|
||||
actor: any,
|
||||
): Promise<void> {
|
||||
// FILE_REVIEWER: by the time we reach this method the caller has already
|
||||
// verified that actor.sub === blame.assignedFileReviewerId (Phase 2 of
|
||||
// assignClaimForReviewV2). The generic tenant-scope check would incorrectly
|
||||
// reject them because initiatedByFieldExpertId belongs to the FileMaker, not
|
||||
// the reviewer. Skip it — the assignment check is the access proof.
|
||||
if ((actor as any).role === RoleEnum.FILE_REVIEWER) return;
|
||||
const blame = await this.loadBlameForClaim(claim);
|
||||
assertClaimCaseForExpertActor(claim, actor, blame);
|
||||
}
|
||||
@@ -535,6 +566,23 @@ export class ExpertClaimService {
|
||||
return this.expertVehicleFromPartyVehicle(party?.vehicle);
|
||||
}
|
||||
|
||||
/** Extract car name / model from the inquiry snapshot embedded on the claim. */
|
||||
private vehicleNamesFromClaimInquiries(claim: any): {
|
||||
carName?: string;
|
||||
carModel?: string;
|
||||
} {
|
||||
// Claim copies inquiries from blame at creation time.
|
||||
// Try thirdParty first, then carBody.
|
||||
const mapped =
|
||||
claim?.inquiries?.thirdParty?.data?.FIRST?.mapped ??
|
||||
claim?.inquiries?.carBody?.data?.FIRST?.mapped ??
|
||||
{};
|
||||
const carName: string | undefined =
|
||||
mapped.MapTypNam || mapped.SystemField || undefined;
|
||||
const carModel: string | undefined = mapped.TypeField || undefined;
|
||||
return { carName, carModel };
|
||||
}
|
||||
|
||||
private vehicleForExpertFromClaimAndBlameMap(
|
||||
claim: any,
|
||||
blameById: Map<string, any>,
|
||||
@@ -548,10 +596,24 @@ export class ExpertClaimService {
|
||||
| undefined {
|
||||
const cv = claim?.vehicle;
|
||||
if (cv && (cv.carName || cv.carModel || cv.carType || (cv as any).plate)) {
|
||||
const carType: string | undefined = cv.carType;
|
||||
// carName/carModel that match the enum value (e.g. "sedan") are invalid —
|
||||
// the expert may have accidentally copied the carType value into those fields.
|
||||
// Fall back to the inquiry snapshot for the real brand / variant strings.
|
||||
let carName: string | undefined = cv.carName;
|
||||
let carModel: string | undefined = cv.carModel;
|
||||
if (
|
||||
(!carName || carName === carType) ||
|
||||
(!carModel || carModel === carType)
|
||||
) {
|
||||
const fromInquiry = this.vehicleNamesFromClaimInquiries(claim);
|
||||
if (!carName || carName === carType) carName = fromInquiry.carName;
|
||||
if (!carModel || carModel === carType) carModel = fromInquiry.carModel;
|
||||
}
|
||||
return {
|
||||
carName: cv.carName,
|
||||
carModel: cv.carModel,
|
||||
carType: cv.carType,
|
||||
carName,
|
||||
carModel,
|
||||
carType,
|
||||
...((cv as any).plate ? { plate: (cv as any).plate } : {}),
|
||||
};
|
||||
}
|
||||
@@ -777,7 +839,7 @@ export class ExpertClaimService {
|
||||
throw new BadRequestException("Request has damage expert reply");
|
||||
}
|
||||
|
||||
const fifteenMinutes = new Date(Date.now() + 15 * 60 * 1000);
|
||||
const lockExpiresAt = expertWorkflowLockExpiredAt();
|
||||
const lockSnapshot = await this.snapshotDamageExpert(actorDetail.sub);
|
||||
|
||||
await this.claimRequestManagementDbService.findOneAndUpdate(
|
||||
@@ -785,7 +847,7 @@ export class ExpertClaimService {
|
||||
{
|
||||
lockFile: true,
|
||||
claimStatus: ReqClaimStatus.ReviewRequest,
|
||||
unlockTime: fifteenMinutes,
|
||||
unlockTime: lockExpiresAt,
|
||||
lockTime: Date.now(),
|
||||
$set: {
|
||||
actorLocked: {
|
||||
@@ -814,7 +876,7 @@ export class ExpertClaimService {
|
||||
idempotencyKey: `claim:${requestId}:checked:${actorDetail.sub}`,
|
||||
});
|
||||
|
||||
this.unlockApi(request, fifteenMinutes.getTime() - Date.now());
|
||||
this.unlockApi(request, lockExpiresAt.getTime() - Date.now());
|
||||
|
||||
return { _id: requestId, lock: true };
|
||||
}
|
||||
@@ -2367,16 +2429,24 @@ export class ExpertClaimService {
|
||||
await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
|
||||
claimRequestId,
|
||||
);
|
||||
const fanavaranExpertise =
|
||||
await this.claimRequestManagementService.autoSubmitFanavaranExpertiseOnExpertReply(
|
||||
claimRequestId,
|
||||
);
|
||||
return {
|
||||
message: this.appendFanavaranAutoSubmitToMessage(
|
||||
this.appendFanavaranAutoSubmitToMessage(
|
||||
"Factors were reviewed with expert repricing on rejected lines. The claim is completed without an owner signature (temporary policy; may require owner acceptance later).",
|
||||
fanavaran,
|
||||
),
|
||||
fanavaranExpertise,
|
||||
),
|
||||
claimRequestId,
|
||||
claimStatus: ClaimStatus.APPROVED,
|
||||
caseStatus: ClaimCaseStatus.COMPLETED,
|
||||
outcome: "REJECTED_REPRICED_AUTO_COMPLETED",
|
||||
fanavaran,
|
||||
fanavaranExpertise,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2393,17 +2463,25 @@ export class ExpertClaimService {
|
||||
await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
|
||||
claimRequestId,
|
||||
);
|
||||
const fanavaranExpertise =
|
||||
await this.claimRequestManagementService.autoSubmitFanavaranExpertiseOnExpertReply(
|
||||
claimRequestId,
|
||||
);
|
||||
|
||||
return {
|
||||
message: this.appendFanavaranAutoSubmitToMessage(
|
||||
this.appendFanavaranAutoSubmitToMessage(
|
||||
"All factors were approved by the expert. The claim is completed without an additional owner signature.",
|
||||
fanavaran,
|
||||
),
|
||||
fanavaranExpertise,
|
||||
),
|
||||
claimRequestId,
|
||||
claimStatus: ClaimStatus.APPROVED,
|
||||
caseStatus: ClaimCaseStatus.COMPLETED,
|
||||
outcome: "ALL_APPROVED_AUTO_COMPLETED",
|
||||
fanavaran,
|
||||
fanavaranExpertise,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2455,15 +2533,122 @@ export class ExpertClaimService {
|
||||
throw new HttpException(error.response, error.claimStatus);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* FILE_REVIEWER path: atomically assign this reviewer to the linked V4 blame
|
||||
* file. Operates on the blame document (not the claim workflow lock).
|
||||
*
|
||||
* Rules:
|
||||
* - Blame must be isMadeByFileMaker, IN_PERSON, expertInitiated.
|
||||
* - Status must be WAITING_FOR_FILE_REVIEWER.
|
||||
* - No other reviewer may have taken it (assignedFileReviewerId absent/null).
|
||||
* - Idempotent: same reviewer calling again gets "already_assigned_to_you".
|
||||
*/
|
||||
private async assignFileReviewerToV4Blame(
|
||||
claimRequestId: string,
|
||||
claim: any,
|
||||
actor: { sub: string; fullName?: string; clientKey?: string },
|
||||
): Promise<ExpertFileAssignResultDto> {
|
||||
if (!claim.blameRequestId) {
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||
message: "This claim has no linked blame file.",
|
||||
});
|
||||
}
|
||||
|
||||
const blame = await this.blameRequestDbService.findById(
|
||||
String(claim.blameRequestId),
|
||||
);
|
||||
if (!blame) {
|
||||
throw new NotFoundException("Linked blame file not found.");
|
||||
}
|
||||
if (!(blame as any).isMadeByFileMaker) {
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||
message: "Only V4 FileMaker files can be assigned to a FileReviewer.",
|
||||
});
|
||||
}
|
||||
if ((blame as any).status !== "WAITING_FOR_FILE_REVIEWER") {
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||
message: `Blame file is not ready for FileReviewer. Status: ${(blame as any).status}`,
|
||||
});
|
||||
}
|
||||
|
||||
const existing = (blame as any).assignedFileReviewerId;
|
||||
if (existing) {
|
||||
if (String(existing) === actor.sub) {
|
||||
return {
|
||||
success: true,
|
||||
status: "already_assigned_to_you",
|
||||
message: "You have already taken this file.",
|
||||
};
|
||||
}
|
||||
throw new ConflictException({
|
||||
success: false,
|
||||
status: "locked" satisfies ExpertFileAssignStatus,
|
||||
message: "Another FileReviewer has already taken this file.",
|
||||
});
|
||||
}
|
||||
|
||||
// Atomically claim it — findOneAndUpdate with null/missing guard
|
||||
const reviewerOid = new Types.ObjectId(actor.sub);
|
||||
const updated = await this.blameRequestDbService.findOneAndUpdate(
|
||||
{
|
||||
_id: (blame as any)._id,
|
||||
$or: [
|
||||
{ assignedFileReviewerId: { $exists: false } },
|
||||
{ assignedFileReviewerId: null },
|
||||
],
|
||||
},
|
||||
{ $set: { assignedFileReviewerId: reviewerOid } },
|
||||
{ new: true },
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
// Another reviewer won the race
|
||||
throw new ConflictException({
|
||||
success: false,
|
||||
status: "locked" satisfies ExpertFileAssignStatus,
|
||||
message: "Another FileReviewer has already taken this file.",
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
this.logger.log(
|
||||
`FileReviewer ${actor.sub} assigned to V4 blame ${String((blame as any)._id)} / claim ${claimRequestId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: "assigned",
|
||||
assignedAt: now.toISOString(),
|
||||
message: "File assigned to you. Proceed with accident fields and field capture.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Assign the current damage expert to a claim for review (V2).
|
||||
* Free cases are locked atomically; already-assigned-to-self is idempotent.
|
||||
*/
|
||||
async assignClaimForReviewV2(
|
||||
claimRequestId: string,
|
||||
actor: { sub: string; fullName?: string; clientKey?: string; role?: string },
|
||||
actor: {
|
||||
sub: string;
|
||||
fullName?: string;
|
||||
clientKey?: string;
|
||||
role?: string;
|
||||
},
|
||||
): Promise<ExpertFileAssignResultDto> {
|
||||
if ((actor as any).role !== RoleEnum.FIELD_EXPERT) requireActorClientKey(actor);
|
||||
if (
|
||||
(actor as any).role !== RoleEnum.FIELD_EXPERT &&
|
||||
(actor as any).role !== RoleEnum.FILE_REVIEWER
|
||||
)
|
||||
requireActorClientKey(actor);
|
||||
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||||
|
||||
const claim = await this.claimCaseDbService.findById(claimRequestId);
|
||||
@@ -2471,6 +2656,57 @@ export class ExpertClaimService {
|
||||
throw new NotFoundException("Claim request not found");
|
||||
}
|
||||
|
||||
// FILE_REVIEWER: two-phase lock behaviour.
|
||||
// Phase 1 — blame is WAITING_FOR_FILE_REVIEWER: atomically set
|
||||
// assignedFileReviewerId on the blame document.
|
||||
// Phase 2 — blame is already past WAITING_FOR_FILE_REVIEWER (i.e. the
|
||||
// reviewer finished field work and blame moved to WAITING_FOR_EXPERT):
|
||||
// the reviewer now needs the standard damage-expert workflow lock
|
||||
// so they can perform damage assessment. Fall through to the
|
||||
// damage-expert path below; assertExpertActorOnClaim will verify
|
||||
// tenant scope via the reviewer's clientKey.
|
||||
if ((actor as any).role === RoleEnum.FILE_REVIEWER) {
|
||||
if (!claim.blameRequestId) {
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||
message: "This claim has no linked blame file.",
|
||||
});
|
||||
}
|
||||
const reviewerBlame = await this.blameRequestDbService.findById(
|
||||
String(claim.blameRequestId),
|
||||
);
|
||||
if (!reviewerBlame) {
|
||||
throw new NotFoundException("Linked blame file not found.");
|
||||
}
|
||||
const blameStatus = (reviewerBlame as any).status as string;
|
||||
if (blameStatus === "WAITING_FOR_FILE_REVIEWER") {
|
||||
// Phase 1: blame-assign path
|
||||
return this.assignFileReviewerToV4Blame(claimRequestId, claim, actor);
|
||||
}
|
||||
// Phase 2: blame is past the initial assignment step.
|
||||
// Only the reviewer who was assigned during Phase 1 may proceed.
|
||||
const assignedReviewerId = (reviewerBlame as any).assignedFileReviewerId
|
||||
? String((reviewerBlame as any).assignedFileReviewerId)
|
||||
: null;
|
||||
if (!assignedReviewerId) {
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||
message: "No reviewer has been assigned to this file yet.",
|
||||
});
|
||||
}
|
||||
if (assignedReviewerId !== actor.sub) {
|
||||
throw new ConflictException({
|
||||
success: false,
|
||||
status: "locked" satisfies ExpertFileAssignStatus,
|
||||
message: "This file is assigned to another reviewer.",
|
||||
});
|
||||
}
|
||||
// Assigned reviewer — fall through to the damage-expert workflow lock below.
|
||||
// (actor.role stays FILE_REVIEWER; assertExpertActorOnClaim checks clientKey scope)
|
||||
}
|
||||
|
||||
await this.assertExpertActorOnClaim(claim, actor);
|
||||
|
||||
const isFieldExpertOwner =
|
||||
@@ -2548,7 +2784,7 @@ export class ExpertClaimService {
|
||||
|
||||
const now = new Date();
|
||||
const lockSnapshot = await this.snapshotDamageExpert(actor.sub);
|
||||
const expiredAt = new Date(now.getTime() + this.claimV2WorkflowLockTtlMs);
|
||||
const expiredAt = new Date(now.getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS);
|
||||
const actorType = isFieldExpertOwner ? "field_expert" : "damage_expert";
|
||||
const actorRoleLabel = isFieldExpertOwner
|
||||
? ("field_expert" as const)
|
||||
@@ -2846,7 +3082,8 @@ export class ExpertClaimService {
|
||||
}
|
||||
|
||||
if (existingResend?.fulfilledAt) {
|
||||
throw new BadRequestException(
|
||||
throw new BusinessRuleException(
|
||||
BusinessErrorCode.DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED,
|
||||
"The owner has already fulfilled a damage-expert resend for this claim. You cannot request another resend.",
|
||||
);
|
||||
}
|
||||
@@ -2927,7 +3164,7 @@ export class ExpertClaimService {
|
||||
receptor: ownerPhoneResend,
|
||||
fileKind: "claim",
|
||||
publicId: claim.publicId,
|
||||
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)),
|
||||
link: this.smsOrchestrationService.buildClaimLink(String(claim._id), "v1"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3002,7 +3239,9 @@ export class ExpertClaimService {
|
||||
if (priceCap !== null) {
|
||||
let totalPrice = 0;
|
||||
for (const part of reply.parts || []) {
|
||||
const parsed = this.parsePersianNumber(String(part.totalPayment ?? "0"));
|
||||
const parsed = this.parsePersianNumber(
|
||||
String(part.totalPayment ?? "0"),
|
||||
);
|
||||
if (!isNaN(parsed)) totalPrice += parsed;
|
||||
}
|
||||
if (totalPrice > priceCap) {
|
||||
@@ -3223,10 +3462,12 @@ export class ExpertClaimService {
|
||||
fileKind: "claim",
|
||||
publicId: claim.publicId,
|
||||
expertLastName,
|
||||
link: this.smsOrchestrationService.buildClaimLink(String(claim._id)),
|
||||
link: this.smsOrchestrationService.buildClaimLink(String(claim._id), "v1"),
|
||||
});
|
||||
}
|
||||
|
||||
// Fanavaran expertise is now triggered on owner final sign, not here.
|
||||
|
||||
return {
|
||||
claimRequestId,
|
||||
status: nextCaseStatus,
|
||||
@@ -3293,6 +3534,7 @@ export class ExpertClaimService {
|
||||
|
||||
await this.claimCaseDbService.findByIdAndUpdate(claimRequestId, {
|
||||
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||||
status: ClaimCaseStatus.COMPLETED,
|
||||
"workflow.locked": false,
|
||||
$unset: {
|
||||
"workflow.lockedAt": "",
|
||||
@@ -3330,7 +3572,7 @@ export class ExpertClaimService {
|
||||
|
||||
return {
|
||||
claimRequestId,
|
||||
status: claim.status,
|
||||
status: ClaimCaseStatus.COMPLETED,
|
||||
claimStatus: ClaimStatus.NEEDS_REVISION,
|
||||
message: "In-person visit requested. User will be notified.",
|
||||
};
|
||||
@@ -3474,14 +3716,19 @@ export class ExpertClaimService {
|
||||
let claims: any[] = [];
|
||||
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
||||
const expertOid = new Types.ObjectId(actor.sub);
|
||||
// Exclude V4/V5 blame files — same rule as getFieldExpertClaimListV2.
|
||||
const expertBlameIds = await this.blameRequestDbService
|
||||
.find(
|
||||
{ expertInitiated: true, initiatedByFieldExpertId: expertOid },
|
||||
{
|
||||
expertInitiated: true,
|
||||
initiatedByFieldExpertId: expertOid,
|
||||
isMadeByFileMaker: { $ne: true },
|
||||
},
|
||||
{ select: "_id", lean: true },
|
||||
)
|
||||
.then((docs) => docs.map((d) => (d as { _id: unknown })._id));
|
||||
const claimOr: Record<string, unknown>[] = [
|
||||
{ initiatedByFieldExpertId: expertOid },
|
||||
{ initiatedByFieldExpertId: expertOid, requiresFileMakerApproval: { $ne: true } },
|
||||
];
|
||||
if (expertBlameIds.length > 0) {
|
||||
claimOr.push({ blameRequestId: { $in: expertBlameIds } });
|
||||
@@ -3491,7 +3738,8 @@ export class ExpertClaimService {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const expertId = String(actor.sub);
|
||||
const expertOid = new Types.ObjectId(expertId);
|
||||
const activityEvents = await this.expertFileActivityDbService.findByExpert(
|
||||
const activityEvents =
|
||||
await this.expertFileActivityDbService.findByExpert(
|
||||
expertId,
|
||||
ExpertFileKind.CLAIM,
|
||||
);
|
||||
@@ -3510,7 +3758,10 @@ export class ExpertClaimService {
|
||||
const rows =
|
||||
orClauses.length === 0
|
||||
? []
|
||||
: await this.claimCaseDbService.find({ $or: orClauses }, { lean: true });
|
||||
: await this.claimCaseDbService.find(
|
||||
{ $or: orClauses },
|
||||
{ lean: true },
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
for (const doc of rows as Record<string, unknown>[]) {
|
||||
const id = String(doc._id ?? "");
|
||||
@@ -3557,6 +3808,12 @@ export class ExpertClaimService {
|
||||
(item) => item.unifiedFileStatus === query.unifiedStatus,
|
||||
);
|
||||
}
|
||||
const { fromDate, toDate } = parseListDateRange(query.startDate, query.endDate);
|
||||
if (fromDate || toDate) {
|
||||
filtered = filtered.filter((item) =>
|
||||
isInListDateRange(item.createdAt, fromDate, toDate),
|
||||
);
|
||||
}
|
||||
const paged = applyListQueryV2(
|
||||
filtered,
|
||||
{
|
||||
@@ -3602,6 +3859,12 @@ export class ExpertClaimService {
|
||||
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
||||
return this.getFieldExpertClaimListV2(actor, query);
|
||||
}
|
||||
if (actor.role === RoleEnum.FILE_REVIEWER) {
|
||||
return this.getFileReviewerClaimListV2(actor, query);
|
||||
}
|
||||
if (actor.role === RoleEnum.FILE_MAKER) {
|
||||
return this.getFileMakerClaimListV2(actor, query);
|
||||
}
|
||||
requireActorClientKey(actor);
|
||||
const actorId = actor.sub;
|
||||
const clientKey = actor.clientKey as string;
|
||||
@@ -3730,7 +3993,7 @@ export class ExpertClaimService {
|
||||
blameIds.length > 0
|
||||
? ((await this.blameRequestDbService.find(
|
||||
{ _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } },
|
||||
{ lean: true, select: "type parties expert.decision blameStatus" },
|
||||
{ lean: true, select: "type parties expert.decision blameStatus status isMadeByFileMaker" },
|
||||
)) as any[])
|
||||
: [];
|
||||
const blameById = new Map<string, any>(
|
||||
@@ -3776,6 +4039,7 @@ export class ExpertClaimService {
|
||||
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
|
||||
: undefined,
|
||||
...fileCtx,
|
||||
blameRequestId: c.blameRequestId?.toString(),
|
||||
createdAt: c.createdAt,
|
||||
awaitingFactorValidation,
|
||||
};
|
||||
@@ -3793,15 +4057,22 @@ export class ExpertClaimService {
|
||||
query: ListQueryV2Dto = {},
|
||||
): Promise<GetClaimListV2ResponseDto> {
|
||||
const expertOid = new Types.ObjectId(actor.sub);
|
||||
// Exclude V4/V5 blame files (isMadeByFileMaker=true) — those belong to
|
||||
// the FILE_MAKER/FILE_REVIEWER flows, not the V3 field-expert flow.
|
||||
const expertBlameIds = await this.blameRequestDbService
|
||||
.find(
|
||||
{ expertInitiated: true, initiatedByFieldExpertId: expertOid },
|
||||
{
|
||||
expertInitiated: true,
|
||||
initiatedByFieldExpertId: expertOid,
|
||||
isMadeByFileMaker: { $ne: true },
|
||||
},
|
||||
{ select: "_id", lean: true },
|
||||
)
|
||||
.then((docs) => docs.map((d) => (d as { _id: unknown })._id));
|
||||
|
||||
const claimOr: Record<string, unknown>[] = [
|
||||
{ initiatedByFieldExpertId: expertOid },
|
||||
// Direct claim link: exclude V5 claims that require FileMaker approval
|
||||
{ initiatedByFieldExpertId: expertOid, requiresFileMakerApproval: { $ne: true } },
|
||||
];
|
||||
if (expertBlameIds.length > 0) {
|
||||
claimOr.push({ blameRequestId: { $in: expertBlameIds } });
|
||||
@@ -3822,7 +4093,7 @@ export class ExpertClaimService {
|
||||
blameIds.length > 0
|
||||
? ((await this.blameRequestDbService.find(
|
||||
{ _id: { $in: blameIds.map((id) => new Types.ObjectId(id)) } },
|
||||
{ lean: true, select: "type parties expert.decision blameStatus" },
|
||||
{ lean: true, select: "type parties expert.decision blameStatus status" },
|
||||
)) as any[])
|
||||
: [];
|
||||
const blameById = new Map<string, any>(
|
||||
@@ -3861,8 +4132,207 @@ export class ExpertClaimService {
|
||||
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
|
||||
: undefined,
|
||||
...fileCtx,
|
||||
blameRequestId: c.blameRequestId?.toString(),
|
||||
createdAt: c.createdAt,
|
||||
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
||||
needsFileReviewerCompletion:
|
||||
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
|
||||
(!!blame?.isMadeByFileMaker &&
|
||||
String(blame?.status ?? "") === "WAITING_FOR_EXPERT"),
|
||||
};
|
||||
}) as ClaimListItemV2Dto[];
|
||||
|
||||
return this.paginateClaimListV2(list, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim list for FILE_REVIEWER.
|
||||
*
|
||||
* Visibility rules:
|
||||
* - WAITING_FOR_FILE_REVIEWER files made by a FileMaker that are either:
|
||||
* (a) not yet assigned to any reviewer, OR
|
||||
* (b) already assigned to THIS reviewer
|
||||
* - WAITING_FOR_EXPERT files that are assigned to THIS reviewer
|
||||
* (they completed the field work and the file is now in damage-expert queue)
|
||||
*
|
||||
* All scoped to this reviewer's insurance company via blame party clientId.
|
||||
*/
|
||||
private async getFileReviewerClaimListV2(
|
||||
actor: any,
|
||||
query: ListQueryV2Dto = {},
|
||||
): Promise<GetClaimListV2ResponseDto> {
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const reviewerOid = new Types.ObjectId(actor.sub);
|
||||
|
||||
// Find V4 blame files visible to this reviewer
|
||||
const blames = (await this.blameRequestDbService.find(
|
||||
{
|
||||
isMadeByFileMaker: true,
|
||||
expertInitiated: true,
|
||||
creationMethod: "IN_PERSON",
|
||||
$or: [
|
||||
// Open: sealed by FileMaker, not yet taken by any reviewer
|
||||
{
|
||||
status: "WAITING_FOR_FILE_REVIEWER",
|
||||
$or: [
|
||||
{ assignedFileReviewerId: { $exists: false } },
|
||||
{ assignedFileReviewerId: null },
|
||||
],
|
||||
},
|
||||
// Taken by this reviewer (any post-assignment status)
|
||||
{ assignedFileReviewerId: reviewerOid },
|
||||
],
|
||||
},
|
||||
{
|
||||
lean: true,
|
||||
select:
|
||||
"_id type parties blameStatus status expert.decision assignedFileReviewerId",
|
||||
},
|
||||
)) as any[];
|
||||
|
||||
if (blames.length === 0) {
|
||||
return this.paginateClaimListV2([], query);
|
||||
}
|
||||
|
||||
// Scope to this reviewer's insurer via blame party clientId
|
||||
const scopedBlames = blames.filter((b) =>
|
||||
blameCaseTouchesClient(b, clientKey),
|
||||
);
|
||||
|
||||
if (scopedBlames.length === 0) {
|
||||
return this.paginateClaimListV2([], query);
|
||||
}
|
||||
|
||||
const scopedBlameIds = scopedBlames.map((b) => b._id);
|
||||
const claims = (await this.claimCaseDbService.find({
|
||||
blameRequestId: { $in: scopedBlameIds },
|
||||
})) as any[];
|
||||
|
||||
const blameById = new Map<string, any>(
|
||||
scopedBlames.map((b) => [String(b._id), b]),
|
||||
);
|
||||
|
||||
const list = claims.map((c) => {
|
||||
const blame = c.blameRequestId
|
||||
? blameById.get(c.blameRequestId.toString())
|
||||
: undefined;
|
||||
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
|
||||
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
|
||||
const lockActive = !!(
|
||||
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c)
|
||||
);
|
||||
const assignedToMe =
|
||||
blame?.assignedFileReviewerId &&
|
||||
String(blame.assignedFileReviewerId) === actor.sub;
|
||||
return {
|
||||
claimRequestId: c._id.toString(),
|
||||
publicId: c.publicId,
|
||||
status: c.status,
|
||||
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||
blameStatus: blame?.status,
|
||||
claimStatus: c.status,
|
||||
}),
|
||||
currentStep: c.workflow?.currentStep || "",
|
||||
locked: lockActive,
|
||||
lockedBy:
|
||||
lockActive && c.workflow?.lockedBy
|
||||
? {
|
||||
actorId: c.workflow.lockedBy.actorId?.toString(),
|
||||
actorName: c.workflow.lockedBy.actorName,
|
||||
lockedAt: (c.workflow as any).lockedAt?.toISOString?.(),
|
||||
expiredAt: (c.workflow as any).expiredAt?.toISOString?.(),
|
||||
}
|
||||
: undefined,
|
||||
vehicle: v
|
||||
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
|
||||
: undefined,
|
||||
...fileCtx,
|
||||
blameRequestId: c.blameRequestId?.toString(),
|
||||
createdAt: c.createdAt,
|
||||
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
||||
needsFileReviewerCompletion:
|
||||
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
|
||||
(!!blame?.isMadeByFileMaker &&
|
||||
String(blame?.status ?? "") === "WAITING_FOR_EXPERT"),
|
||||
assignedToMe: !!assignedToMe,
|
||||
};
|
||||
}) as ClaimListItemV2Dto[];
|
||||
|
||||
return this.paginateClaimListV2(list, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim list for FILE_MAKER — returns only the claims linked to blame files
|
||||
* they personally created (V4 flow).
|
||||
*/
|
||||
private async getFileMakerClaimListV2(
|
||||
actor: any,
|
||||
query: ListQueryV2Dto = {},
|
||||
): Promise<GetClaimListV2ResponseDto> {
|
||||
const makerOid = new Types.ObjectId(actor.sub);
|
||||
|
||||
const makerBlames = (await this.blameRequestDbService.find(
|
||||
{ isMadeByFileMaker: true, initiatedByFieldExpertId: makerOid },
|
||||
{ lean: true, select: "_id type parties blameStatus status expert.decision assignedFileReviewerId" },
|
||||
)) as any[];
|
||||
|
||||
if (makerBlames.length === 0) {
|
||||
return this.paginateClaimListV2([], query);
|
||||
}
|
||||
|
||||
const blameIds = makerBlames.map((b) => b._id);
|
||||
const claims = (await this.claimCaseDbService.find({
|
||||
blameRequestId: { $in: blameIds },
|
||||
requiresFileMakerApproval: true,
|
||||
})) as any[];
|
||||
|
||||
const blameById = new Map<string, any>(
|
||||
makerBlames.map((b) => [String(b._id), b]),
|
||||
);
|
||||
|
||||
const list = claims.map((c) => {
|
||||
const blame = c.blameRequestId
|
||||
? blameById.get(c.blameRequestId.toString())
|
||||
: undefined;
|
||||
const v = this.vehicleForExpertFromClaimAndBlameMap(c, blameById);
|
||||
const fileCtx = blame ? this.blameFileContextForExpert(blame) : {};
|
||||
const lockActive = !!(
|
||||
c.workflow?.locked && this.isClaimV2WorkflowLockCurrentlyEnforced(c)
|
||||
);
|
||||
return {
|
||||
claimRequestId: c._id.toString(),
|
||||
publicId: c.publicId,
|
||||
status: c.status,
|
||||
unifiedFileStatus: resolveUnifiedFileStatus({
|
||||
blameStatus: blame?.status,
|
||||
claimStatus: c.status,
|
||||
}),
|
||||
currentStep: c.workflow?.currentStep || "",
|
||||
locked: lockActive,
|
||||
lockedBy:
|
||||
lockActive && c.workflow?.lockedBy
|
||||
? {
|
||||
actorId: c.workflow.lockedBy.actorId?.toString(),
|
||||
actorName: c.workflow.lockedBy.actorName,
|
||||
lockedAt: (c.workflow as any).lockedAt?.toISOString?.(),
|
||||
expiredAt: (c.workflow as any).expiredAt?.toISOString?.(),
|
||||
}
|
||||
: undefined,
|
||||
vehicle: v
|
||||
? { carName: v.carName, carModel: v.carModel, carType: v.carType }
|
||||
: undefined,
|
||||
...fileCtx,
|
||||
blameRequestId: c.blameRequestId?.toString(),
|
||||
createdAt: c.createdAt,
|
||||
awaitingFactorValidation: claimIsAwaitingExpertFactorValidationV2(c),
|
||||
requiresFileMakerApproval: !!(c as any).requiresFileMakerApproval,
|
||||
needsFileReviewerCompletion:
|
||||
String(blame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER" ||
|
||||
(!!blame?.isMadeByFileMaker &&
|
||||
String(blame?.status ?? "") === "WAITING_FOR_EXPERT"),
|
||||
assignedFileReviewerId: blame?.assignedFileReviewerId
|
||||
? String(blame.assignedFileReviewerId)
|
||||
: undefined,
|
||||
};
|
||||
}) as ClaimListItemV2Dto[];
|
||||
|
||||
@@ -4034,7 +4504,7 @@ export class ExpertClaimService {
|
||||
}
|
||||
const la = claim.workflow?.lockedAt as Date | string | undefined;
|
||||
if (!la) return true;
|
||||
return Date.now() < new Date(la).getTime() + this.claimV2WorkflowLockTtlMs;
|
||||
return Date.now() < new Date(la).getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4046,9 +4516,13 @@ export class ExpertClaimService {
|
||||
clientKey?: string;
|
||||
role?: string;
|
||||
}): Promise<void> {
|
||||
if ((actor as any).role === RoleEnum.FIELD_EXPERT) return;
|
||||
if (
|
||||
(actor as any).role === RoleEnum.FIELD_EXPERT ||
|
||||
(actor as any).role === RoleEnum.FILE_REVIEWER ||
|
||||
(actor as any).role === RoleEnum.FILE_MAKER
|
||||
) return;
|
||||
const clientKey = requireActorClientKey(actor);
|
||||
const lockTtlMs = this.claimV2WorkflowLockTtlMs;
|
||||
const lockTtlMs = EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||
const now = new Date();
|
||||
const candidates = (await this.claimCaseDbService.find(
|
||||
{
|
||||
@@ -4094,7 +4568,7 @@ export class ExpertClaimService {
|
||||
const expiredAt = claim.workflow?.expiredAt;
|
||||
const lockedAt = claim.workflow?.lockedAt;
|
||||
const now = Date.now();
|
||||
const ttl = this.claimV2WorkflowLockTtlMs;
|
||||
const ttl = EXPERT_WORKFLOW_LOCK_TTL_MS;
|
||||
|
||||
if (expiredAt && now < new Date(expiredAt as Date).getTime()) return false;
|
||||
if (
|
||||
@@ -4199,14 +4673,18 @@ export class ExpertClaimService {
|
||||
* - Claim must exist and belong to the actor's insurer (`assertClaimCaseForTenant`)
|
||||
* - Allowed when: WAITING_FOR_DAMAGE_EXPERT (queue), or EXPERT_REVIEWING (after lock — same expert must be able to reopen detail),
|
||||
* or factor-validation queue (EXPERT_VALIDATING_REPAIR_FACTORS / legacy WAITING_FOR_INSURER_APPROVAL + UNDER_REVIEW + EXPERT_COST_EVALUATION)
|
||||
* - If an active workflow lock exists (15 min from lockedAt): only the locking expert; after expiry, any expert (tenant) may view
|
||||
* - If an active workflow lock exists (30 min from lockedAt): only the locking expert; after expiry, any expert (tenant) may view
|
||||
*/
|
||||
async getClaimDetailV2(
|
||||
claimRequestId: string,
|
||||
actor: any,
|
||||
): Promise<ClaimDetailV2ResponseDto> {
|
||||
const actorId = actor.sub;
|
||||
if (actor.role !== RoleEnum.FIELD_EXPERT) {
|
||||
if (
|
||||
actor.role !== RoleEnum.FIELD_EXPERT &&
|
||||
actor.role !== RoleEnum.FILE_MAKER &&
|
||||
actor.role !== RoleEnum.FILE_REVIEWER
|
||||
) {
|
||||
await this.reconcileStaleExpertReviewingClaimLocksForTenant(actor);
|
||||
}
|
||||
await this.expireClaimWorkflowLockV2IfStale(claimRequestId);
|
||||
@@ -4217,7 +4695,12 @@ export class ExpertClaimService {
|
||||
}
|
||||
|
||||
const linkedBlame = await this.loadBlameForClaim(claim);
|
||||
// FILE_REVIEWER access is validated by the role-specific gate below
|
||||
// (isAssignedToMe || isOpen). Skip the generic tenant-scope assert which
|
||||
// would incorrectly reject them via the initiatedByFieldExpertId path.
|
||||
if (actor.role !== RoleEnum.FILE_REVIEWER) {
|
||||
assertClaimCaseForExpertActor(claim, actor, linkedBlame);
|
||||
}
|
||||
|
||||
// Variables used both in the gate block and in the detail-build section below
|
||||
const isDamageExpertPhase =
|
||||
@@ -4226,13 +4709,63 @@ export class ExpertClaimService {
|
||||
const isFactorValidationPending =
|
||||
claimIsAwaitingExpertFactorValidationV2(claim);
|
||||
|
||||
// Field experts can always view their own initiated claims regardless of status
|
||||
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
||||
// FileMaker: can view any file they created (both during filing and approval phase).
|
||||
// The list endpoint already filters to V5-only; detail access is scoped by ownership.
|
||||
if (actor.role === RoleEnum.FILE_MAKER) {
|
||||
const isOwn = claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame);
|
||||
if (!isOwn) {
|
||||
throw new ForbiddenException(
|
||||
"FileMakers can only view files they created.",
|
||||
);
|
||||
}
|
||||
// Fall through to the detail build below
|
||||
} else if (
|
||||
// Field experts and FileReviewers can view IN_PERSON expert-initiated claims
|
||||
// regardless of claim status (they work across multiple non-standard steps).
|
||||
actor.role === RoleEnum.FIELD_EXPERT ||
|
||||
actor.role === RoleEnum.FILE_REVIEWER
|
||||
) {
|
||||
if (actor.role === RoleEnum.FILE_REVIEWER) {
|
||||
// FILE_REVIEWER: must be a V4/V5 file AND (they are the assigned reviewer OR
|
||||
// the file is still open — WAITING_FOR_FILE_REVIEWER with no reviewer yet).
|
||||
const isV4V5Blame =
|
||||
(linkedBlame as any)?.isMadeByFileMaker &&
|
||||
linkedBlame?.expertInitiated &&
|
||||
linkedBlame?.creationMethod === "IN_PERSON";
|
||||
if (!isV4V5Blame) {
|
||||
throw new ForbiddenException(
|
||||
"FileReviewers can only access V4/V5 FileMaker files.",
|
||||
);
|
||||
}
|
||||
const assignedReviewerId = (linkedBlame as any)?.assignedFileReviewerId
|
||||
? String((linkedBlame as any).assignedFileReviewerId)
|
||||
: null;
|
||||
const isAssignedToMe =
|
||||
assignedReviewerId && assignedReviewerId === actorId;
|
||||
const isOpen =
|
||||
!assignedReviewerId &&
|
||||
String(linkedBlame?.status ?? "") === "WAITING_FOR_FILE_REVIEWER";
|
||||
if (!isAssignedToMe && !isOpen) {
|
||||
throw new ForbiddenException(
|
||||
"This file has been taken by another reviewer.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// FIELD_EXPERT: V3 flow only. Reject V4/V5 files (isMadeByFileMaker)
|
||||
// even if their ID matches initiatedByFieldExpertId on the blame —
|
||||
// those are FILE_MAKER files, accessible via the FILE_MAKER role only.
|
||||
const isV4V5Blame = !!(linkedBlame as any)?.isMadeByFileMaker;
|
||||
if (isV4V5Blame) {
|
||||
throw new ForbiddenException(
|
||||
"Field experts can only access V3 expert-initiated files.",
|
||||
);
|
||||
}
|
||||
if (!claimCaseInitiatedByFieldExpert(claim, actor, linkedBlame)) {
|
||||
throw new ForbiddenException("This claim was not initiated by you.");
|
||||
throw new ForbiddenException("This claim is not accessible to you.");
|
||||
}
|
||||
}
|
||||
// Fall through to the detail build below (skip the damage-expert gate)
|
||||
} else {
|
||||
} else if (actor.role !== RoleEnum.FILE_MAKER) {
|
||||
const isResendPending =
|
||||
claim.status === ClaimCaseStatus.WAITING_FOR_USER_RESEND;
|
||||
|
||||
@@ -4397,6 +4930,24 @@ export class ExpertClaimService {
|
||||
if (fromBlame) vehiclePayload = fromBlame;
|
||||
}
|
||||
|
||||
// Patch up carName/carModel when they equal carType (invalid — expert copied the enum value).
|
||||
if (vehiclePayload) {
|
||||
const carType: string | undefined = vehiclePayload.carType;
|
||||
if (
|
||||
carType &&
|
||||
((!vehiclePayload.carName || vehiclePayload.carName === carType) ||
|
||||
(!vehiclePayload.carModel || vehiclePayload.carModel === carType))
|
||||
) {
|
||||
const fromInquiry = this.vehicleNamesFromClaimInquiries(claim);
|
||||
if (!vehiclePayload.carName || vehiclePayload.carName === carType) {
|
||||
vehiclePayload = { ...vehiclePayload, carName: fromInquiry.carName };
|
||||
}
|
||||
if (!vehiclePayload.carModel || vehiclePayload.carModel === carType) {
|
||||
vehiclePayload = { ...vehiclePayload, carModel: fromInquiry.carModel };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blameFileContext = blameLean
|
||||
? this.blameFileContextForExpert(blameLean)
|
||||
: {};
|
||||
@@ -4449,7 +5000,11 @@ export class ExpertClaimService {
|
||||
let evaluationForApi: Record<string, unknown> | undefined;
|
||||
if (enrichedEvaluation) {
|
||||
evaluationForApi = {};
|
||||
if (isFactorValidationPending) {
|
||||
// damageExpertReply / damageExpertReplyFinal: include whenever present.
|
||||
// Previously these were gated on isFactorValidationPending, which hid the
|
||||
// expert assessment once the claim moved to user-side statuses such as
|
||||
// INSURER_REVIEW_AWAITING_OWNER_SIGN. Historical assessment data should
|
||||
// always be visible once submitted.
|
||||
if (enrichedEvaluation.damageExpertReply !== undefined) {
|
||||
evaluationForApi.damageExpertReply =
|
||||
enrichedEvaluation.damageExpertReply;
|
||||
@@ -4458,7 +5013,6 @@ export class ExpertClaimService {
|
||||
evaluationForApi.damageExpertReplyFinal =
|
||||
enrichedEvaluation.damageExpertReplyFinal;
|
||||
}
|
||||
}
|
||||
if (enrichedEvaluation.ownerInsurerApproval !== undefined) {
|
||||
evaluationForApi.ownerInsurerApproval =
|
||||
enrichedEvaluation.ownerInsurerApproval;
|
||||
@@ -4467,9 +5021,14 @@ export class ExpertClaimService {
|
||||
evaluationForApi.ownerPricedPartsApproval =
|
||||
enrichedEvaluation.ownerPricedPartsApproval;
|
||||
}
|
||||
if (isDamageExpertPhase && enrichedEvaluation.priceDrop !== undefined) {
|
||||
// priceDrop: include whenever present, not only during the expert phase.
|
||||
if (enrichedEvaluation.priceDrop !== undefined) {
|
||||
evaluationForApi.priceDrop = enrichedEvaluation.priceDrop;
|
||||
}
|
||||
// objection: include when present so experts can see the disputed parts and any attached invoices.
|
||||
if (enrichedEvaluation.objection !== undefined) {
|
||||
evaluationForApi.objection = enrichedEvaluation.objection;
|
||||
}
|
||||
if (Object.keys(evaluationForApi).length === 0) {
|
||||
evaluationForApi = undefined;
|
||||
}
|
||||
@@ -4512,9 +5071,11 @@ export class ExpertClaimService {
|
||||
carAngles,
|
||||
damagedParts,
|
||||
awaitingFactorValidation: isFactorValidationPending,
|
||||
evaluation: evaluationForApi as
|
||||
requiresFileMakerApproval: !!(claim as any).requiresFileMakerApproval,
|
||||
evaluation:
|
||||
(evaluationForApi as
|
||||
| ClaimDetailV2ResponseDto["evaluation"]
|
||||
| undefined,
|
||||
| undefined) || (enrichedEvaluation as any),
|
||||
videoCapture,
|
||||
blameCase: blameCaseForApi,
|
||||
blameExpertDecision,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -62,7 +63,7 @@ class InPersonVisitV2Dto {
|
||||
@Controller("v2/expert-claim")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_EXPERT)
|
||||
@Roles(RoleEnum.DAMAGE_EXPERT, RoleEnum.FIELD_EXPERT, RoleEnum.FILE_REVIEWER, RoleEnum.FILE_MAKER)
|
||||
export class ExpertClaimV2Controller {
|
||||
constructor(
|
||||
private readonly expertClaimService: ExpertClaimService,
|
||||
@@ -117,6 +118,21 @@ export class ExpertClaimV2Controller {
|
||||
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
|
||||
}
|
||||
|
||||
@Get("inner-parts-catalog")
|
||||
@ApiOperation({
|
||||
summary: "Get inner parts catalog",
|
||||
description: "Returns the full list of inner car parts from the static catalog.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Inner parts catalog (object keyed by part name)" })
|
||||
async getInnerPartsCatalog() {
|
||||
const raw = await readFile(`${process.cwd()}/src/static/car-part.json`, "utf-8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
@Get("branches")
|
||||
@ApiOperation({
|
||||
summary: "List insurer branches for this damage expert (V2)",
|
||||
@@ -153,7 +169,7 @@ export class ExpertClaimV2Controller {
|
||||
@ApiOperation({
|
||||
summary: "Get claim request detail for damage expert",
|
||||
description:
|
||||
"Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` during damage review, `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
|
||||
"Returns full claim details including captured images, required documents, damage selections, `evaluation.priceDrop` (included whenever saved, regardless of current status), `videoCapture` (from claim-video-capture via media.videoCaptureId), and `blameCase` (linked blameCases document with party video/voice URLs like expert-blame detail). `evaluation.damageExpertReply` / `damageExpertReplyFinal` are always returned once submitted. Allowed when status is WAITING_FOR_DAMAGE_EXPERT (if locked, only the locking expert) or when awaiting factor validation.",
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
async getClaimDetailV2(
|
||||
@@ -281,7 +297,20 @@ export class ExpertClaimV2Controller {
|
||||
description:
|
||||
"Claim must be locked by this expert (`EXPERT_REVIEWING`). Sets `WAITING_FOR_USER_RESEND`, `USER_EXPERT_RESEND`, `NEEDS_REVISION`, clears the lock, and stores `evaluation.damageExpertResend`. " +
|
||||
"Owner completes via document/capture endpoints or `POST .../expert-resend/acknowledge` when only instructions were given.\n\n" +
|
||||
"**One resend per claim lifecycle:** if the owner has already fulfilled a resend (`damageExpertResend.fulfilledAt`), this endpoint returns **400**—the expert may only submit a priced reply or request in-person visit afterward.",
|
||||
"**One resend per claim lifecycle:** if the owner has already fulfilled a resend (`damageExpertResend.fulfilledAt`), this endpoint returns **422** with `errorCode: DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED`—the expert may only submit a priced reply or request in-person visit afterward.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 422,
|
||||
description:
|
||||
"Business rule violation: resend limit exceeded. " +
|
||||
"`errorCode: DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED` — the owner has already fulfilled a prior resend request for this claim.",
|
||||
schema: {
|
||||
example: {
|
||||
errorCode: "DAMAGE_EXPERT_RESEND_LIMIT_EXCEEDED",
|
||||
message:
|
||||
"The owner has already fulfilled a damage-expert resend for this claim. You cannot request another resend.",
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: ClaimSubmitResendV2Dto })
|
||||
|
||||
@@ -81,3 +81,19 @@ export class CreateClaimExpertByInsurerDto extends CreateInsurerExpertDto {
|
||||
})
|
||||
role?: RoleEnum.DAMAGE_EXPERT;
|
||||
}
|
||||
|
||||
export class CreateFileMakerByInsurerDto extends CreateInsurerExpertDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: [RoleEnum.FILE_MAKER],
|
||||
default: RoleEnum.FILE_MAKER,
|
||||
})
|
||||
role?: RoleEnum.FILE_MAKER;
|
||||
}
|
||||
|
||||
export class CreateFileReviewerByInsurerDto extends CreateInsurerExpertDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: [RoleEnum.FILE_REVIEWER],
|
||||
default: RoleEnum.FILE_REVIEWER,
|
||||
})
|
||||
role?: RoleEnum.FILE_REVIEWER;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ import { CreateBranchDto } from "src/client/dto/create-branch.dto";
|
||||
import {
|
||||
CreateBlameExpertByInsurerDto,
|
||||
CreateClaimExpertByInsurerDto,
|
||||
CreateFileMakerByInsurerDto,
|
||||
CreateFileReviewerByInsurerDto,
|
||||
} from "./dto/create-insurer-expert.dto";
|
||||
|
||||
@Controller("expert-insurer")
|
||||
@@ -97,20 +99,32 @@ export class ExpertInsurerController {
|
||||
|
||||
@Put("branches/:branchId/status")
|
||||
@ApiParam({ name: "branchId" })
|
||||
@ApiQuery({ name: "isActive", type: Boolean })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { isActive: { type: "boolean" } },
|
||||
required: ["isActive"],
|
||||
},
|
||||
})
|
||||
async setBranchStatus(
|
||||
@CurrentUser() insurer,
|
||||
@Param("branchId") branchId: string,
|
||||
@Query("isActive") isActive: string,
|
||||
@Body("isActive") isActive: unknown,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
const normalized = String(isActive).trim().toLowerCase();
|
||||
// Accept native boolean (JSON body) or string coercion (legacy query/form usage)
|
||||
let active: boolean;
|
||||
if (typeof isActive === "boolean") {
|
||||
active = isActive;
|
||||
} else {
|
||||
const normalized = String(isActive ?? "").trim().toLowerCase();
|
||||
if (!["true", "false", "1", "0", "yes", "no"].includes(normalized)) {
|
||||
throw new BadRequestException("isActive must be true/false");
|
||||
throw new BadRequestException("isActive must be a boolean");
|
||||
}
|
||||
active = ["true", "1", "yes"].includes(normalized);
|
||||
}
|
||||
const active = ["true", "1", "yes"].includes(normalized);
|
||||
return this.expertInsurerService.setBranchActive(
|
||||
insurer.clientKey,
|
||||
branchId,
|
||||
@@ -142,6 +156,32 @@ export class ExpertInsurerController {
|
||||
return this.expertInsurerService.addClaimExpert(insurer.clientKey, body);
|
||||
}
|
||||
|
||||
@Post("experts/file-maker")
|
||||
@ApiBody({ type: CreateFileMakerByInsurerDto })
|
||||
@ApiOperation({ summary: "Create a FileMaker account under this insurer" })
|
||||
async addFileMaker(
|
||||
@CurrentUser() insurer,
|
||||
@Body() body: CreateFileMakerByInsurerDto,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
return this.expertInsurerService.addFileMaker(insurer.clientKey, body);
|
||||
}
|
||||
|
||||
@Post("experts/file-reviewer")
|
||||
@ApiBody({ type: CreateFileReviewerByInsurerDto })
|
||||
@ApiOperation({ summary: "Create a FileReviewer account under this insurer" })
|
||||
async addFileReviewer(
|
||||
@CurrentUser() insurer,
|
||||
@Body() body: CreateFileReviewerByInsurerDto,
|
||||
) {
|
||||
if (!insurer) {
|
||||
throw new UnauthorizedException("Could not identify the current user.");
|
||||
}
|
||||
return this.expertInsurerService.addFileReviewer(insurer.clientKey, body);
|
||||
}
|
||||
|
||||
@ApiQuery({ name: "page", type: Number })
|
||||
@ApiQuery({ name: "response_count", type: Number })
|
||||
@Get("experts/list")
|
||||
@@ -200,6 +240,23 @@ export class ExpertInsurerController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("files/:publicId/timeline")
|
||||
@ApiParam({ name: "publicId" })
|
||||
@ApiOperation({
|
||||
summary: "Activity timeline for a case",
|
||||
description:
|
||||
"Returns a chronological list of all history events for the blame and/or claim associated with the given publicId. Each event has: source, type, timestamp, actor, metadata.",
|
||||
})
|
||||
async getFileTimeline(
|
||||
@CurrentUser() insurer,
|
||||
@Param("publicId") publicId: string,
|
||||
) {
|
||||
return await this.expertInsurerService.getFileTimeline(
|
||||
insurer.clientKey,
|
||||
publicId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("files/:publicId")
|
||||
@ApiParam({ name: "publicId" })
|
||||
async getFileDetailsByPublicId(
|
||||
|
||||
@@ -46,5 +46,6 @@ import { HashModule } from "src/utils/hash/hash.module";
|
||||
],
|
||||
providers: [ExpertInsurerService],
|
||||
controllers: [ExpertInsurerController],
|
||||
exports: [ExpertInsurerService],
|
||||
})
|
||||
export class ExpertInsurerModule {}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { BranchDbService } from "src/client/entities/db-service/branch.db.servic
|
||||
import {
|
||||
CreateBlameExpertByInsurerDto,
|
||||
CreateClaimExpertByInsurerDto,
|
||||
CreateFileMakerByInsurerDto,
|
||||
CreateFileReviewerByInsurerDto,
|
||||
CreateInsurerExpertDto,
|
||||
} from "./dto/create-insurer-expert.dto";
|
||||
import {
|
||||
@@ -25,6 +27,8 @@ import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-
|
||||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||||
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||||
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
|
||||
import { FileMakerDbService } from "src/users/entities/db-service/file-maker.db.service";
|
||||
import { FileReviewerDbService } from "src/users/entities/db-service/file-reviewer.db.service";
|
||||
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
||||
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
|
||||
import { HashService } from "src/utils/hash/hash.service";
|
||||
@@ -71,6 +75,7 @@ import {
|
||||
extractExpertNamesFromBlame,
|
||||
extractExpertNamesFromClaim,
|
||||
} from "./helper/insurer.helper";
|
||||
import { getEventFaLabel } from "./helper/timeline-fa-labels";
|
||||
import { buildEnrichedDamagedParts } from "src/expert-claim/dto/claim-damaged-part.enricher";
|
||||
|
||||
@Injectable()
|
||||
@@ -89,6 +94,8 @@ export class ExpertInsurerService {
|
||||
private readonly videoCaptureDbService: VideoCaptureDbService,
|
||||
private readonly clientDbService: ClientDbService,
|
||||
private readonly claimSignDbService: ClaimSignDbService,
|
||||
private readonly fileMakerDbService: FileMakerDbService,
|
||||
private readonly fileReviewerDbService: FileReviewerDbService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -1328,6 +1335,12 @@ export class ExpertInsurerService {
|
||||
(f) => f.unifiedFileStatus === query.unifiedStatus,
|
||||
);
|
||||
}
|
||||
const { fromDate, toDate } = parseListDateRange(query.startDate, query.endDate);
|
||||
if (fromDate || toDate) {
|
||||
files = files.filter((f) =>
|
||||
isInListDateRange(f.createdAt, fromDate, toDate),
|
||||
);
|
||||
}
|
||||
|
||||
const paged = applyListQueryV2(
|
||||
files,
|
||||
@@ -1513,6 +1526,7 @@ export class ExpertInsurerService {
|
||||
ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||||
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||||
ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER,
|
||||
ClaimCaseStatus.WAITING_FOR_USER_RESEND,
|
||||
ClaimCaseStatus.WAITING_FOR_DAMAGE_EXPERT,
|
||||
ClaimCaseStatus.EXPERT_REVIEWING,
|
||||
@@ -1651,6 +1665,108 @@ export class ExpertInsurerService {
|
||||
);
|
||||
}
|
||||
|
||||
async addFileMaker(
|
||||
insurerClientKey: string,
|
||||
payload: CreateFileMakerByInsurerDto,
|
||||
) {
|
||||
const clientObjectId = this.getClientId(insurerClientKey);
|
||||
const branch = await this.assertBranchBelongsToClient(
|
||||
payload.branchId,
|
||||
clientObjectId,
|
||||
);
|
||||
|
||||
const email = payload.email.trim().toLowerCase();
|
||||
const existing = await this.fileMakerDbService.findOne({ email });
|
||||
if (existing) {
|
||||
throw new ConflictException("A FileMaker with this email already exists.");
|
||||
}
|
||||
|
||||
const nationalCode = payload.nationalCode.trim();
|
||||
const existingByNational = await this.fileMakerDbService.findOne({
|
||||
nationalCode,
|
||||
});
|
||||
if (existingByNational) {
|
||||
throw new ConflictException(
|
||||
"A FileMaker with this national code already exists.",
|
||||
);
|
||||
}
|
||||
|
||||
const hashedPassword = await this.hashService.hash(payload.password);
|
||||
const created = await this.fileMakerDbService.create({
|
||||
...payload,
|
||||
email,
|
||||
nationalCode,
|
||||
password: hashedPassword,
|
||||
role: RoleEnum.FILE_MAKER,
|
||||
clientKey: clientObjectId,
|
||||
branchId: new Types.ObjectId(payload.branchId),
|
||||
});
|
||||
|
||||
return {
|
||||
fileMaker: this.sanitizeExpertResponse(created),
|
||||
branch: {
|
||||
_id: (branch as any)._id,
|
||||
name: branch.name,
|
||||
code: branch.code,
|
||||
city: branch.city,
|
||||
state: branch.state,
|
||||
address: branch.address,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async addFileReviewer(
|
||||
insurerClientKey: string,
|
||||
payload: CreateFileReviewerByInsurerDto,
|
||||
) {
|
||||
const clientObjectId = this.getClientId(insurerClientKey);
|
||||
const branch = await this.assertBranchBelongsToClient(
|
||||
payload.branchId,
|
||||
clientObjectId,
|
||||
);
|
||||
|
||||
const email = payload.email.trim().toLowerCase();
|
||||
const existing = await this.fileReviewerDbService.findOne({ email });
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
"A FileReviewer with this email already exists.",
|
||||
);
|
||||
}
|
||||
|
||||
const nationalCode = payload.nationalCode.trim();
|
||||
const existingByNational = await this.fileReviewerDbService.findOne({
|
||||
nationalCode,
|
||||
});
|
||||
if (existingByNational) {
|
||||
throw new ConflictException(
|
||||
"A FileReviewer with this national code already exists.",
|
||||
);
|
||||
}
|
||||
|
||||
const hashedPassword = await this.hashService.hash(payload.password);
|
||||
const created = await this.fileReviewerDbService.create({
|
||||
...payload,
|
||||
email,
|
||||
nationalCode,
|
||||
password: hashedPassword,
|
||||
role: RoleEnum.FILE_REVIEWER,
|
||||
clientKey: clientObjectId,
|
||||
branchId: new Types.ObjectId(payload.branchId),
|
||||
});
|
||||
|
||||
return {
|
||||
fileReviewer: this.sanitizeExpertResponse(created),
|
||||
branch: {
|
||||
_id: (branch as any)._id,
|
||||
name: branch.name,
|
||||
code: branch.code,
|
||||
city: branch.city,
|
||||
state: branch.state,
|
||||
address: branch.address,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive statistics for all experts of a client
|
||||
* Returns:
|
||||
@@ -1973,4 +2089,77 @@ export class ExpertInsurerService {
|
||||
waiting_for_documents_resend: counts.WAITING_FOR_DOCUMENT_RESEND ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getFileTimeline(
|
||||
insurerId: string,
|
||||
publicId: string,
|
||||
): Promise<{ publicId: string; events: object[] }> {
|
||||
if (!publicId?.trim()) {
|
||||
throw new BadRequestException("publicId is required");
|
||||
}
|
||||
|
||||
const id = this.getClientId(insurerId);
|
||||
const [blameFiles, claimFiles] = await Promise.all([
|
||||
this.getClientBlameFiles(id),
|
||||
this.getClientClaimFiles(id),
|
||||
]);
|
||||
|
||||
const blame = blameFiles.find(
|
||||
(b) => String((b as any).publicId) === publicId,
|
||||
);
|
||||
const claim = claimFiles.find(
|
||||
(c) => String((c as any).publicId) === publicId,
|
||||
);
|
||||
|
||||
if (!blame && !claim) {
|
||||
throw new NotFoundException("File not found for this publicId");
|
||||
}
|
||||
|
||||
const events: object[] = [];
|
||||
|
||||
if (blame) {
|
||||
const blameDoc = await this.blameRequestDbService.findById(
|
||||
String((blame as any)._id),
|
||||
);
|
||||
const blameHistory: any[] = (blameDoc as any)?.history ?? [];
|
||||
for (const ev of blameHistory) {
|
||||
events.push({
|
||||
source: "blame",
|
||||
type: ev.type,
|
||||
faLabel: getEventFaLabel(ev),
|
||||
timestamp: ev.timestamp,
|
||||
actor: ev.actor ?? null,
|
||||
metadata: ev.metadata ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (claim) {
|
||||
const claimId = String((claim as any)._id);
|
||||
const rows = (await this.claimCaseDbService.find(
|
||||
{ _id: new Types.ObjectId(claimId) },
|
||||
{ lean: true, select: "history createdAt" },
|
||||
)) as Record<string, unknown>[];
|
||||
const claimHistory: any[] = (rows[0] as any)?.history ?? [];
|
||||
for (const ev of claimHistory) {
|
||||
events.push({
|
||||
source: "claim",
|
||||
type: ev.type,
|
||||
faLabel: getEventFaLabel(ev),
|
||||
timestamp: ev.timestamp,
|
||||
actor: ev.actor ?? null,
|
||||
metadata: ev.metadata ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
events.sort((a: any, b: any) => {
|
||||
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
||||
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
||||
return ta - tb;
|
||||
});
|
||||
|
||||
// Resolve insurer's client name for context if needed — just return raw events
|
||||
return { publicId, events };
|
||||
}
|
||||
}
|
||||
|
||||
196
src/expert-insurer/helper/timeline-fa-labels.ts
Normal file
196
src/expert-insurer/helper/timeline-fa-labels.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Persian (Farsi) display labels for every timeline event `type` that can
|
||||
* appear in a blame or claim history array.
|
||||
*
|
||||
* For generic `STEP_COMPLETED` / `V3_STEP_COMPLETED` events the label is
|
||||
* derived from the `metadata.stepKey` value; those keys are listed at the
|
||||
* bottom under STEP_KEY_FA_LABELS.
|
||||
*
|
||||
* Usage:
|
||||
* const label = EVENT_TYPE_FA_LABELS[event.type]
|
||||
* ?? resolveStepCompletedFaLabel(event)
|
||||
* ?? event.type;
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blame-phase events (recorded on BlameRequest.history)
|
||||
// ---------------------------------------------------------------------------
|
||||
export const EVENT_TYPE_FA_LABELS: Record<string, string> = {
|
||||
// File creation
|
||||
FILE_CREATED_BY_REGISTRAR: "پرونده توسط ثبتکننده ایجاد شد",
|
||||
FILE_CREATED_BY_FIELD_EXPERT: "پرونده توسط کارشناس میدانی ایجاد شد",
|
||||
|
||||
// Expert link / OTP flow
|
||||
LINK_SENT: "لینک ارسال شد",
|
||||
PARTY_OTP_SENT: "کد تأیید ارسال شد",
|
||||
PARTY_OTP_VERIFIED: "کد تأیید تأیید شد",
|
||||
PARTY_OTPS_VERIFIED: "کدهای تأیید هر دو طرف تأیید شدند",
|
||||
SECOND_PARTY_OTP_SENT: "کد تأیید طرف دوم ارسال شد",
|
||||
SECOND_PARTY_OTP_VERIFIED_ADVANCED: "کد طرف دوم تأیید و پیشرفت انجام شد",
|
||||
|
||||
// Confession / accident type
|
||||
FIRST_BLAME_CONFESSION_SUBMITTED: "اقرار اولیه طرف اول ثبت شد",
|
||||
CAR_BODY_ACCIDENT_TYPE_SUBMITTED: "نوع تصادف بدنه خودرو ثبت شد",
|
||||
AUTO_ADVANCED_TO_CAR_BODY_FORM: "پیشرفت خودکار به فرم بدنه خودرو",
|
||||
AUTO_CONFESSION_SKIPPED: "مرحله اقرار بهصورت خودکار رد شد",
|
||||
|
||||
// First video
|
||||
FIRST_VIDEO_UPLOADED: "ویدیوی اول بارگذاری شد",
|
||||
|
||||
// Second party invitation
|
||||
SECOND_PARTY_INVITED: "طرف دوم دعوت شد",
|
||||
|
||||
// Accident fields / expert in-person completion
|
||||
ACCIDENT_FIELDS_SAVED_ADVANCED_TO_SIGNATURES:
|
||||
"اطلاعات تصادف ذخیره و به مرحله امضاها پیشرفت شد",
|
||||
EXPERT_COMPLETED_CAR_BODY_FORM_V2: "کارشناس فرم بدنه خودرو را تکمیل کرد",
|
||||
EXPERT_COMPLETED_THIRD_PARTY_FORM_V2: "کارشناس فرم شخص ثالث را تکمیل کرد",
|
||||
EXPERT_ADDED_LOCATIONS_V2: "کارشناس موقعیت مکانی را ثبت کرد",
|
||||
EXPERT_UPLOADED_VIDEO_V2: "کارشناس ویدیو را بارگذاری کرد",
|
||||
EXPERT_UPLOADED_VOICE_V2: "کارشناس صدا را بارگذاری کرد",
|
||||
|
||||
// Party rejection / disagreement
|
||||
PARTY_REJECTED_EXPERT_DECISION: "طرف حساب با نظر کارشناس مخالفت کرد",
|
||||
PARTIES_DISAGREED_ON_EXPERT_DECISION: "طرفین با نظر کارشناس توافق نکردند",
|
||||
|
||||
// Document resend (blame)
|
||||
BLAME_DOCUMENT_RESEND_STARTED: "درخواست ارسال مجدد مدارک پرونده شروع شد",
|
||||
BLAME_DOCUMENT_RESEND_CLEARED: "ارسال مجدد مدارک پرونده پاکسازی شد",
|
||||
|
||||
// Expert assignment (blame)
|
||||
BLAME_ASSIGNED: "پرونده به کارشناس تخصیص داده شد",
|
||||
AUTO_ASSIGNED_TO_EXPERT: "پرونده بهصورت خودکار به کارشناس تخصیص یافت",
|
||||
|
||||
// Auto-assignment helpers
|
||||
READY_FOR_EXPERT_EVALUATION: "پرونده آماده ارزیابی کارشناس شد",
|
||||
SECOND_PARTY_DESCRIPTION_COMPLETED: "توضیحات طرف دوم تکمیل شد",
|
||||
|
||||
// V3 blame steps
|
||||
V3_PARTY_SIGNATURE_RECORDED: "امضای طرف ثبت شد",
|
||||
V3_ACCIDENT_FIELDS_SAVED: "اطلاعات تصادف ثبت شد",
|
||||
V3_BLAME_ACCIDENT_VIDEO_UPLOADED: "ویدیوی تصادف بارگذاری شد",
|
||||
V3_PARTY_VOICE_UPLOADED: "صدای طرف بارگذاری شد",
|
||||
V3_PARTY_LOCATION_SAVED: "موقعیت مکانی طرف ذخیره شد",
|
||||
V3_PARTY_DESCRIPTION_SAVED: "توضیحات طرف ذخیره شد",
|
||||
V3_CAR_BODY_ACCIDENT_TYPE_SAVED: "نوع تصادف بدنه خودرو ذخیره شد",
|
||||
|
||||
// V5 blame steps
|
||||
V5_BLAME_ACCIDENT_VIDEO_UPLOADED: "ویدیوی تصادف (نسخه ۵) بارگذاری شد",
|
||||
V5_FILE_MAKER_APPROVED: "پروندهساز پرونده را تأیید کرد",
|
||||
V5_FILE_MAKER_REJECTED: "پروندهساز پرونده را رد کرد",
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claim-phase events (recorded on ClaimCase.history)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Claim creation
|
||||
CLAIM_CREATED: "پرونده خسارت ایجاد شد",
|
||||
|
||||
// Expert assignment (claim)
|
||||
CLAIM_ASSIGNED: "پرونده خسارت به کارشناس ارزیابی تخصیص داده شد",
|
||||
|
||||
// Expert actions on claim
|
||||
EXPERT_RESEND_REQUESTED: "کارشناس درخواست ارسال مجدد مدارک داد",
|
||||
EXPERT_RESEND_FULFILLED: "مدارک درخواستی ارسال شد",
|
||||
IN_PERSON_VISIT_REQUESTED: "کارشناس بازدید حضوری درخواست کرد",
|
||||
EXPERT_DAMAGED_PARTS_UPDATED: "کارشناس قطعات آسیبدیده را بهروزرسانی کرد",
|
||||
|
||||
// Workflow steps (generic)
|
||||
STEP_COMPLETED: "مرحله تکمیل شد",
|
||||
V3_STEP_COMPLETED: "مرحله تکمیل شد",
|
||||
|
||||
// Documents & media
|
||||
DOCUMENT_UPLOADED: "مدرک بارگذاری شد",
|
||||
VIDEO_CAPTURE_UPLOADED: "تصویر ویدیویی بارگذاری شد",
|
||||
ALL_FACTORS_UPLOADED_PENDING_VALIDATION:
|
||||
"تمام فاکتورها بارگذاری شدند و در انتظار تأیید هستند",
|
||||
|
||||
// User responses
|
||||
USER_OBJECTION_SUBMITTED: "اعتراض کاربر ثبت شد",
|
||||
USER_RATING_SUBMITTED: "امتیاز کاربر ثبت شد",
|
||||
|
||||
// Owner insurer approval
|
||||
OWNER_SIGNED_INSURER_APPROVAL: "صاحب خودرو قرارداد بیمه را امضا کرد",
|
||||
OWNER_REJECTED_INSURER_APPROVAL_PRICING: "صاحب خودرو قیمتگذاری بیمه را رد کرد",
|
||||
OWNER_SIGNED_PRICED_PARTS_PENDING_FACTOR_UPLOAD:
|
||||
"صاحب خودرو قطعات قیمتگذاریشده را امضا کرد و در انتظار بارگذاری فاکتور",
|
||||
OWNER_REJECTED_PRICED_PARTS_BEFORE_FACTORS:
|
||||
"صاحب خودرو قطعات قیمتگذاریشده را پیش از فاکتور رد کرد",
|
||||
|
||||
// Fanavaran sync
|
||||
FANAVARAN_AUTO_SUBMIT_SUCCEEDED: "ارسال خودکار به فناوران موفق بود",
|
||||
FANAVARAN_AUTO_SUBMIT_FAILED: "ارسال خودکار به فناوران ناموفق بود",
|
||||
FANAVARAN_EARLY_AUTO_SUBMIT_SUCCEEDED: "ارسال زودهنگام خودکار به فناوران موفق بود",
|
||||
FANAVARAN_EARLY_AUTO_SUBMIT_FAILED: "ارسال زودهنگام خودکار به فناوران ناموفق بود",
|
||||
FANAVARAN_EXPERTISE_AUTO_SUBMIT_SUCCEEDED:
|
||||
"ارسال خودکار کارشناسی به فناوران موفق بود",
|
||||
FANAVARAN_EXPERTISE_AUTO_SUBMIT_FAILED:
|
||||
"ارسال خودکار کارشناسی به فناوران ناموفق بود",
|
||||
FANAVARAN_ATTACHMENT_AUTO_SUBMIT_SUCCEEDED:
|
||||
"ارسال خودکار پیوست به فناوران موفق بود",
|
||||
FANAVARAN_ATTACHMENT_AUTO_SUBMIT_FAILED:
|
||||
"ارسال خودکار پیوست به فناوران ناموفق بود",
|
||||
FANAVARAN_DAMAGE_CASE_AUTO_SUBMIT_SUCCEEDED:
|
||||
"ارسال خودکار پرونده خسارت به فناوران موفق بود",
|
||||
FANAVARAN_DAMAGE_CASE_AUTO_SUBMIT_FAILED:
|
||||
"ارسال خودکار پرونده خسارت به فناوران ناموفق بود",
|
||||
|
||||
// V5 claim
|
||||
V5_HELD_FOR_FILE_MAKER_APPROVAL: "پرونده در انتظار تأیید پروندهساز متوقف شد",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persian labels for `metadata.stepKey` inside STEP_COMPLETED events
|
||||
// ---------------------------------------------------------------------------
|
||||
export const STEP_KEY_FA_LABELS: Record<string, string> = {
|
||||
// Claim workflow steps
|
||||
CLAIM_CREATED: "ایجاد پرونده خسارت",
|
||||
SELECT_OUTER_PARTS: "انتخاب قطعات بیرونی آسیبدیده",
|
||||
SELECT_OTHER_PARTS: "انتخاب سایر اطلاعات و قطعات",
|
||||
CAPTURE_PART_DAMAGES: "عکسبرداری از آسیبهای قطعات",
|
||||
UPLOAD_REQUIRED_DOCUMENTS: "بارگذاری مدارک مورد نیاز",
|
||||
USER_SUBMISSION_COMPLETE: "ارسال اطلاعات توسط کاربر",
|
||||
USER_EXPERT_RESEND: "ارسال مجدد مدارک توسط کاربر",
|
||||
EXPERT_DAMAGE_ASSESSMENT: "ارزیابی خسارت توسط کارشناس",
|
||||
EXPERT_FINAL_REPLY: "پاسخ نهایی کارشناس",
|
||||
EXPERT_COST_EVALUATION: "ارزیابی هزینه توسط کارشناس",
|
||||
OWNER_UPLOAD_FACTOR_DOCUMENTS: "بارگذاری فاکتورهای تعمیر توسط مالک",
|
||||
INSURER_REVIEW: "بررسی توسط بیمهگر",
|
||||
CLAIM_COMPLETED: "پرونده خسارت تکمیل شد",
|
||||
};
|
||||
|
||||
/**
|
||||
* For `STEP_COMPLETED` and `V3_STEP_COMPLETED` events whose final label
|
||||
* depends on the `metadata.stepKey` value, this function returns the
|
||||
* most specific Persian label available.
|
||||
*/
|
||||
export function resolveStepCompletedFaLabel(event: {
|
||||
type: string;
|
||||
metadata?: any;
|
||||
}): string | undefined {
|
||||
if (
|
||||
event.type !== "STEP_COMPLETED" &&
|
||||
event.type !== "V3_STEP_COMPLETED"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const stepKey: string | undefined = event.metadata?.stepKey;
|
||||
if (stepKey && STEP_KEY_FA_LABELS[stepKey]) {
|
||||
return STEP_KEY_FA_LABELS[stepKey];
|
||||
}
|
||||
return "مرحله تکمیل شد";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the best Persian label for any timeline event.
|
||||
*/
|
||||
export function getEventFaLabel(event: {
|
||||
type: string;
|
||||
metadata?: any;
|
||||
}): string {
|
||||
return (
|
||||
resolveStepCompletedFaLabel(event) ??
|
||||
EVENT_TYPE_FA_LABELS[event.type] ??
|
||||
event.type
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,72 @@ export const FANAVARAN_REMOTE_LOOKUPS: FanavaranRemoteLookupDefinition[] = [
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/accident-culprit-type`,
|
||||
cacheFile: "accident-culprit-type.json",
|
||||
},
|
||||
{
|
||||
name: "inspection-place",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/inspection-place`,
|
||||
cacheFile: "inspection-place.json",
|
||||
},
|
||||
{
|
||||
name: "drop-amount-status",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/drop-amount-status`,
|
||||
cacheFile: "drop-amount-status.json",
|
||||
},
|
||||
{
|
||||
name: "car-components",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/car-components`,
|
||||
cacheFile: "car-components.json",
|
||||
},
|
||||
{
|
||||
name: "accident-level",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/accident-level`,
|
||||
cacheFile: "accident-level.json",
|
||||
},
|
||||
{
|
||||
name: "expert-status",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/expert-status`,
|
||||
cacheFile: "expert-status.json",
|
||||
},
|
||||
{
|
||||
name: "vehicle-kinds",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/base-info/vehicle-kinds`,
|
||||
cacheFile: "vehicle-kinds.json",
|
||||
},
|
||||
{
|
||||
name: "person-role",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/person-role`,
|
||||
cacheFile: "person-role.json",
|
||||
},
|
||||
{
|
||||
name: "insurance-corp",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/insurance-corp`,
|
||||
cacheFile: "insurance-corp.json",
|
||||
},
|
||||
{
|
||||
name: "file-types",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/base-info/file-types`,
|
||||
cacheFile: "file-types.json",
|
||||
},
|
||||
// NEW LOOKUPS ADDED
|
||||
{
|
||||
name: "cities",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/base-info/cities`,
|
||||
cacheFile: "cities.json",
|
||||
},
|
||||
{
|
||||
name: "dmg-case-type",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/dmg-case-type`,
|
||||
cacheFile: "dmg-case-type.json",
|
||||
},
|
||||
{
|
||||
name: "dmg-history-status",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/car/code-list/dmg-case-history-status`,
|
||||
cacheFile: "dmg-history-status.json",
|
||||
},
|
||||
{
|
||||
name: "provinces",
|
||||
url: `${FANAVARAN_LOOKUP_BASE_URL}/common/base-info/Provinces`,
|
||||
cacheFile: "provinces.json",
|
||||
},
|
||||
];
|
||||
|
||||
export const TEJARAT_STATIC_ACCIDENT_FILES = {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { createHttpModuleOptions } from "src/core/config/http-proxy.factory";
|
||||
import { FanavaranLookupService } from "./fanavaran-lookup.service";
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
imports: [
|
||||
HttpModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: createHttpModuleOptions,
|
||||
}),
|
||||
],
|
||||
providers: [FanavaranLookupService],
|
||||
exports: [FanavaranLookupService],
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type FanavaranClientKey,
|
||||
} from "src/core/config/fanavaran-client.config";
|
||||
import {
|
||||
FANAVARAN_LOOKUP_BASE_URL,
|
||||
fanavaranLookupCacheDir,
|
||||
tejaratStaticAccidentFilePath,
|
||||
} from "./fanavaran-lookup.config";
|
||||
@@ -156,6 +157,10 @@ export class FanavaranLookupService {
|
||||
const appToken = await this.getAppToken(profile.auth);
|
||||
const authenticationToken = await this.login(appToken, profile.auth);
|
||||
|
||||
this.logger.log(
|
||||
`[${clientKey}] Calling Fanavaran lookup API: ${url}`,
|
||||
);
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(url, {
|
||||
headers: {
|
||||
@@ -169,6 +174,16 @@ export class FanavaranLookupService {
|
||||
}),
|
||||
);
|
||||
|
||||
const dataCount = Array.isArray(response.data)
|
||||
? response.data.length
|
||||
: typeof response.data === "object" && response.data !== null
|
||||
? Object.keys(response.data).length
|
||||
: 0;
|
||||
|
||||
this.logger.log(
|
||||
`[${clientKey}] Fanavaran lookup response status=${response.status} dataCount=${dataCount}`,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const message = isAxiosError(error)
|
||||
@@ -214,6 +229,162 @@ export class FanavaranLookupService {
|
||||
}
|
||||
}
|
||||
|
||||
async inquiryByVin(
|
||||
clientKey: FanavaranClientKey,
|
||||
vin: string,
|
||||
): Promise<unknown> {
|
||||
const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicles/inquiry-by-vin?vin=${encodeURIComponent(vin)}`;
|
||||
return this.fetchFromFanavaran(clientKey, url);
|
||||
}
|
||||
|
||||
async myPolicies(
|
||||
clientKey: FanavaranClientKey,
|
||||
nationalCode: string,
|
||||
insuranceLineId: number = 5,
|
||||
): Promise<unknown> {
|
||||
const url =
|
||||
`${FANAVARAN_LOOKUP_BASE_URL}/common/Policies/inquiry-my-policies` +
|
||||
`?InsuranceLineId=${insuranceLineId}` +
|
||||
`&NationalCode=${encodeURIComponent(nationalCode)}`;
|
||||
return this.fetchFromFanavaran(clientKey, url);
|
||||
}
|
||||
|
||||
async thirdPartyPolicyById(
|
||||
clientKey: FanavaranClientKey,
|
||||
policyId: number,
|
||||
): Promise<unknown> {
|
||||
const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/third-party-car-policies/${policyId}`;
|
||||
return this.fetchFromFanavaran(clientKey, url);
|
||||
}
|
||||
|
||||
async bodyPolicyById(
|
||||
clientKey: FanavaranClientKey,
|
||||
policyId: number,
|
||||
): Promise<unknown> {
|
||||
const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicle-hull-policies/${policyId}`;
|
||||
return this.fetchFromFanavaran(clientKey, url);
|
||||
}
|
||||
|
||||
async vehicleById(
|
||||
clientKey: FanavaranClientKey,
|
||||
vehicleId: number,
|
||||
versionNo: number = 1,
|
||||
): Promise<unknown> {
|
||||
const url = `${FANAVARAN_LOOKUP_BASE_URL}/car/vehicles/${vehicleId}?versionno=${versionNo}`;
|
||||
return this.fetchFromFanavaran(clientKey, url);
|
||||
}
|
||||
|
||||
async customerById(
|
||||
clientKey: FanavaranClientKey,
|
||||
customerId: number,
|
||||
): Promise<unknown> {
|
||||
const url = `${FANAVARAN_LOOKUP_BASE_URL}/common/customers/${customerId}`;
|
||||
return this.fetchFromFanavaran(clientKey, url);
|
||||
}
|
||||
|
||||
async inquiryByUniqueIdentifier(
|
||||
clientKey: FanavaranClientKey,
|
||||
params: {
|
||||
nationalCode: string;
|
||||
birthYear: number;
|
||||
birthMonth: number;
|
||||
birthDay: number;
|
||||
},
|
||||
): Promise<unknown> {
|
||||
const url =
|
||||
`${FANAVARAN_LOOKUP_BASE_URL}/common/parties/inquiry-by-unique-identifier` +
|
||||
`?NationalCode=${encodeURIComponent(params.nationalCode)}` +
|
||||
`&BirthYear=${params.birthYear}` +
|
||||
`&BirthMonth=${params.birthMonth}` +
|
||||
`&BirthDay=${params.birthDay}`;
|
||||
return this.fetchFromFanavaran(clientKey, url);
|
||||
}
|
||||
|
||||
async resolveInsuranceCorpId(
|
||||
clientKey: FanavaranClientKey,
|
||||
): Promise<number | null> {
|
||||
const caption = process.env.INSURANCE_CORP_ID?.trim();
|
||||
if (!caption) {
|
||||
this.logger.warn("resolveInsuranceCorpId: INSURANCE_CORP_ID env not set");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check resolved cache first
|
||||
const cachedId = await this.readCacheFile<number>(clientKey, "insurance-corp-id-resolved.json");
|
||||
if (cachedId !== null) {
|
||||
this.logger.log(`resolveInsuranceCorpId: using cached id=${cachedId} for "${caption}"`);
|
||||
return cachedId;
|
||||
}
|
||||
|
||||
// Try fetching the full list directly from Fanavaran
|
||||
let companies: unknown;
|
||||
const url = `${FANAVARAN_LOOKUP_BASE_URL}/common/code-list/insurance-corp`;
|
||||
try {
|
||||
companies = await this.fetchFromFanavaran(clientKey, url);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`resolveInsuranceCorpId: Fanavaran fetch failed, trying local lookup endpoint`,
|
||||
);
|
||||
// Fallback: call our own local lookup endpoint
|
||||
try {
|
||||
const localPort = process.env.PORT || 3000;
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(`http://localhost:${localPort}/lookups/fanavaran/insurance-corp`, {
|
||||
timeout: 10000,
|
||||
}),
|
||||
);
|
||||
companies = response.data;
|
||||
} catch (localError) {
|
||||
this.logger.error(
|
||||
`resolveInsuranceCorpId: both Fanavaran and local lookup failed`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(companies)) {
|
||||
this.logger.warn(
|
||||
`resolveInsuranceCorpId: insurance-corp response is not an array, type=${typeof companies}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`resolveInsuranceCorpId: got ${companies.length} companies, searching for "${caption}"`,
|
||||
);
|
||||
|
||||
const normalizedCaption = caption
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
|
||||
const match = companies.find((c: any) => {
|
||||
if (c?.IsActive !== 1) return false;
|
||||
if (typeof c?.Id !== "number") return false;
|
||||
const cCaption = typeof c?.Caption === "string" ? c.Caption : "";
|
||||
const normalized = cCaption.replace(/\s+/g, " ").toLowerCase().trim();
|
||||
return normalized.includes(normalizedCaption) || normalizedCaption.includes(normalized);
|
||||
});
|
||||
|
||||
if (!match) {
|
||||
this.logger.warn(
|
||||
`resolveInsuranceCorpId: no active company matching "${caption}". Available: ` +
|
||||
companies
|
||||
.filter((c: any) => c?.IsActive === 1)
|
||||
.map((c: any) => `${c.Caption} (${c.Id})`)
|
||||
.join(", "),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const corpId = match.Id as number;
|
||||
// Cache both the resolved id and the full list for future use
|
||||
await this.writeCacheFile(clientKey, "insurance-corp-id-resolved.json", corpId);
|
||||
await this.writeCacheFile(clientKey, "insurance-corp.json", companies);
|
||||
this.logger.log(`resolveInsuranceCorpId: resolved id=${corpId} for "${caption}"`);
|
||||
return corpId;
|
||||
}
|
||||
|
||||
mapFanavaranAccidentCausesToReasonOptions(
|
||||
causes: unknown,
|
||||
): { id: number; label: string; fanavaran: number }[] {
|
||||
@@ -238,4 +409,5 @@ export class FanavaranLookupService {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
@@ -54,11 +55,11 @@ export class FanavaranController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get(":client/claim-cases/:claimCaseId")
|
||||
@Get(":client/claim-cases/:claimCaseId/base-claim/preview")
|
||||
@ApiOperation({
|
||||
summary: "Preview Fanavaran submit payload (V2)",
|
||||
summary: "Preview Fanavaran base claim create payload",
|
||||
description:
|
||||
"Builds the third-party-car-financial-claims body from claimCases + blameCases without calling Fanavaran.",
|
||||
"Builds the GEN.03 third-party-car-financial-claims payload from local claimCases + blameCases without creating a Fanavaran claim.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "client",
|
||||
@@ -87,11 +88,11 @@ export class FanavaranController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":client/claim-cases/:claimCaseId")
|
||||
@Post(":client/claim-cases/:claimCaseId/base-claim/submit")
|
||||
@ApiOperation({
|
||||
summary: "Submit claim to Fanavaran (V2)",
|
||||
summary: "Submit Fanavaran base claim create request",
|
||||
description:
|
||||
"Authenticates with the selected client credentials and submits the mapped claimCases + blameCases payload.",
|
||||
"Authenticates with the selected tenant credentials and submits the GEN.03 base claim create request. Stores returned Id as claimId and ClaimNo when present.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "client",
|
||||
@@ -105,11 +106,173 @@ export class FanavaranController {
|
||||
async submit(
|
||||
@Param("client") client: string,
|
||||
@Param("claimCaseId") claimCaseId: string,
|
||||
@Body() body?: Record<string, unknown>,
|
||||
) {
|
||||
const clientKey = this.parseClientParam(client);
|
||||
return await this.claimRequestManagementService.submitFanavaranV2(
|
||||
claimCaseId,
|
||||
clientKey,
|
||||
body && Object.keys(body).length > 0 ? body : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":client/claim-cases/:claimCaseId/damage-case/preview")
|
||||
@ApiOperation({
|
||||
summary: "Preview Fanavaran damage-case payload",
|
||||
description:
|
||||
"Builds the GEN.12 damaged vehicle/person case payload without calling Fanavaran. Requires selected damaged parts; submit requires a Fanavaran claimId.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "client",
|
||||
description: "Fanavaran tenant key",
|
||||
enum: ["parsian", "tejaratno"],
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimCaseId",
|
||||
description: "Claim case MongoDB ObjectId",
|
||||
})
|
||||
async previewDamageCase(
|
||||
@Param("client") client: string,
|
||||
@Param("claimCaseId") claimCaseId: string,
|
||||
) {
|
||||
const clientKey = this.parseClientParam(client);
|
||||
return await this.claimRequestManagementService.previewFanavaranDamageCaseV2(
|
||||
claimCaseId,
|
||||
clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":client/claim-cases/:claimCaseId/damage-case/submit")
|
||||
@ApiOperation({
|
||||
summary: "Submit Fanavaran damage-case request",
|
||||
description:
|
||||
"Submits the GEN.12 dmg-cases request for the already-created Fanavaran claim and stores returned Id as local dmgCaseId.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "client",
|
||||
description: "Fanavaran tenant key",
|
||||
enum: ["parsian", "tejaratno"],
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimCaseId",
|
||||
description: "Claim case MongoDB ObjectId",
|
||||
})
|
||||
async submitDamageCase(
|
||||
@Param("client") client: string,
|
||||
@Param("claimCaseId") claimCaseId: string,
|
||||
@Body() body?: Record<string, unknown>,
|
||||
) {
|
||||
const clientKey = this.parseClientParam(client);
|
||||
return await this.claimRequestManagementService.submitFanavaranDamageCaseV2(
|
||||
claimCaseId,
|
||||
clientKey,
|
||||
body && Object.keys(body).length > 0 ? body : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":client/claim-cases/:claimCaseId/attachments/preview")
|
||||
@ApiOperation({
|
||||
summary: "Preview Fanavaran attachment upload plan",
|
||||
description:
|
||||
"Lists local required-document and captured damage images that would be sent to GEN.07. Shows which images are already recorded as uploaded to Fanavaran.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "client",
|
||||
description: "Fanavaran tenant key",
|
||||
enum: ["parsian", "tejaratno"],
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimCaseId",
|
||||
description: "Claim case MongoDB ObjectId",
|
||||
})
|
||||
async previewAttachments(
|
||||
@Param("client") client: string,
|
||||
@Param("claimCaseId") claimCaseId: string,
|
||||
) {
|
||||
const clientKey = this.parseClientParam(client);
|
||||
return await this.claimRequestManagementService.previewFanavaranAttachmentsV2(
|
||||
claimCaseId,
|
||||
clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":client/claim-cases/:claimCaseId/attachments/submit")
|
||||
@ApiOperation({
|
||||
summary: "Submit missing Fanavaran attachments",
|
||||
description:
|
||||
"Uploads every local image that does not already have a recorded successful GEN.07 Fanavaran file upload. Uses one multipart request per image.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "client",
|
||||
description: "Fanavaran tenant key",
|
||||
enum: ["parsian", "tejaratno"],
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimCaseId",
|
||||
description: "Claim case MongoDB ObjectId",
|
||||
})
|
||||
async submitAttachments(
|
||||
@Param("client") client: string,
|
||||
@Param("claimCaseId") claimCaseId: string,
|
||||
) {
|
||||
const clientKey = this.parseClientParam(client);
|
||||
return await this.claimRequestManagementService.submitFanavaranAttachmentsV2(
|
||||
claimCaseId,
|
||||
clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":client/claim-cases/:claimCaseId/expertise/preview")
|
||||
@ApiOperation({
|
||||
summary: "Preview Fanavaran expertise payload",
|
||||
description:
|
||||
"Builds the GEN.08 expertise payload from the active damage expert reply, price-drop data, and Fanavaran lookup mappings without calling Fanavaran.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "client",
|
||||
description: "Fanavaran tenant key",
|
||||
enum: ["parsian", "tejaratno"],
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimCaseId",
|
||||
description: "Claim case MongoDB ObjectId",
|
||||
})
|
||||
async previewExpertise(
|
||||
@Param("client") client: string,
|
||||
@Param("claimCaseId") claimCaseId: string,
|
||||
) {
|
||||
const clientKey = this.parseClientParam(client);
|
||||
return await this.claimRequestManagementService.previewFanavaranExpertiseV2(
|
||||
claimCaseId,
|
||||
clientKey,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":client/claim-cases/:claimCaseId/expertise/submit")
|
||||
@ApiOperation({
|
||||
summary: "Submit Fanavaran expertise request",
|
||||
description:
|
||||
"Submits the GEN.08 expertise payload for a claim with existing Fanavaran claimId and dmgCaseId. Stores returned Id as local expertiseId.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "client",
|
||||
description: "Fanavaran tenant key",
|
||||
enum: ["parsian", "tejaratno"],
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimCaseId",
|
||||
description: "Claim case MongoDB ObjectId",
|
||||
})
|
||||
async submitExpertise(
|
||||
@Param("client") client: string,
|
||||
@Param("claimCaseId") claimCaseId: string,
|
||||
@Body() body?: Record<string, unknown>,
|
||||
) {
|
||||
const clientKey = this.parseClientParam(client);
|
||||
return await this.claimRequestManagementService.submitFanavaranExpertiseV2(
|
||||
claimCaseId,
|
||||
clientKey,
|
||||
body && Object.keys(body).length > 0 ? body : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ export enum FanavaranAuditStep {
|
||||
POLICY_INQUIRY = "POLICY_INQUIRY",
|
||||
BUILD_PAYLOAD = "BUILD_PAYLOAD",
|
||||
SUBMIT_CLAIM = "SUBMIT_CLAIM",
|
||||
SUBMIT_DAMAGE_CASE = "SUBMIT_DAMAGE_CASE",
|
||||
SUBMIT_ATTACHMENT = "SUBMIT_ATTACHMENT",
|
||||
SUBMIT_EXPERTISE = "SUBMIT_EXPERTISE",
|
||||
}
|
||||
|
||||
export enum FanavaranAuditStatus {
|
||||
@@ -31,13 +34,23 @@ export class FanavaranAuditLog {
|
||||
@Prop({ type: String, required: true, enum: FanavaranAuditStep, index: true })
|
||||
step: FanavaranAuditStep;
|
||||
|
||||
@Prop({ type: String, required: true, enum: FanavaranAuditStatus, index: true })
|
||||
@Prop({
|
||||
type: String,
|
||||
required: true,
|
||||
enum: FanavaranAuditStatus,
|
||||
index: true,
|
||||
})
|
||||
status: FanavaranAuditStatus;
|
||||
|
||||
@Prop({ type: String, required: true, index: true })
|
||||
clientKey: FanavaranClientKey;
|
||||
|
||||
@Prop({ type: String, required: true, enum: FanavaranAuditSource, index: true })
|
||||
@Prop({
|
||||
type: String,
|
||||
required: true,
|
||||
enum: FanavaranAuditSource,
|
||||
index: true,
|
||||
})
|
||||
source: FanavaranAuditSource;
|
||||
|
||||
@Prop({ type: Types.ObjectId, required: false, index: true })
|
||||
|
||||
@@ -32,6 +32,63 @@ export function resolveGuiltyPartyUserId(blame: {
|
||||
return null;
|
||||
}
|
||||
|
||||
type BlamePartyRow = {
|
||||
role?: string;
|
||||
person?: {
|
||||
userId?: unknown;
|
||||
phoneNumber?: string;
|
||||
clientId?: unknown;
|
||||
fullName?: string;
|
||||
nationalCodeOfInsurer?: string;
|
||||
nationalCodeOfDriver?: string;
|
||||
insurerLicense?: string;
|
||||
driverLicense?: string;
|
||||
driverIsInsurer?: boolean;
|
||||
birthday?: string;
|
||||
insurerBirthday?: number;
|
||||
driverBirthday?: string | null;
|
||||
};
|
||||
statement?: { admitsGuilt?: boolean; claimsDamage?: boolean };
|
||||
insurance?: Record<string, unknown>;
|
||||
vehicle?: Record<string, unknown>;
|
||||
location?: { lat?: number; lon?: number };
|
||||
};
|
||||
|
||||
/** Full party row for the damaged vehicle (owner / beneficiary side). */
|
||||
export function resolveDamagedPartyRow(blame: {
|
||||
type?: string;
|
||||
parties?: BlamePartyRow[];
|
||||
expert?: { decision?: { guiltyPartyId?: unknown } };
|
||||
blameStatus?: string;
|
||||
}): BlamePartyRow | null {
|
||||
const parties = blame?.parties ?? [];
|
||||
if (!parties.length) return null;
|
||||
|
||||
const isCarBody =
|
||||
blame?.type === BlameRequestType.CAR_BODY || blame?.type === "CAR_BODY";
|
||||
|
||||
if (isCarBody) {
|
||||
return parties.find((p) => p.role === "FIRST") ?? parties[0] ?? null;
|
||||
}
|
||||
|
||||
const guiltyId = resolveGuiltyPartyUserId(blame);
|
||||
if (!guiltyId) {
|
||||
return parties.find((p) => p.statement?.claimsDamage === true) ?? null;
|
||||
}
|
||||
|
||||
const damagedParty = parties.find(
|
||||
(p) =>
|
||||
p.person?.userId != null && String(p.person.userId) !== String(guiltyId),
|
||||
);
|
||||
if (damagedParty) return damagedParty;
|
||||
|
||||
return (
|
||||
parties.find(
|
||||
(p) => p.person && String(p.person.userId ?? "") !== String(guiltyId),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Person row for the damaged party on a blame case (claim beneficiary / user-panel actor). */
|
||||
export function resolveDamagedPartyPerson(blame: {
|
||||
type?: string;
|
||||
|
||||
@@ -16,6 +16,12 @@ export const CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS = [
|
||||
"damaged_metal_plate",
|
||||
] as const;
|
||||
|
||||
/** Metal-plate keys that are optional in the V4/V5 FileMaker flow. */
|
||||
export const OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5 = [
|
||||
"damaged_metal_plate",
|
||||
"guilty_metal_plate",
|
||||
] as const;
|
||||
|
||||
export type CapturePhaseSequence =
|
||||
| "parts"
|
||||
| "angles"
|
||||
@@ -52,7 +58,7 @@ function isRequiredDocumentUploadedOnClaim(
|
||||
|
||||
export function getClaimCaptureProgress(
|
||||
claimCase: any,
|
||||
options?: { assumeCapturePhaseDocKey?: string },
|
||||
options?: { assumeCapturePhaseDocKey?: string; skipMetalPlate?: boolean },
|
||||
): ClaimCaptureProgress {
|
||||
const carType = claimCase?.vehicle?.carType as ClaimVehicleTypeV2 | undefined;
|
||||
const selectedNorm = normalizeDamageSelectedParts(
|
||||
@@ -83,6 +89,7 @@ export function getClaimCaptureProgress(
|
||||
|
||||
const capturePhaseDocsRemaining = CAPTURE_PHASE_DAMAGED_PARTY_DOC_KEYS.filter(
|
||||
(k) => {
|
||||
if (options?.skipMetalPlate && OPTIONAL_CAPTURE_PHASE_DOC_KEYS_V4V5.includes(k as any)) return false;
|
||||
if (k === options?.assumeCapturePhaseDocKey) return false;
|
||||
return !isRequiredDocumentUploadedOnClaim(claimCase, k);
|
||||
},
|
||||
@@ -111,8 +118,11 @@ export function getClaimCaptureProgress(
|
||||
};
|
||||
}
|
||||
|
||||
export function isClaimCaptureStepComplete(claimCase: any): boolean {
|
||||
const p = getClaimCaptureProgress(claimCase);
|
||||
export function isClaimCaptureStepComplete(
|
||||
claimCase: any,
|
||||
options?: { skipMetalPlate?: boolean },
|
||||
): boolean {
|
||||
const p = getClaimCaptureProgress(claimCase, options);
|
||||
return (
|
||||
p.partsComplete && p.anglesComplete && p.capturePhaseDocsComplete
|
||||
);
|
||||
|
||||
@@ -263,8 +263,6 @@ export function buildClaimDetailsV2OwnerGuidance(
|
||||
shape.mixedFactorAndPrice &&
|
||||
!claim.evaluation?.ownerPricedPartsApproval?.signedAt
|
||||
) {
|
||||
const ow = objectionWindowForOwner(claim);
|
||||
const allowObj = ow.pricingEligible;
|
||||
return {
|
||||
phaseKey: "SIGN_PRICED_LINES",
|
||||
headline: "Sign acceptance of priced lines",
|
||||
@@ -278,13 +276,12 @@ export function buildClaimDetailsV2OwnerGuidance(
|
||||
"Multipart sign + agree + branchId",
|
||||
),
|
||||
],
|
||||
objectionAllowed: allowObj,
|
||||
objectionHint: objectionHintWhen(allowObj),
|
||||
objectionAllowed,
|
||||
objectionHint,
|
||||
};
|
||||
}
|
||||
|
||||
/** NEEDS_REVISION at INSURER_REVIEW without mixed gate — all-factor initial sign not used; fallback */
|
||||
const allowObj = objectionWindowForOwner(claim).pricingEligible;
|
||||
return {
|
||||
phaseKey: "INSURER_REVIEW_NEEDS_REVISION",
|
||||
headline: "Insurer approval — action pending",
|
||||
@@ -298,13 +295,12 @@ export function buildClaimDetailsV2OwnerGuidance(
|
||||
"Sign if prompted by app state",
|
||||
),
|
||||
],
|
||||
objectionAllowed: allowObj,
|
||||
objectionHint: objectionHintWhen(allowObj),
|
||||
objectionAllowed,
|
||||
objectionHint,
|
||||
};
|
||||
}
|
||||
|
||||
if (cs === ClaimStatus.APPROVED && !claim.evaluation?.ownerInsurerApproval?.signedAt) {
|
||||
const allowObj = objectionWindowForOwner(claim).pricingEligible;
|
||||
return {
|
||||
phaseKey: "FINAL_SIGN_OR_REJECT",
|
||||
headline: "Accept or reject final pricing",
|
||||
@@ -323,8 +319,8 @@ export function buildClaimDetailsV2OwnerGuidance(
|
||||
"Dispute priced lines before signing",
|
||||
),
|
||||
],
|
||||
objectionAllowed: allowObj,
|
||||
objectionHint: objectionHintWhen(allowObj),
|
||||
objectionAllowed,
|
||||
objectionHint,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ const CLAIM_IN_PROGRESS_STATUSES = new Set<string>([
|
||||
ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||||
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||||
// V4 split flow: sealed by FileMaker, pending FileReviewer pickup.
|
||||
ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER,
|
||||
]);
|
||||
|
||||
export function blameCaseStatusToReportBucket(status: string): string {
|
||||
|
||||
8
src/helpers/expert-workflow-lock.ts
Normal file
8
src/helpers/expert-workflow-lock.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Expert blame/claim review assign + workflow lock TTL (POST assign, PUT lock). */
|
||||
export const EXPERT_WORKFLOW_LOCK_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
export function expertWorkflowLockExpiredAt(
|
||||
from: Date = new Date(),
|
||||
): Date {
|
||||
return new Date(from.getTime() + EXPERT_WORKFLOW_LOCK_TTL_MS);
|
||||
}
|
||||
166
src/helpers/persian-pdf-document.ts
Normal file
166
src/helpers/persian-pdf-document.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type PDFDocumentType from "pdfkit";
|
||||
import {
|
||||
isMostlyAscii,
|
||||
pdfKitRtlKeyValue,
|
||||
pdfKitRtlParagraph,
|
||||
} from "./persian-pdf-text";
|
||||
|
||||
// pdfkit publishes CJS (`module.exports = PDFDocument`) without a `.default` export.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const PDFDocument = require("pdfkit") as typeof PDFDocumentType;
|
||||
|
||||
const PAGE_WIDTH = 595.28;
|
||||
const MARGIN = 50;
|
||||
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2;
|
||||
const LABEL_COLUMN_WIDTH = CONTENT_WIDTH * 0.44;
|
||||
const VALUE_COLUMN_WIDTH = CONTENT_WIDTH * 0.5;
|
||||
const VALUE_COLUMN_X = MARGIN;
|
||||
const COLON_X = MARGIN + VALUE_COLUMN_WIDTH + 4;
|
||||
const LABEL_COLUMN_X = COLON_X + 10;
|
||||
|
||||
function resolveFontFile(variant: "regular" | "bold"): string {
|
||||
const file =
|
||||
variant === "bold" ? "Vazirmatn-Bold.ttf" : "Vazirmatn-Regular.ttf";
|
||||
const candidates = [
|
||||
join(process.cwd(), "assets", file),
|
||||
join(process.cwd(), "assets", "fonts", file),
|
||||
join(process.cwd(), "dist", "assets", file),
|
||||
join(process.cwd(), "dist", "assets", "fonts", file),
|
||||
];
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return path;
|
||||
}
|
||||
throw new Error(
|
||||
`Persian PDF font not found (${file}). Place it under assets/ or assets/fonts/.`,
|
||||
);
|
||||
}
|
||||
|
||||
export type PersianPdfDocument = InstanceType<typeof PDFDocument> & {
|
||||
addTitle: (text: string) => void;
|
||||
addSection: (text: string) => void;
|
||||
addKeyValue: (label: string, value?: string | number | null) => void;
|
||||
addBlank: (lines?: number) => void;
|
||||
};
|
||||
|
||||
/** Ensure at least `neededPts` of vertical space remain; add a page if not. */
|
||||
function ensureSpace(
|
||||
doc: InstanceType<typeof PDFDocument>,
|
||||
neededPts: number,
|
||||
): void {
|
||||
if (doc.y + neededPts > doc.page.maxY()) {
|
||||
doc.addPage();
|
||||
}
|
||||
}
|
||||
|
||||
function drawRtlLine(
|
||||
doc: InstanceType<typeof PDFDocument>,
|
||||
text: string,
|
||||
fontSize: number,
|
||||
bold: boolean,
|
||||
): void {
|
||||
const y = doc.y;
|
||||
doc
|
||||
.font(bold ? "FaBold" : "FaRegular")
|
||||
.fontSize(fontSize)
|
||||
.text(pdfKitRtlParagraph(text), MARGIN, y, {
|
||||
width: CONTENT_WIDTH,
|
||||
align: "right",
|
||||
lineBreak: false,
|
||||
});
|
||||
doc.moveDown(fontSize > 12 ? 0.55 : 0.45);
|
||||
}
|
||||
|
||||
function drawKeyValueRow(
|
||||
doc: InstanceType<typeof PDFDocument>,
|
||||
label: string,
|
||||
value: string,
|
||||
): void {
|
||||
// Reserve one row height (font 10pt ≈ 14 pts with leading) before drawing.
|
||||
ensureSpace(doc, 16);
|
||||
|
||||
const y = doc.y;
|
||||
const { label: faLabel, value: faValue } = pdfKitRtlKeyValue(label, value);
|
||||
|
||||
if (isMostlyAscii(faValue)) {
|
||||
doc.font("Helvetica").fontSize(10).text(faValue, VALUE_COLUMN_X, y, {
|
||||
width: VALUE_COLUMN_WIDTH,
|
||||
align: "left",
|
||||
lineBreak: false,
|
||||
});
|
||||
} else {
|
||||
doc.font("FaRegular").fontSize(10).text(faValue, VALUE_COLUMN_X, y, {
|
||||
width: VALUE_COLUMN_WIDTH,
|
||||
align: "left",
|
||||
lineBreak: false,
|
||||
});
|
||||
}
|
||||
|
||||
doc.font("Helvetica").fontSize(10).text(":", COLON_X, y, {
|
||||
lineBreak: false,
|
||||
});
|
||||
|
||||
doc.font("FaRegular").fontSize(10).text(faLabel, LABEL_COLUMN_X, y, {
|
||||
width: LABEL_COLUMN_WIDTH,
|
||||
align: "right",
|
||||
lineBreak: false,
|
||||
});
|
||||
|
||||
doc.moveDown(0.5);
|
||||
}
|
||||
|
||||
export function createPersianPdfDocument(): PersianPdfDocument {
|
||||
const doc = new PDFDocument({
|
||||
size: "A4",
|
||||
margin: MARGIN,
|
||||
autoFirstPage: true,
|
||||
}) as PersianPdfDocument;
|
||||
|
||||
doc.registerFont("FaRegular", resolveFontFile("regular"));
|
||||
doc.registerFont("FaBold", resolveFontFile("bold"));
|
||||
doc.font("FaRegular");
|
||||
|
||||
doc.addTitle = (text: string) => {
|
||||
doc.moveDown(0.2);
|
||||
drawRtlLine(doc, text, 16, true);
|
||||
doc.font("FaRegular").fontSize(10);
|
||||
doc.moveDown(0.15);
|
||||
};
|
||||
|
||||
doc.addSection = (text: string) => {
|
||||
// Reserve space for the gap + section heading + at least one data row below it,
|
||||
// so the heading is never stranded alone at the bottom of a page.
|
||||
ensureSpace(doc, 60);
|
||||
doc.moveDown(0.5);
|
||||
drawRtlLine(doc, text, 13, true);
|
||||
doc.font("FaRegular").fontSize(10);
|
||||
doc.moveDown(0.1);
|
||||
};
|
||||
|
||||
doc.addKeyValue = (label: string, value?: string | number | null) => {
|
||||
const v =
|
||||
value === undefined || value === null || value === ""
|
||||
? "-"
|
||||
: String(value);
|
||||
drawKeyValueRow(doc, label, v);
|
||||
};
|
||||
|
||||
doc.addBlank = (lines = 1) => {
|
||||
doc.moveDown(lines * 0.35);
|
||||
};
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
export function persianPdfToBuffer(
|
||||
doc: InstanceType<typeof PDFDocument>,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
doc.on("error", reject);
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
40
src/helpers/persian-pdf-text.ts
Normal file
40
src/helpers/persian-pdf-text.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/** Strip invisible bidi / ZWNJ chars that render as tofu in embedded Arabic fonts. */
|
||||
export function normalizePersianPdfText(text: string): string {
|
||||
return text
|
||||
.replace(/\u200c/g, " ")
|
||||
.replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, "")
|
||||
.replace(/\u2014/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* pdfkit renders glyphs strictly left-to-right.
|
||||
* fontkit already reshapes each word's glyphs into visual (LTR) order,
|
||||
* but it does NOT reorder words — so a multi-word RTL string like
|
||||
* "نام صاحب خودرو" has its words placed LTR on the page and reads
|
||||
* backwards. Reversing the word sequence here makes pdfkit emit the
|
||||
* words in the correct visual order for a right-to-left PDF reader.
|
||||
*/
|
||||
function reverseRtlWords(text: string): string {
|
||||
if (!/[\u0600-\u06FF]/.test(text)) return text;
|
||||
return text.split(" ").reverse().join(" ");
|
||||
}
|
||||
|
||||
export function pdfKitRtlKeyValue(
|
||||
label: string,
|
||||
value: string,
|
||||
): { label: string; value: string } {
|
||||
return {
|
||||
label: reverseRtlWords(normalizePersianPdfText(label)),
|
||||
value: reverseRtlWords(normalizePersianPdfText(value)),
|
||||
};
|
||||
}
|
||||
|
||||
export function pdfKitRtlParagraph(text: string): string {
|
||||
return reverseRtlWords(normalizePersianPdfText(text));
|
||||
}
|
||||
|
||||
export function isMostlyAscii(text: string): boolean {
|
||||
return text.length > 0 && !/[\u0600-\u06FF]/.test(text);
|
||||
}
|
||||
166
src/helpers/simple-pdf-writer.ts
Normal file
166
src/helpers/simple-pdf-writer.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
type PdfTextRun = {
|
||||
pageIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
fontSize: number;
|
||||
bold: boolean;
|
||||
text: string;
|
||||
};
|
||||
|
||||
/** Minimal PDF 1.4 writer (Helvetica) — no external dependencies. */
|
||||
export class SimplePdfWriter {
|
||||
private readonly pageWidth = 595.28;
|
||||
private readonly pageHeight = 841.89;
|
||||
private readonly marginX = 50;
|
||||
private readonly marginTop = 50;
|
||||
private readonly marginBottom = 50;
|
||||
private runs: PdfTextRun[] = [];
|
||||
private pageCount = 1;
|
||||
private cursorY = this.pageHeight - this.marginTop;
|
||||
private pageIndex = 0;
|
||||
|
||||
private ensureSpace(lineHeight: number): void {
|
||||
if (this.cursorY - lineHeight < this.marginBottom) {
|
||||
this.pageIndex += 1;
|
||||
this.pageCount += 1;
|
||||
this.cursorY = this.pageHeight - this.marginTop;
|
||||
}
|
||||
}
|
||||
|
||||
private addRun(text: string, fontSize: number, bold = false): void {
|
||||
const lineHeight = fontSize + 4;
|
||||
this.ensureSpace(lineHeight);
|
||||
this.runs.push({
|
||||
pageIndex: this.pageIndex,
|
||||
x: this.marginX,
|
||||
y: this.cursorY,
|
||||
fontSize,
|
||||
bold,
|
||||
text,
|
||||
});
|
||||
this.cursorY -= lineHeight;
|
||||
}
|
||||
|
||||
addTitle(text: string): void {
|
||||
this.addRun(text, 16, true);
|
||||
this.cursorY -= 4;
|
||||
}
|
||||
|
||||
addSection(text: string): void {
|
||||
this.cursorY -= 6;
|
||||
this.addRun(text, 13, true);
|
||||
this.cursorY -= 2;
|
||||
}
|
||||
|
||||
addLine(text: string): void {
|
||||
this.addRun(text, 10, false);
|
||||
}
|
||||
|
||||
addKeyValue(label: string, value?: string | number | null): void {
|
||||
const v =
|
||||
value === undefined || value === null || value === ""
|
||||
? "-"
|
||||
: String(value);
|
||||
this.addLine(`${label}: ${v}`);
|
||||
}
|
||||
|
||||
addBlank(): void {
|
||||
this.cursorY -= 8;
|
||||
}
|
||||
|
||||
toBuffer(): Buffer {
|
||||
const chunkById: string[] = [];
|
||||
const offsets: number[] = [0];
|
||||
let size = Buffer.byteLength("%PDF-1.4\n", "utf8");
|
||||
|
||||
const addObj = (content: string): number => {
|
||||
const id = chunkById.length;
|
||||
offsets.push(size);
|
||||
const block = `${id} 0 obj\n${content}\nendobj\n`;
|
||||
chunkById.push(block);
|
||||
size += Buffer.byteLength(block, "utf8");
|
||||
return id;
|
||||
};
|
||||
|
||||
chunkById.push("%PDF-1.4\n");
|
||||
|
||||
const fontRegularId = addObj(
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
);
|
||||
const fontBoldId = addObj(
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>",
|
||||
);
|
||||
|
||||
const contentIds: number[] = [];
|
||||
for (let p = 0; p < this.pageCount; p++) {
|
||||
const pageRuns = this.runs.filter((r) => r.pageIndex === p);
|
||||
const cmds: string[] = [];
|
||||
for (const run of pageRuns) {
|
||||
const font = run.bold ? "F2" : "F1";
|
||||
cmds.push("BT");
|
||||
cmds.push(`/${font} ${run.fontSize} Tf`);
|
||||
cmds.push(`1 0 0 1 ${run.x.toFixed(2)} ${run.y.toFixed(2)} Tm`);
|
||||
cmds.push(`${encodePdfText(run.text)} Tj`);
|
||||
cmds.push("ET");
|
||||
}
|
||||
const stream = cmds.join("\n");
|
||||
contentIds.push(
|
||||
addObj(
|
||||
`<< /Length ${Buffer.byteLength(stream, "utf8")} >>\nstream\n${stream}\nendstream`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const pagesKidsToken = "__PAGES_KIDS__";
|
||||
const pagesId = addObj(
|
||||
`<< /Type /Pages /Kids [${pagesKidsToken}] /Count ${this.pageCount} >>`,
|
||||
);
|
||||
const pageIds: number[] = [];
|
||||
for (let p = 0; p < this.pageCount; p++) {
|
||||
pageIds.push(
|
||||
addObj(
|
||||
`<< /Type /Page /Parent ${pagesId} 0 R /Resources << /Font << /F1 ${fontRegularId} 0 R /F2 ${fontBoldId} 0 R >> >> /MediaBox [0 0 ${this.pageWidth} ${this.pageHeight}] /Contents ${contentIds[p]} 0 R >>`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const catalogId = addObj(`<< /Type /Catalog /Pages ${pagesId} 0 R >>`);
|
||||
|
||||
chunkById[pagesId] = chunkById[pagesId].replace(
|
||||
pagesKidsToken,
|
||||
pageIds.map((id) => `${id} 0 R`).join(" "),
|
||||
);
|
||||
|
||||
const body = chunkById.join("");
|
||||
const bodyBuf = Buffer.from(body, "utf8");
|
||||
const xrefAt = bodyBuf.length;
|
||||
const xref: string[] = [
|
||||
`xref\n0 ${offsets.length}\n0000000000 65535 f \n`,
|
||||
];
|
||||
for (let i = 1; i < offsets.length; i++) {
|
||||
xref.push(`${String(offsets[i]).padStart(10, "0")} 00000 n \n`);
|
||||
}
|
||||
const trailer = `trailer\n<< /Size ${offsets.length} /Root ${catalogId} 0 R >>\nstartxref\n${xrefAt}\n%%EOF\n`;
|
||||
return Buffer.concat([bodyBuf, Buffer.from(xref.join("") + trailer, "utf8")]);
|
||||
}
|
||||
}
|
||||
|
||||
/** PDF literal string for Helvetica (WinAnsi) — Latin text only. */
|
||||
function encodePdfText(input: string): string {
|
||||
const text = input.normalize("NFC");
|
||||
const safe = [...text]
|
||||
.map((ch) => {
|
||||
const code = ch.charCodeAt(0);
|
||||
if (code >= 0x20 && code <= 0x7e) return ch;
|
||||
if (ch === "—" || ch === "–") return "-";
|
||||
if (/\s/.test(ch)) return " ";
|
||||
return "";
|
||||
})
|
||||
.join("")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return `(${safe
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\(/g, "\\(")
|
||||
.replace(/\)/g, "\\)")})`;
|
||||
}
|
||||
@@ -31,6 +31,8 @@ const CLAIM_USER_PHASE = new Set<string>([
|
||||
ClaimCaseStatus.SELECTING_OTHER_PARTS,
|
||||
ClaimCaseStatus.UPLOADING_REQUIRED_DOCUMENTS,
|
||||
ClaimCaseStatus.CAPTURING_PART_DAMAGES,
|
||||
// V4 split flow: FileMaker sealed the file; FileReviewer hasn't picked it up yet.
|
||||
ClaimCaseStatus.WAITING_FOR_FILE_REVIEWER,
|
||||
]);
|
||||
|
||||
const INSURER_REVIEW_CLAIM_STATUSES = new Set<string>([
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { ApiOkResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { Controller, Get, Param, ParseIntPipe, Query, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { AuthGuard } from "src/common/auth/guards";
|
||||
import { LookupsService } from "./lookups.service";
|
||||
|
||||
@ApiTags("lookups")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller("lookups")
|
||||
export class LookupsController {
|
||||
constructor(private readonly lookupsService: LookupsService) {}
|
||||
@@ -96,9 +106,247 @@ export class LookupsController {
|
||||
return await this.lookupsService.getAccidentCulpritType();
|
||||
}
|
||||
|
||||
@Get("inspection-place")
|
||||
@ApiOperation({
|
||||
summary: "Fanavaran GEN.08 inspection place lookup",
|
||||
description:
|
||||
"Returns values for expertise payload field InspectionPlaceId from car/code-list/inspection-place.",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns Fanavaran inspection place lookup data",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
async getInspectionPlace() {
|
||||
return await this.lookupsService.getInspectionPlace();
|
||||
}
|
||||
|
||||
@Get("drop-amount-status")
|
||||
@ApiOperation({
|
||||
summary: "Fanavaran GEN.08 drop amount status lookup",
|
||||
description:
|
||||
"Returns values for expertise payload field DropAmountStatus from car/code-list/drop-amount-status.",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns Fanavaran drop amount status lookup data",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
async getDropAmountStatus() {
|
||||
return await this.lookupsService.getDropAmountStatus();
|
||||
}
|
||||
|
||||
@Get("car-components")
|
||||
@ApiOperation({
|
||||
summary: "Fanavaran GEN.08 damaged section lookup",
|
||||
description:
|
||||
"Returns values for DmgSections[].DmgSectionId from car/base-info/car-components.",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns Fanavaran car component lookup data",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
async getCarComponents() {
|
||||
return await this.lookupsService.getCarComponents();
|
||||
}
|
||||
|
||||
@Get("accident-level")
|
||||
@ApiOperation({
|
||||
summary: "Fanavaran GEN.08 accident level lookup",
|
||||
description:
|
||||
"Returns values for DmgSections[].AccidentLevel from car/code-list/accident-level.",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns Fanavaran accident level lookup data",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
async getAccidentLevel() {
|
||||
return await this.lookupsService.getAccidentLevel();
|
||||
}
|
||||
|
||||
@Get("expert-status")
|
||||
@ApiOperation({
|
||||
summary: "Fanavaran GEN.08 expert status lookup",
|
||||
description:
|
||||
"Returns values for expertise response field Status from car/code-list/expert-status.",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns Fanavaran expert status lookup data",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
async getExpertStatus() {
|
||||
return await this.lookupsService.getExpertStatus();
|
||||
}
|
||||
|
||||
@Get("vehicle-kinds")
|
||||
@ApiOperation({
|
||||
summary: "Fanavaran GEN.12 vehicle kind lookup",
|
||||
description:
|
||||
"Returns values for damage case payload field VehicleKindId from car/base-info/vehicle-kinds.",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns Fanavaran vehicle kinds lookup data",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
async getVehicleKinds() {
|
||||
return await this.lookupsService.getVehicleKinds();
|
||||
}
|
||||
|
||||
@Get("person-role")
|
||||
@ApiOperation({
|
||||
summary: "Fanavaran person role lookup",
|
||||
description:
|
||||
"Returns person role codes from common/code-list/person-role.",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns Fanavaran person role lookup data",
|
||||
schema: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
Caption: { type: "string" },
|
||||
Id: { type: "number" },
|
||||
IsActive: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async getPersonRole() {
|
||||
return await this.lookupsService.getPersonRole();
|
||||
}
|
||||
|
||||
@Get("file-types")
|
||||
@ApiOperation({
|
||||
summary: "Fanavaran file types lookup",
|
||||
description:
|
||||
"Returns file type codes from common/base-info/file-types. Use FileTypeId in attachment uploads.",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns Fanavaran file types lookup data",
|
||||
schema: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
Caption: { type: "string" },
|
||||
Id: { type: "number" },
|
||||
IsActive: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async getFileTypes() {
|
||||
return await this.lookupsService.getFileTypes();
|
||||
}
|
||||
|
||||
@Get("cities")
|
||||
@ApiOperation({
|
||||
summary: "Fanavaran cities lookup",
|
||||
description: "Returns city codes from common/base-info/cities.",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns Fanavaran cities lookup data",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
async cities() {
|
||||
return await this.lookupsService.getCities();
|
||||
}
|
||||
|
||||
@Get("provinces")
|
||||
@ApiOperation({
|
||||
summary: "Fanavaran provinces lookup",
|
||||
description: "Returns province codes from common/base-info/provinces.",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns Fanavaran provinces lookup data",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
async provinces() {
|
||||
return await this.lookupsService.getProvinces();
|
||||
}
|
||||
|
||||
@Get("dmg-case-type")
|
||||
@ApiOperation({
|
||||
summary: "Fanavaran damage case type lookup",
|
||||
description: "Returns damage case type codes from car/code-list/dmg-case-type.",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns Fanavaran damage case type lookup data",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
async dmgCaseType() {
|
||||
return await this.lookupsService.getDmgCaseType();
|
||||
}
|
||||
|
||||
@Get("dmg-history-status")
|
||||
@ApiOperation({
|
||||
summary: "Fanavaran damage history status lookup",
|
||||
description:
|
||||
"Returns damage history status codes from car/code-list/dmg-case-history-status.",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns Fanavaran damage history status lookup data",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
async dmgHistoryStatus() {
|
||||
return await this.lookupsService.getDmgHistoryStatus();
|
||||
}
|
||||
|
||||
@Get("inquiry-by-vin")
|
||||
@ApiOperation({
|
||||
summary: "Fanavaran vehicle inquiry by VIN",
|
||||
description:
|
||||
"Returns vehicle details from Fanavaran for the given VIN number via car/vehicles/inquiry-by-vin.",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "vin",
|
||||
description: "Vehicle Identification Number (VIN)",
|
||||
example: "IRNKAEK4150012345",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns Fanavaran vehicle inquiry data for the VIN",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
async inquiryByVin(@Query("vin") vin: string) {
|
||||
return await this.lookupsService.inquiryByVin(vin);
|
||||
}
|
||||
|
||||
@Get("fanavaran")
|
||||
@ApiOperation({
|
||||
summary: "List configured Fanavaran remote lookups",
|
||||
description:
|
||||
"Returns the lookup names available through /lookups/fanavaran/{lookupName}.",
|
||||
})
|
||||
async listFanavaranRemoteLookups() {
|
||||
return this.lookupsService.listFanavaranRemoteLookups().map((lookup) => ({
|
||||
name: lookup.name,
|
||||
cacheFile: lookup.cacheFile,
|
||||
url: lookup.url,
|
||||
}));
|
||||
}
|
||||
|
||||
@Get("fanavaran/:lookupName")
|
||||
@ApiOperation({
|
||||
summary: "Fetch a Fanavaran remote lookup by name",
|
||||
description:
|
||||
"Generic cached Fanavaran lookup fetcher for configured lookup names, including GEN.08 expertise lookups.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "lookupName",
|
||||
description:
|
||||
"Configured Fanavaran lookup name, for example inspection-place, drop-amount-status, car-components, accident-level, expert-status",
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns cached or live Fanavaran lookup data",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
async getFanavaranRemoteLookup(@Param("lookupName") lookupName: string) {
|
||||
return await this.lookupsService.getClientRemoteLookup(lookupName);
|
||||
}
|
||||
|
||||
@Get("accident-way")
|
||||
@ApiOkResponse({
|
||||
description: "Returns accident way options for the add-accident-fields step",
|
||||
description:
|
||||
"Returns accident way options for the add-accident-fields step",
|
||||
schema: {
|
||||
type: "array",
|
||||
items: {
|
||||
@@ -204,5 +452,75 @@ export class LookupsController {
|
||||
async getAccidentFields() {
|
||||
return await this.lookupsService.getAccidentFields();
|
||||
}
|
||||
|
||||
@Get("my-policies")
|
||||
@ApiOperation({
|
||||
summary: "My policies inquiry",
|
||||
description:
|
||||
"Returns the list of policies for the given national code via common/Policies/inquiry-my-policies.",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "nationalCode",
|
||||
description: "National code of the insured person",
|
||||
example: "0012345678",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "insuranceLineId",
|
||||
description: "Insurance line ID (default: 5 for car)",
|
||||
required: false,
|
||||
example: 5,
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns list of policies",
|
||||
schema: { type: "array", items: { type: "object" } },
|
||||
})
|
||||
async myPolicies(
|
||||
@Query("nationalCode") nationalCode: string,
|
||||
@Query("insuranceLineId") insuranceLineId?: number,
|
||||
) {
|
||||
return await this.lookupsService.myPolicies(
|
||||
nationalCode,
|
||||
insuranceLineId ?? 5,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("third-party-policy/:policyId")
|
||||
@ApiOperation({
|
||||
summary: "Third-party car policy inquiry by policy ID",
|
||||
description:
|
||||
"Returns third-party car insurance policy details for the given policy ID via car/third-party-car-policies/{policyId}.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "policyId",
|
||||
description: "Fanavaran policy ID",
|
||||
example: 12345,
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns third-party policy details",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
async thirdPartyPolicyById(
|
||||
@Param("policyId", ParseIntPipe) policyId: number,
|
||||
) {
|
||||
return await this.lookupsService.thirdPartyPolicyById(policyId);
|
||||
}
|
||||
|
||||
@Get("body-policy/:policyId")
|
||||
@ApiOperation({
|
||||
summary: "Vehicle hull (body) policy inquiry by policy ID",
|
||||
description:
|
||||
"Returns vehicle hull (body) insurance policy details for the given policy ID via car/vehicle-hull-policies/{policyId}.",
|
||||
})
|
||||
@ApiParam({
|
||||
name: "policyId",
|
||||
description: "Fanavaran policy ID",
|
||||
example: 12345,
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: "Returns body policy details",
|
||||
schema: { type: "object" },
|
||||
})
|
||||
async bodyPolicyById(@Param("policyId", ParseIntPipe) policyId: number) {
|
||||
return await this.lookupsService.bodyPolicyById(policyId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { resolveFanavaranClientKey } from "src/core/config/fanavaran-client.config";
|
||||
import {
|
||||
FANAVARAN_REMOTE_LOOKUPS,
|
||||
type FanavaranRemoteLookupDefinition,
|
||||
TEJARAT_STATIC_ACCIDENT_FILES,
|
||||
} from "src/fanavaran/fanavaran-lookup.config";
|
||||
import { FanavaranLookupService } from "src/fanavaran/fanavaran-lookup.service";
|
||||
@@ -31,10 +32,14 @@ export class LookupsService {
|
||||
return resolveFanavaranClientKey();
|
||||
}
|
||||
|
||||
listFanavaranRemoteLookups(): FanavaranRemoteLookupDefinition[] {
|
||||
return FANAVARAN_REMOTE_LOOKUPS;
|
||||
}
|
||||
|
||||
private findRemoteLookup(name: string) {
|
||||
const lookup = FANAVARAN_REMOTE_LOOKUPS.find((item) => item.name === name);
|
||||
if (!lookup) {
|
||||
throw new Error(`Unknown Fanavaran remote lookup: ${name}`);
|
||||
throw new NotFoundException(`Unknown Fanavaran remote lookup: ${name}`);
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
@@ -52,7 +57,7 @@ export class LookupsService {
|
||||
return doc.response;
|
||||
}
|
||||
|
||||
private async getClientRemoteLookup(lookupName: string): Promise<unknown> {
|
||||
async getClientRemoteLookup(lookupName: string): Promise<unknown> {
|
||||
const clientKey = this.activeClientKey();
|
||||
const definition = this.findRemoteLookup(lookupName);
|
||||
|
||||
@@ -90,6 +95,137 @@ export class LookupsService {
|
||||
return await this.getClientRemoteLookup("accident-culprit-type");
|
||||
}
|
||||
|
||||
async getInspectionPlace(): Promise<any> {
|
||||
return await this.getClientRemoteLookup("inspection-place");
|
||||
}
|
||||
|
||||
async getDropAmountStatus(): Promise<any> {
|
||||
return await this.getClientRemoteLookup("drop-amount-status");
|
||||
}
|
||||
|
||||
async getCarComponents(): Promise<any> {
|
||||
return await this.getClientRemoteLookup("car-components");
|
||||
}
|
||||
|
||||
async getAccidentLevel(): Promise<any> {
|
||||
return await this.getClientRemoteLookup("accident-level");
|
||||
}
|
||||
|
||||
async getExpertStatus(): Promise<any> {
|
||||
return await this.getClientRemoteLookup("expert-status");
|
||||
}
|
||||
|
||||
async getVehicleKinds(): Promise<any> {
|
||||
return await this.getClientRemoteLookup("vehicle-kinds");
|
||||
}
|
||||
|
||||
async getPersonRole(): Promise<any> {
|
||||
return await this.getClientRemoteLookup("person-role");
|
||||
}
|
||||
|
||||
async getFileTypes(): Promise<any> {
|
||||
return await this.getClientRemoteLookup("file-types");
|
||||
}
|
||||
|
||||
async getCities(): Promise<any> {
|
||||
return await this.getClientRemoteLookup("cities");
|
||||
}
|
||||
|
||||
async getProvinces(): Promise<any> {
|
||||
return await this.getClientRemoteLookup("provinces");
|
||||
}
|
||||
|
||||
async getDmgCaseType(): Promise<any> {
|
||||
return await this.getClientRemoteLookup("dmg-case-type");
|
||||
}
|
||||
|
||||
async getDmgHistoryStatus(): Promise<any> {
|
||||
return await this.getClientRemoteLookup("dmg-history-status");
|
||||
}
|
||||
|
||||
async inquiryByVin(vin: string): Promise<unknown> {
|
||||
const clientKey = this.activeClientKey();
|
||||
return this.fanavaranLookupService.inquiryByVin(clientKey, vin);
|
||||
}
|
||||
|
||||
async myPolicies(
|
||||
nationalCode: string,
|
||||
insuranceLineId: number = 5,
|
||||
): Promise<unknown> {
|
||||
const clientKey = this.activeClientKey();
|
||||
return this.fanavaranLookupService.myPolicies(
|
||||
clientKey,
|
||||
nationalCode,
|
||||
insuranceLineId,
|
||||
);
|
||||
}
|
||||
|
||||
async mapPolicyDetails(policy: any): Promise<any> {
|
||||
if (!policy || typeof policy !== "object") {
|
||||
return policy;
|
||||
}
|
||||
|
||||
const mappedPolicy = { ...policy };
|
||||
|
||||
// 1. Map PreviousInsuranceCorpId and TransferorInsuranceCorpId
|
||||
try {
|
||||
const companies = (await this.getClientRemoteLookup("insurance-corp")) as any[];
|
||||
if (Array.isArray(companies)) {
|
||||
if (typeof policy.PreviousInsuranceCorpId === "number") {
|
||||
const match = companies.find((c) => c.Id === policy.PreviousInsuranceCorpId);
|
||||
if (match) {
|
||||
mappedPolicy.PreviousInsuranceCorpName = match.Caption;
|
||||
}
|
||||
}
|
||||
if (typeof policy.TransferorInsuranceCorpId === "number") {
|
||||
const match = companies.find((c) => c.Id === policy.TransferorInsuranceCorpId);
|
||||
if (match) {
|
||||
mappedPolicy.TransferorInsuranceCorpName = match.Caption;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(`Failed to map insurance-corp lookups: ${e.message}`);
|
||||
}
|
||||
|
||||
// 2. Map PolicyUsageTypeId
|
||||
try {
|
||||
const useTypes = (await this.getClientRemoteLookup("vehicle-use-types")) as any[];
|
||||
if (Array.isArray(useTypes) && typeof policy.PolicyUsageTypeId === "number") {
|
||||
const match = useTypes.find((t) => t.Id === policy.PolicyUsageTypeId);
|
||||
if (match) {
|
||||
mappedPolicy.PolicyUsageTypeName = match.Caption;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(`Failed to map vehicle-use-types lookups: ${e.message}`);
|
||||
}
|
||||
|
||||
return mappedPolicy;
|
||||
}
|
||||
|
||||
async thirdPartyPolicyById(policyId: number): Promise<unknown> {
|
||||
const clientKey = this.activeClientKey();
|
||||
const policy = await this.fanavaranLookupService.thirdPartyPolicyById(clientKey, policyId);
|
||||
return this.mapPolicyDetails(policy);
|
||||
}
|
||||
|
||||
async bodyPolicyById(policyId: number): Promise<unknown> {
|
||||
const clientKey = this.activeClientKey();
|
||||
const policy = await this.fanavaranLookupService.bodyPolicyById(clientKey, policyId);
|
||||
return this.mapPolicyDetails(policy);
|
||||
}
|
||||
|
||||
async vehicleById(vehicleId: number, versionNo: number = 1): Promise<unknown> {
|
||||
const clientKey = this.activeClientKey();
|
||||
return this.fanavaranLookupService.vehicleById(clientKey, vehicleId, versionNo);
|
||||
}
|
||||
|
||||
async customerById(customerId: number): Promise<unknown> {
|
||||
const clientKey = this.activeClientKey();
|
||||
return this.fanavaranLookupService.customerById(clientKey, customerId);
|
||||
}
|
||||
|
||||
async getAccidentWay(): Promise<{ id: number; label: string }[]> {
|
||||
const clientKey = this.activeClientKey();
|
||||
const fileName = TEJARAT_STATIC_ACCIDENT_FILES.accidentWay;
|
||||
@@ -129,8 +265,7 @@ export class LookupsService {
|
||||
|
||||
if (clientKey === "parsian") {
|
||||
const cacheFile = "accident-reason-options.json";
|
||||
const cached =
|
||||
await this.fanavaranLookupService.readCacheFile<
|
||||
const cached = await this.fanavaranLookupService.readCacheFile<
|
||||
{ id: number; label: string; fanavaran: number }[]
|
||||
>(clientKey, cacheFile);
|
||||
if (cached) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ClientModule } from "src/client/client.module";
|
||||
import { PlateDbService } from "src/plates/entites/db-service/plate.db.service";
|
||||
import { SandHubModule } from "src/sand-hub/sand-hub.module";
|
||||
import { UsersModule } from "src/users/users.module";
|
||||
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
||||
import { PlatesModel, PlatesSchema } from "./entites/schema/plate.schema";
|
||||
import { PlatesService } from "./plates.service";
|
||||
|
||||
@@ -12,6 +13,7 @@ import { PlatesService } from "./plates.service";
|
||||
UsersModule,
|
||||
SandHubModule,
|
||||
ClientModule,
|
||||
PlateNormalizerModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: PlatesModel.name, schema: PlatesSchema },
|
||||
]),
|
||||
|
||||
@@ -7,20 +7,23 @@ import {
|
||||
import { Types } from "mongoose";
|
||||
import { PlateDbService } from "src/plates/entites/db-service/plate.db.service";
|
||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
|
||||
|
||||
@Injectable()
|
||||
export class PlatesService {
|
||||
constructor(
|
||||
private readonly plateDbService: PlateDbService,
|
||||
private readonly plateNormalizer: PlateNormalizerService,
|
||||
) {}
|
||||
|
||||
async addPlate(currentUser, body: AddPlateDto) {
|
||||
const { plate, nationalCodeOfInsurer } = body;
|
||||
|
||||
const normalizedAlphabet = this.plateNormalizer.normalizePlateText(String(plate.centerAlphabet || ""));
|
||||
const duplicate = await this.plateDbService.findOne({
|
||||
userId: currentUser,
|
||||
leftDigits: plate.leftDigits,
|
||||
centerAlphabet: plate.centerAlphabet,
|
||||
centerAlphabet: normalizedAlphabet,
|
||||
centerDigits: plate.centerDigits,
|
||||
ir: plate.ir,
|
||||
nationalCodeOfInsurer,
|
||||
@@ -31,6 +34,7 @@ export class PlatesService {
|
||||
|
||||
const newPlateData = await this.plateDbService.create({
|
||||
...plate,
|
||||
centerAlphabet: normalizedAlphabet,
|
||||
userId: currentUser,
|
||||
nationalCodeOfInsurer: body.nationalCodeOfInsurer,
|
||||
});
|
||||
|
||||
154
src/request-management/call-center-blame-v6.controller.ts
Normal file
154
src/request-management/call-center-blame-v6.controller.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { RequestManagementService } from "./request-management.service";
|
||||
import { RunCallCenterInquiryV6Dto } from "./dto/run-call-center-inquiry-v6.dto";
|
||||
|
||||
/**
|
||||
* V6 call-center blame API.
|
||||
*
|
||||
* A call-center agent takes the guilty party's details over the phone
|
||||
* (plate, national code, birthday), runs the insurance inquiry, then sends
|
||||
* the blame link via SMS. The user (guilty party) opens the link and
|
||||
* completes the form through the standard v2 user flow — except the
|
||||
* initial-form / inquiry step is skipped (`skipInitialFormStep=true`) because
|
||||
* the agent already did it.
|
||||
*
|
||||
* For THIRD_PARTY files: only the guilty party's data is collected here.
|
||||
* The damaged party sees their own portion of the page and fills their info
|
||||
* as normal after the blame link is opened.
|
||||
*/
|
||||
@ApiTags("call-center-blame (v6)")
|
||||
@Controller("v6/call-center-blame")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.CALL_CENTER)
|
||||
export class CallCenterBlameV6Controller {
|
||||
constructor(
|
||||
private readonly requestManagementService: RequestManagementService,
|
||||
) {}
|
||||
|
||||
@Get("my-files")
|
||||
@ApiOperation({ summary: "[V6] List blame files created by this call-center agent" })
|
||||
@ApiResponse({ status: 200, description: "List of blame files started by this agent" })
|
||||
getMyFiles(@CurrentUser() agent: any) {
|
||||
return this.requestManagementService.getMyCallCenterFilesV6(agent);
|
||||
}
|
||||
|
||||
@Post("create")
|
||||
@ApiOperation({
|
||||
summary: "[V6] Create a call-center–initiated blame file",
|
||||
description:
|
||||
"Creates a LINK blame file. The call-center agent collects the guilty " +
|
||||
"party's plate + national-code over the phone, calls run-inquiry to store " +
|
||||
"the results, then calls send-link to SMS the blame URL to the user. " +
|
||||
"The user opens the link and fills the form via the normal v2 flow; the " +
|
||||
"initial-form/inquiry step is automatically skipped because the agent " +
|
||||
"already ran it.",
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["type"],
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["THIRD_PARTY", "CAR_BODY"],
|
||||
example: "THIRD_PARTY",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
createFile(
|
||||
@CurrentUser() agent: any,
|
||||
@Body("type") type: "THIRD_PARTY" | "CAR_BODY",
|
||||
) {
|
||||
return this.requestManagementService.createCallCenterInitiatedBlameV6(agent, { type });
|
||||
}
|
||||
|
||||
@Post("run-inquiry/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "[V6] Run plate + insurance inquiry for the guilty party",
|
||||
description:
|
||||
"Call after `create`. The agent supplies the plate and national-code data " +
|
||||
"collected from the caller. Plate + third-party block inquiry is executed " +
|
||||
"and the results are stored on the blame document. " +
|
||||
"For THIRD_PARTY files only the guilty party (first party) is inquired here; " +
|
||||
"the damaged party's inquiry is handled later by the user via the link. " +
|
||||
"Sheba (IBAN) is not collected here — the user adds it themselves.",
|
||||
})
|
||||
@ApiParam({ name: "requestId", description: "Blame request ID from `create`" })
|
||||
@ApiBody({ type: RunCallCenterInquiryV6Dto })
|
||||
runInquiry(
|
||||
@CurrentUser() agent: any,
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() dto: RunCallCenterInquiryV6Dto,
|
||||
) {
|
||||
return this.requestManagementService.runCallCenterInquiryV6(agent, requestId, dto);
|
||||
}
|
||||
|
||||
@Post("send-link/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "[V6] Send blame link to the guilty party via SMS",
|
||||
description:
|
||||
"Call after `run-inquiry`. Provide the guilty party's phone number; " +
|
||||
"the service registers the user if needed, stores them as the first party, " +
|
||||
"and sends the blame invite link via SMS. The user opens the link and " +
|
||||
"completes the blame form via the standard v2 user flow (initial-form step skipped).",
|
||||
})
|
||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["phoneNumber"],
|
||||
properties: {
|
||||
phoneNumber: {
|
||||
type: "string",
|
||||
example: "09121234567",
|
||||
description: "Mobile number of the guilty party",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
sendLink(
|
||||
@CurrentUser() agent: any,
|
||||
@Param("requestId") requestId: string,
|
||||
@Body("phoneNumber") phoneNumber: string,
|
||||
) {
|
||||
return this.requestManagementService.sendCallCenterLinkV6(agent, requestId, { phoneNumber });
|
||||
}
|
||||
|
||||
@Get("blame/:requestId")
|
||||
@ApiOperation({
|
||||
summary: "[V6] Get a single blame file created by this agent",
|
||||
description:
|
||||
"Returns the current status, workflow step, and party data for one blame " +
|
||||
"file started by this call-center agent. Useful for checking whether the " +
|
||||
"user has opened the link and progressed through the form.",
|
||||
})
|
||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||
getBlame(
|
||||
@CurrentUser() agent: any,
|
||||
@Param("requestId") requestId: string,
|
||||
) {
|
||||
return this.requestManagementService.getCallCenterBlameDetailV6(agent, requestId);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
|
||||
import { Types } from "mongoose";
|
||||
import { AddPlateDto } from "src/profile/dto/user/AddPlateDto";
|
||||
import { StepsEnum } from "src/Types&Enums/blame-request-management/steps.enum";
|
||||
@@ -190,6 +191,52 @@ export class BlameConfessionDtoV2 {
|
||||
imGuilty?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 initial-form step submitted with a VIN/chassis number instead of a plate.
|
||||
* All identity and license fields from {@link AddPlateDto} are preserved; only
|
||||
* `plate` is replaced by `vin` (the 17-character chassis / VIN string).
|
||||
*/
|
||||
export class InitialFormVinDto {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
required: true,
|
||||
description: "17-character VIN / chassis number (شماره شاسی)",
|
||||
example: "NAAM01E15HK123456",
|
||||
maxLength: 17,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(17)
|
||||
vin: string;
|
||||
|
||||
@ApiProperty({ type: String, required: true })
|
||||
nationalCodeOfInsurer: string;
|
||||
|
||||
@ApiProperty({ type: String, required: true })
|
||||
nationalCodeOfDriver: string;
|
||||
|
||||
@ApiProperty({ type: String, required: true })
|
||||
insurerLicense: string;
|
||||
|
||||
@ApiProperty({ type: String, required: true })
|
||||
driverLicense: string;
|
||||
|
||||
@ApiProperty({ type: Boolean, required: true })
|
||||
driverIsInsurer: boolean;
|
||||
|
||||
@ApiProperty({ type: Boolean, required: true, default: false })
|
||||
isNewCar: boolean;
|
||||
|
||||
@ApiProperty({ type: Boolean, required: true })
|
||||
userNoCertificate: boolean;
|
||||
|
||||
@ApiProperty({ type: Number, required: true })
|
||||
insurerBirthday: number;
|
||||
|
||||
@ApiPropertyOptional({ type: String, required: false })
|
||||
driverBirthday: string | null;
|
||||
}
|
||||
|
||||
// export class DocsOfThisFile {
|
||||
// @ApiProperty()
|
||||
// firstPartyFile?: FirstPartyFileDto;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEnum, IsOptional, IsString } from "class-validator";
|
||||
import { IsBoolean, IsEnum, IsOptional } from "class-validator";
|
||||
import { CreationMethod } from "../entities/schema/request-management.schema";
|
||||
|
||||
export class CreateExpertInitiatedFileDto {
|
||||
@@ -18,4 +18,17 @@ export class CreateExpertInitiatedFileDto {
|
||||
})
|
||||
@IsEnum(CreationMethod)
|
||||
creationMethod: CreationMethod;
|
||||
|
||||
/**
|
||||
* V5 only. When true the resulting claim will require FileMaker approval
|
||||
* before fanavaran submission. V4 files must leave this unset (or false).
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"V5 only — when true the claim requires FileMaker approval before fanavaran submission.",
|
||||
example: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
requiresFileMakerApproval?: boolean;
|
||||
}
|
||||
|
||||
85
src/request-management/dto/run-call-center-inquiry-v6.dto.ts
Normal file
85
src/request-management/dto/run-call-center-inquiry-v6.dto.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
class PlateV6Dto {
|
||||
@ApiProperty({ example: "44", description: "Left two digits" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
leftDigits: string;
|
||||
|
||||
@ApiProperty({ example: "ب", description: "Center alphabet letter" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
centerAlphabet: string;
|
||||
|
||||
@ApiProperty({ example: "111", description: "Center three digits" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
centerDigits: string;
|
||||
|
||||
@ApiProperty({ example: "22", description: "Right two digits (Iran region code)" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
ir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inquiry body for the V6 call-center flow.
|
||||
* Same as V3 but without `sheba` — the user adds their own IBAN later via the link.
|
||||
*/
|
||||
export class RunCallCenterInquiryV6Dto {
|
||||
@ApiProperty({
|
||||
type: PlateV6Dto,
|
||||
description: "Plate segments — Tejarat block / third-party inquiry.",
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => PlateV6Dto)
|
||||
plate: PlateV6Dto;
|
||||
|
||||
@ApiProperty({ example: "1234567890", description: "National code of the policyholder (insurer)" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
nationalCodeOfInsurer: string;
|
||||
|
||||
@ApiProperty({ example: "1234567890", description: "National code of the driver" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
nationalCodeOfDriver: string;
|
||||
|
||||
@ApiProperty({ example: true, description: "Whether the driver is the same person as the insurer" })
|
||||
@IsBoolean()
|
||||
driverIsInsurer: boolean;
|
||||
|
||||
@ApiProperty({ example: 13780624, description: "Insurer birth date (Jalali)" })
|
||||
insurerBirthday: number | string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 13780624,
|
||||
description: "Driver birth date (Jalali). Required when driverIsInsurer is false.",
|
||||
})
|
||||
@IsOptional()
|
||||
driverBirthday?: number | string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "123456789",
|
||||
description: "Driver license (required when driverIsInsurer is false).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
driverLicense?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "123456789",
|
||||
description: "Insurer license (required when driverIsInsurer is true).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
insurerLicense?: string;
|
||||
}
|
||||
@@ -23,7 +23,9 @@ export class BlameRequestDbService {
|
||||
return this.blameRequestModel.findOne(filter);
|
||||
}
|
||||
|
||||
async findById(id: string | Types.ObjectId): Promise<BlameRequestDocument | null> {
|
||||
async findById(
|
||||
id: string | Types.ObjectId,
|
||||
): Promise<BlameRequestDocument | null> {
|
||||
return this.blameRequestModel.findById(id);
|
||||
}
|
||||
|
||||
@@ -72,5 +74,11 @@ export class BlameRequestDbService {
|
||||
: this.blameRequestModel.find(filter);
|
||||
return query.exec() as Promise<BlameRequestDocument[]>;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMany(
|
||||
filter: FilterQuery<BlameRequest>,
|
||||
): Promise<{ deletedCount: number }> {
|
||||
const result = await this.blameRequestModel.deleteMany(filter);
|
||||
return { deletedCount: result.deletedCount };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,48 @@ export class BlameRequest {
|
||||
/** Who fills the blame data: CUSTOMER (LINK) or EXPERT (IN_PERSON). */
|
||||
@Prop({ type: String, enum: FilledBy })
|
||||
filledBy?: FilledBy;
|
||||
|
||||
/**
|
||||
* True when this file was created by a FileMaker (V4 split flow).
|
||||
* These files are sealed by the FileMaker and completed by a FileReviewer.
|
||||
*/
|
||||
@Prop({ default: false })
|
||||
isMadeByFileMaker?: boolean;
|
||||
|
||||
/**
|
||||
* V5 only. When true the resulting claim requires FileMaker approval before
|
||||
* fanavaran submission. V4 files never set this; it is written at creation
|
||||
* time by the V5 controller and propagated to the linked claim.
|
||||
*/
|
||||
@Prop({ default: false })
|
||||
requiresFileMakerApproval?: boolean;
|
||||
|
||||
/**
|
||||
* The FileReviewer who claimed this file for completion (V4 flow only).
|
||||
* Set when a FileReviewer calls assign on this file; enforces one-reviewer-per-file.
|
||||
*/
|
||||
@Prop({ type: Types.ObjectId })
|
||||
assignedFileReviewerId?: Types.ObjectId;
|
||||
|
||||
/**
|
||||
* V6 call-center flow: true when this blame was started by a call-center agent
|
||||
* on behalf of the guilty party who called in.
|
||||
*/
|
||||
@Prop({ default: false })
|
||||
callCenterInitiated?: boolean;
|
||||
|
||||
/**
|
||||
* V6 call-center flow: the call-center agent who created this file.
|
||||
*/
|
||||
@Prop({ type: Types.ObjectId })
|
||||
initiatedByCallCenterId?: Types.ObjectId;
|
||||
|
||||
/**
|
||||
* V6 call-center flow: when true the user-side blame page should skip the
|
||||
* inquiry / initial-form step (the call-center agent already ran the inquiry).
|
||||
*/
|
||||
@Prop({ default: false })
|
||||
skipInitialFormStep?: boolean;
|
||||
}
|
||||
|
||||
export type BlameRequestDocument = HydratedDocument<BlameRequest>;
|
||||
|
||||
@@ -41,6 +41,10 @@ export class Person {
|
||||
|
||||
@Prop({ type: String })
|
||||
driverBirthday?: string | null;
|
||||
|
||||
/** Cached Fanavaran party ID resolved from nationalCodeOfDriver + driverBirthday + driverIsInsurer */
|
||||
@Prop({ type: Number })
|
||||
fanavaranDriverId?: number;
|
||||
}
|
||||
export const PersonSchema = SchemaFactory.createForClass(Person);
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export class Workflow {
|
||||
@Prop({ type: Date })
|
||||
lockedAt?: Date;
|
||||
|
||||
/** Lock expiry used by UI countdown (typically lockedAt + 15m). */
|
||||
/** Lock expiry used by UI countdown (typically lockedAt + 30m). */
|
||||
@Prop({ type: Date })
|
||||
expiredAt?: Date;
|
||||
|
||||
@@ -50,7 +50,7 @@ export class Workflow {
|
||||
lockedBy?: ActorLock;
|
||||
|
||||
/**
|
||||
* First expert who called review assign; kept after the 15m workflow lock expires
|
||||
* First expert who called review assign; kept after the 30m workflow lock expires
|
||||
* so no other expert can take the case while it remains in the expert queue.
|
||||
*/
|
||||
@Prop({ type: ActorLockSchema })
|
||||
|
||||
@@ -45,9 +45,7 @@ import {
|
||||
SelectOuterPartsV2Dto,
|
||||
SelectOuterPartsV2ResponseDto,
|
||||
} from "src/claim-request-management/dto/select-outer-parts-v2.dto";
|
||||
import {
|
||||
SelectOtherPartsV2ResponseDto,
|
||||
} from "src/claim-request-management/dto/select-other-parts-v2.dto";
|
||||
import { SelectOtherPartsV2ResponseDto } from "src/claim-request-management/dto/select-other-parts-v2.dto";
|
||||
import { SelectOtherPartsV3Dto } from "src/claim-request-management/dto/select-other-parts-v3.dto";
|
||||
import {
|
||||
UploadRequiredDocumentV2Dto,
|
||||
@@ -117,7 +115,8 @@ export class ExpertInitiatedBlameV3MirrorController {
|
||||
@ApiBody({ type: SendPartyOtpDto })
|
||||
@ApiOperation({
|
||||
summary: "Send OTP to one party",
|
||||
description: "Guilty party first; damaged party after guilty party has signed (THIRD_PARTY).",
|
||||
description:
|
||||
"Guilty party first; damaged party after guilty party has signed (THIRD_PARTY).",
|
||||
})
|
||||
async sendPartyOtp(
|
||||
@CurrentUser() expert: any,
|
||||
@@ -139,7 +138,11 @@ export class ExpertInitiatedBlameV3MirrorController {
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() dto: VerifyPartyOtpDto,
|
||||
) {
|
||||
return this.requestManagementService.verifyPartyOtpV2(expert, requestId, dto);
|
||||
return this.requestManagementService.verifyPartyOtpV2(
|
||||
expert,
|
||||
requestId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("car-body-form/:requestId")
|
||||
@@ -181,7 +184,9 @@ export class ExpertInitiatedBlameV3MirrorController {
|
||||
@Post("add-detail-location/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: LocationDto })
|
||||
@ApiOperation({ summary: "Add location for current party (partyRole FIRST or SECOND)" })
|
||||
@ApiOperation({
|
||||
summary: "Add location for current party (partyRole FIRST or SECOND)",
|
||||
})
|
||||
async addLocation(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: LocationDto,
|
||||
@@ -400,38 +405,42 @@ export class ExpertInitiatedBlameV3MirrorController {
|
||||
|
||||
@Patch("select-other-parts/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-required-document",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `v3-other-parts-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiOperation({
|
||||
summary: "Select other damaged parts (V3)",
|
||||
description:
|
||||
"Other parts only — sheba and national code were collected during run-inquiries. Optional car green card file.",
|
||||
})
|
||||
@ApiBody({
|
||||
description: "Other parts selection. Bank info is already on the claim from run-inquiries.",
|
||||
description:
|
||||
"Other parts selection. Bank info is already on the claim from run-inquiries.",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
otherParts: {
|
||||
oneOf: [
|
||||
{ type: "array", items: { type: "string", enum: ["engine", "suspension", "brake_system", "electrical", "radiator", "transmission", "exhaust", "headlight", "taillight", "mirror", "glass"] } },
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"engine",
|
||||
"suspension",
|
||||
"brake_system",
|
||||
"electrical",
|
||||
"radiator",
|
||||
"transmission",
|
||||
"exhaust",
|
||||
"headlight",
|
||||
"taillight",
|
||||
"mirror",
|
||||
"glass",
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "string", description: "JSON string array for multipart" },
|
||||
],
|
||||
example: ["engine", "suspension"],
|
||||
},
|
||||
file: { type: "string", format: "binary", description: "Optional car green card" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -439,17 +448,12 @@ export class ExpertInitiatedBlameV3MirrorController {
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOtherPartsV3Dto,
|
||||
@CurrentUser() expert: any,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||
if (file) {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
}
|
||||
return this.claimRequestManagementService.selectOtherPartsV3(
|
||||
claimRequestId,
|
||||
body,
|
||||
expert.sub,
|
||||
expert,
|
||||
file,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { extname } from "node:path";
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Param,
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
LocationDto,
|
||||
} from "./dto/create-request-management.dto";
|
||||
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
|
||||
import { CreationMethod } from "./entities/schema/request-management.schema";
|
||||
import { SendPartyOtpsDto } from "./dto/send-party-otps.dto";
|
||||
import { VerifyPartyOtpsDto } from "./dto/verify-party-otps.dto";
|
||||
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
|
||||
@@ -76,17 +76,17 @@ export class ExpertInitiatedBlameMirrorController {
|
||||
@ApiOperation({
|
||||
summary: "[Expert mirror] Create expert-initiated blame file",
|
||||
description:
|
||||
"Use creationMethod=IN_PERSON for the on-site flow. Creates a BlameRequest owned by the parties but filled by the expert.",
|
||||
"Creates an IN_PERSON blame file filled by the expert on behalf of both parties. creationMethod is always forced to IN_PERSON regardless of what is sent.",
|
||||
})
|
||||
@ApiBody({ type: CreateExpertInitiatedFileDto })
|
||||
async create(
|
||||
@CurrentUser() expert: any,
|
||||
@Body() dto: CreateExpertInitiatedFileDto,
|
||||
) {
|
||||
return this.requestManagementService.createExpertInitiatedBlameV2(
|
||||
expert,
|
||||
dto,
|
||||
);
|
||||
return this.requestManagementService.createExpertInitiatedBlameV2(expert, {
|
||||
...dto,
|
||||
creationMethod: CreationMethod.IN_PERSON,
|
||||
});
|
||||
}
|
||||
|
||||
@Post("send-link/:requestId")
|
||||
@@ -159,7 +159,7 @@ export class ExpertInitiatedBlameMirrorController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post("/initial-form/:requestId")
|
||||
@Post("/run-inquiries/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: AddPlateDto })
|
||||
@ApiOperation({
|
||||
@@ -362,20 +362,6 @@ export class ExpertInitiatedBlameMirrorController {
|
||||
@CurrentUser() expert: any,
|
||||
@UploadedFile() sign: Express.Multer.File,
|
||||
) {
|
||||
// Guard: accident fields (accidentWay, etc.) must be filled before signing.
|
||||
// This prevents the expert from bypassing add-accident-fields and avoids the
|
||||
// double-sign bug where add-accident-fields re-enters WAITING_FOR_SIGNATURES
|
||||
// after a signature was already recorded.
|
||||
const requestData = await this.requestManagementService.getBlameRequestV2(
|
||||
requestId,
|
||||
expert,
|
||||
);
|
||||
if (!(requestData?.expert?.decision as any)?.fields?.accidentWay) {
|
||||
throw new BadRequestException(
|
||||
"Accident fields (accidentWay, accidentReason, accidentType) must be submitted via add-accident-fields before signing.",
|
||||
);
|
||||
}
|
||||
|
||||
await this.mediaPolicyService.assertForBlame(sign, requestId, "image");
|
||||
const partyRole =
|
||||
body?.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
|
||||
@@ -389,7 +375,7 @@ export class ExpertInitiatedBlameMirrorController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post("add-accident-fields/:requestId")
|
||||
@Post("accident-fields/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: ExpertAccidentFieldsDto })
|
||||
@ApiOperation({
|
||||
|
||||
391
src/request-management/file-maker-blame-v4.controller.ts
Normal file
391
src/request-management/file-maker-blame-v4.controller.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { extname } from "node:path";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { MediaPolicyService } from "src/media-policy/media-policy.service";
|
||||
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { CreationMethod } from "./entities/schema/request-management.schema";
|
||||
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
|
||||
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
|
||||
import { RunInquiriesV3Dto } from "./dto/run-inquiries-v3.dto";
|
||||
import {
|
||||
CarBodyFormDto,
|
||||
DescriptionDto,
|
||||
LocationDto,
|
||||
} from "./dto/create-request-management.dto";
|
||||
import { RequestManagementService } from "./request-management.service";
|
||||
import { PartyRole } from "./entities/schema/partyRole.enum";
|
||||
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||
import {
|
||||
UploadRequiredDocumentV2Dto,
|
||||
UploadRequiredDocumentV2ResponseDto,
|
||||
} from "src/claim-request-management/dto/upload-document-v2.dto";
|
||||
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
||||
|
||||
/**
|
||||
* V4 FileMaker flow — first half of the split blame workflow.
|
||||
*
|
||||
* The FileMaker is responsible for everything up to and including the final
|
||||
* party signature. After both signatures are collected the file is considered
|
||||
* "sealed" and a FileReviewer can pick it up.
|
||||
*
|
||||
* THIRD_PARTY sequence:
|
||||
* create → send-party-otp (guilty) → verify-party-otp (guilty)
|
||||
* → [car-body-form if CAR_BODY] → run-inquiries (guilty + auto claim)
|
||||
* → add-detail-location / add-detail-description / upload-voice (guilty)
|
||||
* → sign (guilty)
|
||||
* → send-party-otp (damaged) → verify-party-otp (damaged)
|
||||
* → run-inquiries (damaged)
|
||||
* → add-detail-location / add-detail-description / upload-voice (damaged)
|
||||
* → sign (damaged) ← FileMaker job done here
|
||||
*
|
||||
* CAR_BODY sequence: same but single party — omit second-party steps.
|
||||
*/
|
||||
@ApiTags("v4 FileMaker — blame file creation")
|
||||
@Controller("v4/file-maker/blame-request-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.FILE_MAKER)
|
||||
export class FileMakerBlameV4Controller {
|
||||
constructor(
|
||||
private readonly requestManagementService: RequestManagementService,
|
||||
private readonly mediaPolicyService: MediaPolicyService,
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
) {}
|
||||
|
||||
// ─── File creation ───────────────────────────────────────────────────────────
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: "Create IN_PERSON blame file (FileMaker)" })
|
||||
@ApiBody({ type: CreateExpertInitiatedFileDto })
|
||||
async create(
|
||||
@CurrentUser() fileMaker: any,
|
||||
@Body() dto: CreateExpertInitiatedFileDto,
|
||||
) {
|
||||
return this.requestManagementService.createExpertInitiatedBlameV2(
|
||||
fileMaker,
|
||||
// requiresFileMakerApproval is V5-only; explicitly exclude it from V4 so
|
||||
// a client that accidentally sends the field cannot poison the blame record.
|
||||
{ ...dto, creationMethod: CreationMethod.IN_PERSON, requiresFileMakerApproval: false },
|
||||
);
|
||||
}
|
||||
|
||||
@Get("claim-id/:requestId")
|
||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||
@ApiOperation({
|
||||
summary: "Get linked claim ID",
|
||||
description:
|
||||
"Returns the claim auto-created during guilty-party run-inquiries. " +
|
||||
"Share this claim ID with the FileReviewer.",
|
||||
})
|
||||
async getLinkedClaimId(
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() fileMaker: any,
|
||||
) {
|
||||
return this.requestManagementService.getV3LinkedClaimForExpert(
|
||||
fileMaker,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── OTP ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Post("send-party-otp/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: SendPartyOtpDto })
|
||||
@ApiOperation({
|
||||
summary: "Send OTP to one party",
|
||||
description:
|
||||
"Guilty party first; damaged party after guilty party has signed (THIRD_PARTY).",
|
||||
})
|
||||
async sendPartyOtp(
|
||||
@CurrentUser() fileMaker: any,
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() dto: SendPartyOtpDto,
|
||||
) {
|
||||
return this.requestManagementService.sendPartyOtpV2(
|
||||
fileMaker,
|
||||
requestId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("verify-party-otp/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: VerifyPartyOtpDto })
|
||||
@ApiOperation({
|
||||
summary: "Verify one party OTP",
|
||||
description: "FIRST (guilty) then SECOND (damaged) on THIRD_PARTY files.",
|
||||
})
|
||||
async verifyPartyOtp(
|
||||
@CurrentUser() fileMaker: any,
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() dto: VerifyPartyOtpDto,
|
||||
) {
|
||||
return this.requestManagementService.verifyPartyOtpV2(
|
||||
fileMaker,
|
||||
requestId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pre-inquiry form ────────────────────────────────────────────────────────
|
||||
|
||||
@Post("car-body-form/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: CarBodyFormDto })
|
||||
@ApiOperation({
|
||||
summary: "CAR_BODY only — accident type before inquiries",
|
||||
description: "Submit after guilty-party OTP verify, before run-inquiries.",
|
||||
})
|
||||
async carBodyForm(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: CarBodyFormDto,
|
||||
@CurrentUser() fileMaker: any,
|
||||
) {
|
||||
return this.requestManagementService.carBodyAccidentTypeFormV3(
|
||||
requestId,
|
||||
body,
|
||||
fileMaker,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("run-inquiries/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: RunInquiriesV3Dto })
|
||||
@ApiOperation({
|
||||
summary: "Run external inquiries for the current party",
|
||||
description:
|
||||
"1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).",
|
||||
})
|
||||
async runInquiries(
|
||||
@CurrentUser() fileMaker: any,
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() dto: RunInquiriesV3Dto,
|
||||
) {
|
||||
return this.requestManagementService.runInquiriesV3(
|
||||
requestId,
|
||||
fileMaker,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Party details ────────────────────────────────────────────────────────────
|
||||
|
||||
@Post("add-detail-location/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: LocationDto })
|
||||
@ApiOperation({
|
||||
summary: "Add location for current party (partyRole FIRST or SECOND)",
|
||||
})
|
||||
async addLocation(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: LocationDto,
|
||||
@CurrentUser() fileMaker: any,
|
||||
@Body("partyRole") partyRole?: string,
|
||||
) {
|
||||
return this.requestManagementService.addPartyLocationV3(
|
||||
requestId,
|
||||
fileMaker,
|
||||
body,
|
||||
partyRole,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("add-detail-description/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: DescriptionDto })
|
||||
@ApiOperation({ summary: "Add description for current party" })
|
||||
async addDescription(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: DescriptionDto,
|
||||
@CurrentUser() fileMaker: any,
|
||||
@Body("partyRole") partyRole?: string,
|
||||
) {
|
||||
return this.requestManagementService.addPartyDescriptionV3(
|
||||
requestId,
|
||||
fileMaker,
|
||||
body,
|
||||
partyRole,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: { type: "string", format: "binary" },
|
||||
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
|
||||
},
|
||||
required: ["file"],
|
||||
},
|
||||
})
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/voice",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `v4-filemaker-voice-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@Post("upload-voice/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiOperation({ summary: "Upload voice for current party" })
|
||||
async uploadVoice(
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() fileMaker: any,
|
||||
@UploadedFile() voice: Express.Multer.File,
|
||||
@Body("partyRole") partyRole?: string,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForBlame(voice, requestId, "voice");
|
||||
return this.requestManagementService.addPartyVoiceV3(
|
||||
requestId,
|
||||
fileMaker,
|
||||
voice,
|
||||
partyRole,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Signatures (final FileMaker step) ───────────────────────────────────────
|
||||
|
||||
@Put("sign/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Party on-site signature (FileMaker final step)",
|
||||
description:
|
||||
"Collect FIRST (guilty) then SECOND (damaged) signatures. " +
|
||||
"After the last signature the file is ready for FileReviewer pickup.",
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["partyRole", "sign"],
|
||||
properties: {
|
||||
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
|
||||
isAccept: { type: "boolean", default: true },
|
||||
sign: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("sign", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/signs",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `v4-filemaker-party-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
async sign(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: { partyRole?: string; isAccept?: string | boolean },
|
||||
@CurrentUser() fileMaker: any,
|
||||
@UploadedFile() sign: Express.Multer.File,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForBlame(sign, requestId, "image");
|
||||
const partyRole =
|
||||
body?.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
|
||||
const isAccept = !(body?.isAccept === false || body?.isAccept === "false");
|
||||
return this.requestManagementService.expertUploadPartySignatureV3(
|
||||
fileMaker,
|
||||
requestId,
|
||||
partyRole,
|
||||
isAccept,
|
||||
sign,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Claim document upload ────────────────────────────────────────────────────
|
||||
|
||||
@Post("upload-document/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-documents",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `${file.originalname.split(".")[0]}-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiOperation({
|
||||
summary: "Upload one required claim document (FileMaker)",
|
||||
description:
|
||||
"Upload licenses and car cards against the auto-created claim. " +
|
||||
"Use claim-id/:requestId to obtain the claimRequestId after run-inquiries.",
|
||||
})
|
||||
async uploadDocument(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UploadRequiredDocumentV2Dto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() fileMaker: any,
|
||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.uploadRequiredDocumentV3(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
fileMaker.sub,
|
||||
fileMaker,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("capture-requirements/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiOperation({
|
||||
summary: "Capture requirements (step-aware)",
|
||||
description:
|
||||
"Initial documents phase: pre-capture docs only (no chassis/engine/metal plate). " +
|
||||
"CAPTURE_PART_DAMAGES phase: damaged parts + angles via capture-part, then chassis/engine/metal plate via upload-document. " +
|
||||
"Use `captureSequencePhase` and `captureSequenceHint` to drive the UI.",
|
||||
})
|
||||
async getCaptureRequirements(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() fileMaker: any,
|
||||
): Promise<GetCaptureRequirementsV2ResponseDto> {
|
||||
return this.claimRequestManagementService.getCaptureRequirementsV3(
|
||||
claimRequestId,
|
||||
fileMaker.sub,
|
||||
fileMaker,
|
||||
);
|
||||
}
|
||||
}
|
||||
390
src/request-management/file-maker-blame-v5.controller.ts
Normal file
390
src/request-management/file-maker-blame-v5.controller.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { extname } from "node:path";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { MediaPolicyService } from "src/media-policy/media-policy.service";
|
||||
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { CreationMethod } from "./entities/schema/request-management.schema";
|
||||
import { CreateExpertInitiatedFileDto } from "./dto/expert-initiated.dto";
|
||||
import { SendPartyOtpDto, VerifyPartyOtpDto } from "./dto/party-otp.dto";
|
||||
import { RunInquiriesV3Dto } from "./dto/run-inquiries-v3.dto";
|
||||
import {
|
||||
CarBodyFormDto,
|
||||
DescriptionDto,
|
||||
LocationDto,
|
||||
} from "./dto/create-request-management.dto";
|
||||
import { RequestManagementService } from "./request-management.service";
|
||||
import { PartyRole } from "./entities/schema/partyRole.enum";
|
||||
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||
import {
|
||||
UploadRequiredDocumentV2Dto,
|
||||
UploadRequiredDocumentV2ResponseDto,
|
||||
} from "src/claim-request-management/dto/upload-document-v2.dto";
|
||||
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
||||
|
||||
/**
|
||||
* V5 FileMaker flow — identical to V4 but under the /v5/ prefix.
|
||||
*
|
||||
* The only behavioural difference in the V5 flow is in the FileReviewer's final
|
||||
* step: instead of moving directly to WAITING_FOR_EXPERT, the blame video moves
|
||||
* the file to WAITING_FOR_FINANCIAL_EXPERT so a FinancialExpert can approve or
|
||||
* reject before fanavaran submission.
|
||||
*
|
||||
* The FileMaker side is unchanged; all sequence and endpoints are the same.
|
||||
*
|
||||
* THIRD_PARTY sequence:
|
||||
* create → send-party-otp (guilty) → verify-party-otp (guilty)
|
||||
* → [car-body-form if CAR_BODY] → run-inquiries (guilty + auto claim)
|
||||
* → add-detail-location / add-detail-description / upload-voice (guilty)
|
||||
* → sign (guilty)
|
||||
* → send-party-otp (damaged) → verify-party-otp (damaged)
|
||||
* → run-inquiries (damaged)
|
||||
* → add-detail-location / add-detail-description / upload-voice (damaged)
|
||||
* → sign (damaged) ← FileMaker job done here
|
||||
*/
|
||||
@ApiTags("v5 FileMaker — blame file creation (with FinancialExpert approval)")
|
||||
@Controller("v5/file-maker/blame-request-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.FILE_MAKER)
|
||||
export class FileMakerBlameV5Controller {
|
||||
constructor(
|
||||
private readonly requestManagementService: RequestManagementService,
|
||||
private readonly mediaPolicyService: MediaPolicyService,
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
) {}
|
||||
|
||||
// ─── File creation ───────────────────────────────────────────────────────────
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: "Create IN_PERSON blame file (FileMaker V5)" })
|
||||
@ApiBody({ type: CreateExpertInitiatedFileDto })
|
||||
async create(
|
||||
@CurrentUser() fileMaker: any,
|
||||
@Body() dto: CreateExpertInitiatedFileDto,
|
||||
) {
|
||||
return this.requestManagementService.createExpertInitiatedBlameV2(
|
||||
fileMaker,
|
||||
{ ...dto, creationMethod: CreationMethod.IN_PERSON, requiresFileMakerApproval: true },
|
||||
);
|
||||
}
|
||||
|
||||
@Get("claim-id/:requestId")
|
||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||
@ApiOperation({
|
||||
summary: "Get linked claim ID",
|
||||
description:
|
||||
"Returns the claim auto-created during guilty-party run-inquiries. " +
|
||||
"Share this claim ID with the FileReviewer.",
|
||||
})
|
||||
async getLinkedClaimId(
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() fileMaker: any,
|
||||
) {
|
||||
return this.requestManagementService.getV3LinkedClaimForExpert(
|
||||
fileMaker,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── OTP ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Post("send-party-otp/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: SendPartyOtpDto })
|
||||
@ApiOperation({
|
||||
summary: "Send OTP to one party",
|
||||
description:
|
||||
"Guilty party first; damaged party after guilty party has signed (THIRD_PARTY).",
|
||||
})
|
||||
async sendPartyOtp(
|
||||
@CurrentUser() fileMaker: any,
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() dto: SendPartyOtpDto,
|
||||
) {
|
||||
return this.requestManagementService.sendPartyOtpV2(
|
||||
fileMaker,
|
||||
requestId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("verify-party-otp/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: VerifyPartyOtpDto })
|
||||
@ApiOperation({
|
||||
summary: "Verify one party OTP",
|
||||
description: "FIRST (guilty) then SECOND (damaged) on THIRD_PARTY files.",
|
||||
})
|
||||
async verifyPartyOtp(
|
||||
@CurrentUser() fileMaker: any,
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() dto: VerifyPartyOtpDto,
|
||||
) {
|
||||
return this.requestManagementService.verifyPartyOtpV2(
|
||||
fileMaker,
|
||||
requestId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pre-inquiry form ────────────────────────────────────────────────────────
|
||||
|
||||
@Post("car-body-form/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: CarBodyFormDto })
|
||||
@ApiOperation({
|
||||
summary: "CAR_BODY only — accident type before inquiries",
|
||||
description: "Submit after guilty-party OTP verify, before run-inquiries.",
|
||||
})
|
||||
async carBodyForm(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: CarBodyFormDto,
|
||||
@CurrentUser() fileMaker: any,
|
||||
) {
|
||||
return this.requestManagementService.carBodyAccidentTypeFormV3(
|
||||
requestId,
|
||||
body,
|
||||
fileMaker,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("run-inquiries/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: RunInquiriesV3Dto })
|
||||
@ApiOperation({
|
||||
summary: "Run external inquiries for the current party",
|
||||
description:
|
||||
"1st call = guilty party (+ auto claim). 2nd call = damaged party (THIRD_PARTY, after guilty sign).",
|
||||
})
|
||||
async runInquiries(
|
||||
@CurrentUser() fileMaker: any,
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() dto: RunInquiriesV3Dto,
|
||||
) {
|
||||
return this.requestManagementService.runInquiriesV3(
|
||||
requestId,
|
||||
fileMaker,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Party details ────────────────────────────────────────────────────────────
|
||||
|
||||
@Post("add-detail-location/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: LocationDto })
|
||||
@ApiOperation({
|
||||
summary: "Add location for current party (partyRole FIRST or SECOND)",
|
||||
})
|
||||
async addLocation(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: LocationDto,
|
||||
@CurrentUser() fileMaker: any,
|
||||
@Body("partyRole") partyRole?: string,
|
||||
) {
|
||||
return this.requestManagementService.addPartyLocationV3(
|
||||
requestId,
|
||||
fileMaker,
|
||||
body,
|
||||
partyRole,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("add-detail-description/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: DescriptionDto })
|
||||
@ApiOperation({ summary: "Add description for current party" })
|
||||
async addDescription(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: DescriptionDto,
|
||||
@CurrentUser() fileMaker: any,
|
||||
@Body("partyRole") partyRole?: string,
|
||||
) {
|
||||
return this.requestManagementService.addPartyDescriptionV3(
|
||||
requestId,
|
||||
fileMaker,
|
||||
body,
|
||||
partyRole,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: { type: "string", format: "binary" },
|
||||
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
|
||||
},
|
||||
required: ["file"],
|
||||
},
|
||||
})
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/voice",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `v5-filemaker-voice-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@Post("upload-voice/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiOperation({ summary: "Upload voice for current party" })
|
||||
async uploadVoice(
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() fileMaker: any,
|
||||
@UploadedFile() voice: Express.Multer.File,
|
||||
@Body("partyRole") partyRole?: string,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForBlame(voice, requestId, "voice");
|
||||
return this.requestManagementService.addPartyVoiceV3(
|
||||
requestId,
|
||||
fileMaker,
|
||||
voice,
|
||||
partyRole,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Signatures (final FileMaker step) ───────────────────────────────────────
|
||||
|
||||
@Put("sign/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Party on-site signature (FileMaker final step)",
|
||||
description:
|
||||
"Collect FIRST (guilty) then SECOND (damaged) signatures. " +
|
||||
"After the last signature the file is ready for FileReviewer pickup.",
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["partyRole", "sign"],
|
||||
properties: {
|
||||
partyRole: { type: "string", enum: ["FIRST", "SECOND"] },
|
||||
isAccept: { type: "boolean", default: true },
|
||||
sign: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("sign", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/signs",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `v5-filemaker-party-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
async sign(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() body: { partyRole?: string; isAccept?: string | boolean },
|
||||
@CurrentUser() fileMaker: any,
|
||||
@UploadedFile() sign: Express.Multer.File,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForBlame(sign, requestId, "image");
|
||||
const partyRole =
|
||||
body?.partyRole === "SECOND" ? PartyRole.SECOND : PartyRole.FIRST;
|
||||
const isAccept = !(body?.isAccept === false || body?.isAccept === "false");
|
||||
return this.requestManagementService.expertUploadPartySignatureV3(
|
||||
fileMaker,
|
||||
requestId,
|
||||
partyRole,
|
||||
isAccept,
|
||||
sign,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Claim document upload ────────────────────────────────────────────────────
|
||||
|
||||
@Post("upload-document/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-documents",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `${file.originalname.split(".")[0]}-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiOperation({
|
||||
summary: "Upload one required claim document (FileMaker)",
|
||||
description:
|
||||
"Upload licenses and car cards against the auto-created claim. " +
|
||||
"Use claim-id/:requestId to obtain the claimRequestId after run-inquiries.",
|
||||
})
|
||||
async uploadDocument(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UploadRequiredDocumentV2Dto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() fileMaker: any,
|
||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.uploadRequiredDocumentV3(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
fileMaker.sub,
|
||||
fileMaker,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("capture-requirements/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiOperation({
|
||||
summary: "Capture requirements (step-aware)",
|
||||
description:
|
||||
"Initial documents phase: pre-capture docs only (no chassis/engine/metal plate). " +
|
||||
"CAPTURE_PART_DAMAGES phase: damaged parts + angles via capture-part, then chassis/engine/metal plate via upload-document. " +
|
||||
"Use `captureSequencePhase` and `captureSequenceHint` to drive the UI.",
|
||||
})
|
||||
async getCaptureRequirements(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() fileMaker: any,
|
||||
): Promise<GetCaptureRequirementsV2ResponseDto> {
|
||||
return this.claimRequestManagementService.getCaptureRequirementsV3(
|
||||
claimRequestId,
|
||||
fileMaker.sub,
|
||||
fileMaker,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { RequestManagementService } from "./request-management.service";
|
||||
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||
|
||||
class FileMakerRejectDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Reason for rejection (visible to FileReviewer)",
|
||||
example: "Damage assessment is incorrect — please re-evaluate parts X and Y",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* V5 FileMaker approval panel.
|
||||
*
|
||||
* After the full claim flow completes (FileReviewer does damage assessment via
|
||||
* expert-claim APIs, user signs), the claim lands in WAITING_FOR_FILE_MAKER_APPROVAL.
|
||||
* The FileMaker who created the blame file can then:
|
||||
*
|
||||
* approve → triggers fanavaran submission (claim → COMPLETED)
|
||||
* reject → sends claim back to WAITING_FOR_DAMAGE_EXPERT so the FileReviewer
|
||||
* can re-lock, adjust pricing, and redo the user interaction
|
||||
*
|
||||
* All endpoints operate on `claimRequestId` (the claim case ID, not the blame ID).
|
||||
* Use `GET v5/file-maker/blame-request-management/claim-id/:requestId` to obtain
|
||||
* the claimRequestId from the original blame request ID.
|
||||
*/
|
||||
@ApiTags("v5 FileMaker — claim approval before fanavaran")
|
||||
@Controller("v5/file-maker/claim-approval")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.FILE_MAKER)
|
||||
export class FileMakerClaimApprovalV5Controller {
|
||||
constructor(
|
||||
private readonly requestManagementService: RequestManagementService,
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
) {}
|
||||
|
||||
@Post("approve/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiOperation({
|
||||
summary: "Approve the completed claim (FileMaker V5)",
|
||||
description:
|
||||
"Approves a claim that is in WAITING_FOR_FILE_MAKER_APPROVAL status. " +
|
||||
"Marks the claim COMPLETED and triggers fanavaran submission.",
|
||||
})
|
||||
async approve(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() fileMaker: any,
|
||||
) {
|
||||
const result = await this.requestManagementService.fileMakerApproveV5(
|
||||
fileMaker,
|
||||
claimRequestId,
|
||||
);
|
||||
|
||||
// Trigger fanavaran auto-submit now that the claim is COMPLETED and the gate
|
||||
// (requiresFileMakerApproval) has been cleared.
|
||||
const fanavaran =
|
||||
await this.claimRequestManagementService.autoSubmitToFanavaranV2OnClaimCompleted(
|
||||
claimRequestId,
|
||||
);
|
||||
|
||||
return { ...result, fanavaran };
|
||||
}
|
||||
|
||||
@Post("reject/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: FileMakerRejectDto })
|
||||
@ApiOperation({
|
||||
summary: "Reject the completed claim back to FileReviewer (FileMaker V5)",
|
||||
description:
|
||||
"Rejects a claim that is in WAITING_FOR_FILE_MAKER_APPROVAL status. " +
|
||||
"Moves claim to FILE_MAKER_REJECTED so the FileReviewer can re-lock, " +
|
||||
"adjust the damage assessment, and restart user interaction as needed.",
|
||||
})
|
||||
async reject(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: FileMakerRejectDto,
|
||||
@CurrentUser() fileMaker: any,
|
||||
) {
|
||||
return this.requestManagementService.fileMakerRejectV5(
|
||||
fileMaker,
|
||||
claimRequestId,
|
||||
body?.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
479
src/request-management/file-reviewer-blame-v4.controller.ts
Normal file
479
src/request-management/file-reviewer-blame-v4.controller.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { extname } from "node:path";
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
InternalServerErrorException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { MediaPolicyService } from "src/media-policy/media-policy.service";
|
||||
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
|
||||
import { RequestManagementService } from "./request-management.service";
|
||||
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||
import {
|
||||
SelectOuterPartsV2Dto,
|
||||
SelectOuterPartsV2ResponseDto,
|
||||
} from "src/claim-request-management/dto/select-outer-parts-v2.dto";
|
||||
import { SelectOtherPartsV2ResponseDto } from "src/claim-request-management/dto/select-other-parts-v2.dto";
|
||||
import { SelectOtherPartsV3Dto } from "src/claim-request-management/dto/select-other-parts-v3.dto";
|
||||
import {
|
||||
UploadRequiredDocumentV2Dto,
|
||||
UploadRequiredDocumentV2ResponseDto,
|
||||
} from "src/claim-request-management/dto/upload-document-v2.dto";
|
||||
import {
|
||||
CapturePartV2Dto,
|
||||
CapturePartV2ResponseDto,
|
||||
} from "src/claim-request-management/dto/capture-part-v2.dto";
|
||||
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
||||
|
||||
/**
|
||||
* V4 FileReviewer flow — second half of the split blame workflow.
|
||||
*
|
||||
* The FileReviewer picks up a sealed file (both signatures collected by the
|
||||
* FileMaker) and completes the damage-assessment portion: accident fields,
|
||||
* documents, part selection, photo capture, and walk-around video (final step).
|
||||
*
|
||||
* Sequence:
|
||||
* accident-fields
|
||||
* → capture-requirements (via linked claimRequestId)
|
||||
* → upload-document (licenses, car cards)
|
||||
* → select-outer-parts
|
||||
* → select-other-parts
|
||||
* → capture-part (damaged-part photos + four angles)
|
||||
* → upload-document (chassis / engine — capture phase)
|
||||
* → car-capture (walk-around video — FINAL FileReviewer step)
|
||||
* ↳ claim → WAITING_FOR_DAMAGE_EXPERT, blame → COMPLETED
|
||||
*
|
||||
* NOTE: upload-video is a no-op for V4 (blame is already COMPLETED by car-capture).
|
||||
*/
|
||||
@ApiTags("v4 FileReviewer — blame file review & capture")
|
||||
@Controller("v4/file-reviewer/blame-request-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.FILE_REVIEWER)
|
||||
export class FileReviewerBlameV4Controller {
|
||||
constructor(
|
||||
private readonly requestManagementService: RequestManagementService,
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
private readonly mediaPolicyService: MediaPolicyService,
|
||||
) {}
|
||||
|
||||
// ─── Linked claim lookup ──────────────────────────────────────────────────────
|
||||
|
||||
@Get("claim-id/:requestId")
|
||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||
@ApiOperation({
|
||||
summary: "Get linked claim ID",
|
||||
description:
|
||||
"Returns the claim auto-created during FileMaker's guilty-party run-inquiries. " +
|
||||
"Use this claim ID for all capture/document/parts endpoints below.",
|
||||
})
|
||||
async getLinkedClaimId(
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
) {
|
||||
return this.requestManagementService.getV3LinkedClaimForExpert(
|
||||
fileReviewer,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Accident fields ──────────────────────────────────────────────────────────
|
||||
|
||||
@Post("accident-fields/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: ExpertAccidentFieldsDto })
|
||||
@ApiOperation({
|
||||
summary: "Accident type / fields (FileReviewer first step)",
|
||||
description:
|
||||
"Saves accident fields on the sealed file. " +
|
||||
"Does not move to WAITING_FOR_EXPERT — that happens after the final blame accident video.",
|
||||
})
|
||||
async addAccidentFields(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() fields: ExpertAccidentFieldsDto,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
) {
|
||||
return this.requestManagementService.expertAddAccidentFieldsForBlameV3(
|
||||
fileReviewer,
|
||||
requestId,
|
||||
fields,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Capture requirements ─────────────────────────────────────────────────────
|
||||
|
||||
@Get("capture-requirements/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiOperation({
|
||||
summary: "Capture requirements (step-aware)",
|
||||
description:
|
||||
"Initial documents phase: pre-capture docs only (no chassis/engine/metal plate). " +
|
||||
"CAPTURE_PART_DAMAGES phase: damaged parts + angles via capture-part, then chassis/engine/metal plate via upload-document. " +
|
||||
"Use `captureSequencePhase` and `captureSequenceHint` to drive the UI.",
|
||||
})
|
||||
async getCaptureRequirements(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
): Promise<GetCaptureRequirementsV2ResponseDto> {
|
||||
return this.claimRequestManagementService.getCaptureRequirementsV3(
|
||||
claimRequestId,
|
||||
fileReviewer.sub,
|
||||
fileReviewer,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Document upload ──────────────────────────────────────────────────────────
|
||||
|
||||
@Post("upload-document/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-documents",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `${file.originalname.split(".")[0]}-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiOperation({
|
||||
summary: "Upload one required claim document",
|
||||
description:
|
||||
"Initial phase (UPLOAD_REQUIRED_DOCUMENTS): licenses and car cards only. " +
|
||||
"Capture phase (CAPTURE_PART_DAMAGES, after parts + angles): chassis, engine, metal plate only.",
|
||||
})
|
||||
async uploadDocument(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UploadRequiredDocumentV2Dto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.uploadRequiredDocumentV3(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
fileReviewer.sub,
|
||||
fileReviewer,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Part selection ───────────────────────────────────────────────────────────
|
||||
|
||||
@Patch("select-outer-parts/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: SelectOuterPartsV2Dto })
|
||||
@ApiOperation({ summary: "Select damaged outer parts" })
|
||||
async selectOuterParts(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOuterPartsV2Dto,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
): Promise<SelectOuterPartsV2ResponseDto> {
|
||||
return this.claimRequestManagementService.selectOuterPartsV3(
|
||||
claimRequestId,
|
||||
body,
|
||||
fileReviewer.sub,
|
||||
fileReviewer,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("select-other-parts/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
|
||||
@ApiOperation({
|
||||
summary: "Select other damaged parts (V4)",
|
||||
description:
|
||||
"Other parts only — sheba and national code were collected during FileMaker's run-inquiries. Optional car green card file.",
|
||||
})
|
||||
@ApiBody({
|
||||
description:
|
||||
"Other parts selection. Bank info is already on the claim from run-inquiries.",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
otherParts: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"engine",
|
||||
"suspension",
|
||||
"brake_system",
|
||||
"electrical",
|
||||
"radiator",
|
||||
"transmission",
|
||||
"exhaust",
|
||||
"headlight",
|
||||
"taillight",
|
||||
"mirror",
|
||||
"glass",
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "string", description: "JSON string array for multipart" },
|
||||
],
|
||||
example: ["engine", "suspension"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async selectOtherParts(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOtherPartsV3Dto,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||
return this.claimRequestManagementService.selectOtherPartsV3(
|
||||
claimRequestId,
|
||||
body,
|
||||
fileReviewer.sub,
|
||||
fileReviewer,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Photo capture ────────────────────────────────────────────────────────────
|
||||
|
||||
@Post("capture-part/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-captures",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `v4-capture-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiOperation({
|
||||
summary: "Car angles and damaged-part photos",
|
||||
description:
|
||||
"CAPTURE_PART_DAMAGES: (1) all damaged-part photos, (2) four car angles, " +
|
||||
"(3) chassis/engine/metal-plate via upload-document. Then car-capture walk-around video.",
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["captureType", "captureKey", "file"],
|
||||
properties: {
|
||||
captureType: {
|
||||
type: "string",
|
||||
enum: ["angle", "part"],
|
||||
example: "angle",
|
||||
},
|
||||
captureKey: {
|
||||
type: "string",
|
||||
example: "front",
|
||||
description:
|
||||
"For angle: front/back/left/right. For part: hood/front_bumper/etc.",
|
||||
},
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async capturePart(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: CapturePartV2Dto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
): Promise<CapturePartV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.capturePartV3(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
fileReviewer.sub,
|
||||
fileReviewer,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Owner signature on expert pricing ───────────────────────────────────────
|
||||
|
||||
@Put("claim-sign/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiBody({
|
||||
description: "Signature file, agreement, and branch",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["sign", "agree", "branchId"],
|
||||
properties: {
|
||||
sign: { type: "string", format: "binary", description: "Signature image" },
|
||||
agree: { type: "boolean", description: "true to accept, false to reject" },
|
||||
branchId: { type: "string", description: "Insurer branch ID" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Owner signature on expert pricing (V4 — FileReviewer acts on behalf of user)",
|
||||
description:
|
||||
"FileReviewer submits the damaged party's signature during the final approval stage. " +
|
||||
"Delegates to the same service method as the user sign endpoint; the FileReviewer's " +
|
||||
"identity is resolved to the claim owner via `resolveClaimEffectiveUserId`.",
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("sign", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-sign",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
const base = file.originalname.split(/[.,\s-]/)[0] || "sign";
|
||||
callback(null, `${base}-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
async submitOwnerSign(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body("agree") agree: string | boolean,
|
||||
@Body("branchId") branchId: string,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
@UploadedFile() sign: Express.Multer.File,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image");
|
||||
const agreed =
|
||||
typeof agree === "string"
|
||||
? agree === "true" || agree === "1"
|
||||
: Boolean(agree);
|
||||
try {
|
||||
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
|
||||
claimRequestId,
|
||||
agreed,
|
||||
typeof branchId === "string" ? branchId : "",
|
||||
sign,
|
||||
fileReviewer.sub,
|
||||
fileReviewer,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to submit signature",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Walk-around video ────────────────────────────────────────────────────────
|
||||
|
||||
@Patch("car-capture/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/car-capture-videos/",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `v4-claim-video-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["file"],
|
||||
properties: { file: { type: "string", format: "binary" } },
|
||||
},
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Walk-around video (final FileReviewer step — V4)",
|
||||
description:
|
||||
"Last FileReviewer action. Upload after capture-part is complete (parts, angles, chassis/engine docs). " +
|
||||
"Finalises the claim → WAITING_FOR_DAMAGE_EXPERT and marks the blame as COMPLETED.",
|
||||
})
|
||||
async carCapture(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@UploadedFile("file") file: Express.Multer.File,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
|
||||
return this.claimRequestManagementService.setVideoCaptureV3(
|
||||
claimRequestId,
|
||||
file,
|
||||
fileReviewer.sub,
|
||||
fileReviewer,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Final blame video ────────────────────────────────────────────────────────
|
||||
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { file: { type: "string", format: "binary" } },
|
||||
},
|
||||
})
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/video",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `v4-blame-accident-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@Post("upload-video/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiOperation({
|
||||
summary: "Blame accident video (no-op for V4 — car-capture is the final step)",
|
||||
description:
|
||||
"Deprecated for V4. The blame is already COMPLETED by car-capture. " +
|
||||
"Calling this endpoint returns an idempotent success response.",
|
||||
})
|
||||
async uploadBlameVideo(
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
if (file) {
|
||||
await this.mediaPolicyService.assertForBlame(file, requestId, "video");
|
||||
}
|
||||
return this.requestManagementService.expertUploadBlameVideoV3(
|
||||
fileReviewer,
|
||||
requestId,
|
||||
file,
|
||||
);
|
||||
}
|
||||
}
|
||||
480
src/request-management/file-reviewer-blame-v5.controller.ts
Normal file
480
src/request-management/file-reviewer-blame-v5.controller.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
import { extname } from "node:path";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
InternalServerErrorException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { MediaPolicyService } from "src/media-policy/media-policy.service";
|
||||
import { DEFAULT_MEDIA_MAX_BYTES } from "src/client/client.service";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ExpertAccidentFieldsDto } from "./dto/expert-accident-fields.dto";
|
||||
import { RequestManagementService } from "./request-management.service";
|
||||
import { ClaimRequestManagementService } from "src/claim-request-management/claim-request-management.service";
|
||||
import {
|
||||
SelectOuterPartsV2Dto,
|
||||
SelectOuterPartsV2ResponseDto,
|
||||
} from "src/claim-request-management/dto/select-outer-parts-v2.dto";
|
||||
import { SelectOtherPartsV2ResponseDto } from "src/claim-request-management/dto/select-other-parts-v2.dto";
|
||||
import { SelectOtherPartsV3Dto } from "src/claim-request-management/dto/select-other-parts-v3.dto";
|
||||
import {
|
||||
UploadRequiredDocumentV2Dto,
|
||||
UploadRequiredDocumentV2ResponseDto,
|
||||
} from "src/claim-request-management/dto/upload-document-v2.dto";
|
||||
import {
|
||||
CapturePartV2Dto,
|
||||
CapturePartV2ResponseDto,
|
||||
} from "src/claim-request-management/dto/capture-part-v2.dto";
|
||||
import { GetCaptureRequirementsV2ResponseDto } from "src/claim-request-management/dto/capture-requirements-v2.dto";
|
||||
|
||||
/**
|
||||
* V5 FileReviewer flow — same as V4 except after the damage expert completes
|
||||
* their review, the claim goes to WAITING_FOR_FILE_MAKER_APPROVAL instead of
|
||||
* being submitted directly to fanavaran.
|
||||
*
|
||||
* Sequence:
|
||||
* accident-fields
|
||||
* → capture-requirements (via linked claimRequestId)
|
||||
* → upload-document (licenses, car cards)
|
||||
* → select-outer-parts
|
||||
* → select-other-parts
|
||||
* → capture-part (damaged-part photos + four angles)
|
||||
* → upload-document (chassis / engine — capture phase)
|
||||
* → car-capture (walk-around video — FINAL FileReviewer step)
|
||||
* ↳ claim → WAITING_FOR_DAMAGE_EXPERT, blame → COMPLETED
|
||||
* ↳ after expert review: claim → WAITING_FOR_FILE_MAKER_APPROVAL
|
||||
*
|
||||
* NOTE: upload-video is a no-op for V5 (blame is already COMPLETED by car-capture).
|
||||
*/
|
||||
@ApiTags("v5 FileReviewer — blame file review & capture (with FinancialExpert approval)")
|
||||
@Controller("v5/file-reviewer/blame-request-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.FILE_REVIEWER)
|
||||
export class FileReviewerBlameV5Controller {
|
||||
constructor(
|
||||
private readonly requestManagementService: RequestManagementService,
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
private readonly mediaPolicyService: MediaPolicyService,
|
||||
) {}
|
||||
|
||||
// ─── Linked claim lookup ──────────────────────────────────────────────────────
|
||||
|
||||
@Get("claim-id/:requestId")
|
||||
@ApiParam({ name: "requestId", description: "Blame request ID" })
|
||||
@ApiOperation({
|
||||
summary: "Get linked claim ID",
|
||||
description:
|
||||
"Returns the claim auto-created during FileMaker's guilty-party run-inquiries. " +
|
||||
"Use this claim ID for all capture/document/parts endpoints below.",
|
||||
})
|
||||
async getLinkedClaimId(
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
) {
|
||||
return this.requestManagementService.getV3LinkedClaimForExpert(
|
||||
fileReviewer,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Accident fields ──────────────────────────────────────────────────────────
|
||||
|
||||
@Post("accident-fields/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiBody({ type: ExpertAccidentFieldsDto })
|
||||
@ApiOperation({
|
||||
summary: "Accident type / fields (FileReviewer first step)",
|
||||
description:
|
||||
"Saves accident fields on the sealed file. " +
|
||||
"Does not move to WAITING_FOR_FINANCIAL_EXPERT — that happens after the final blame accident video.",
|
||||
})
|
||||
async addAccidentFields(
|
||||
@Param("requestId") requestId: string,
|
||||
@Body() fields: ExpertAccidentFieldsDto,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
) {
|
||||
return this.requestManagementService.expertAddAccidentFieldsForBlameV3(
|
||||
fileReviewer,
|
||||
requestId,
|
||||
fields,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Capture requirements ─────────────────────────────────────────────────────
|
||||
|
||||
@Get("capture-requirements/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiOperation({
|
||||
summary: "Capture requirements (step-aware)",
|
||||
description:
|
||||
"Initial documents phase: pre-capture docs only (no chassis/engine/metal plate). " +
|
||||
"CAPTURE_PART_DAMAGES phase: damaged parts + angles via capture-part, then chassis/engine/metal plate via upload-document. " +
|
||||
"Use `captureSequencePhase` and `captureSequenceHint` to drive the UI.",
|
||||
})
|
||||
async getCaptureRequirements(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
): Promise<GetCaptureRequirementsV2ResponseDto> {
|
||||
return this.claimRequestManagementService.getCaptureRequirementsV3(
|
||||
claimRequestId,
|
||||
fileReviewer.sub,
|
||||
fileReviewer,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Document upload ──────────────────────────────────────────────────────────
|
||||
|
||||
@Post("upload-document/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-documents",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `${file.originalname.split(".")[0]}-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiOperation({
|
||||
summary: "Upload one required claim document",
|
||||
description:
|
||||
"Initial phase (UPLOAD_REQUIRED_DOCUMENTS): licenses and car cards only. " +
|
||||
"Capture phase (CAPTURE_PART_DAMAGES, after parts + angles): chassis, engine, metal plate only.",
|
||||
})
|
||||
async uploadDocument(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UploadRequiredDocumentV2Dto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.uploadRequiredDocumentV3(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
fileReviewer.sub,
|
||||
fileReviewer,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Part selection ───────────────────────────────────────────────────────────
|
||||
|
||||
@Patch("select-outer-parts/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: SelectOuterPartsV2Dto })
|
||||
@ApiOperation({ summary: "Select damaged outer parts" })
|
||||
async selectOuterParts(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOuterPartsV2Dto,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
): Promise<SelectOuterPartsV2ResponseDto> {
|
||||
return this.claimRequestManagementService.selectOuterPartsV3(
|
||||
claimRequestId,
|
||||
body,
|
||||
fileReviewer.sub,
|
||||
fileReviewer,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("select-other-parts/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
|
||||
@ApiOperation({
|
||||
summary: "Select other damaged parts (V5)",
|
||||
description:
|
||||
"Other parts only — sheba and national code were collected during FileMaker's run-inquiries. Optional car green card file.",
|
||||
})
|
||||
@ApiBody({
|
||||
description:
|
||||
"Other parts selection. Bank info is already on the claim from run-inquiries.",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
otherParts: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"engine",
|
||||
"suspension",
|
||||
"brake_system",
|
||||
"electrical",
|
||||
"radiator",
|
||||
"transmission",
|
||||
"exhaust",
|
||||
"headlight",
|
||||
"taillight",
|
||||
"mirror",
|
||||
"glass",
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "string", description: "JSON string array for multipart" },
|
||||
],
|
||||
example: ["engine", "suspension"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async selectOtherParts(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOtherPartsV3Dto,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||
return this.claimRequestManagementService.selectOtherPartsV3(
|
||||
claimRequestId,
|
||||
body,
|
||||
fileReviewer.sub,
|
||||
fileReviewer,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Photo capture ────────────────────────────────────────────────────────────
|
||||
|
||||
@Post("capture-part/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-captures",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `v5-capture-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiOperation({
|
||||
summary: "Car angles and damaged-part photos",
|
||||
description:
|
||||
"CAPTURE_PART_DAMAGES: (1) all damaged-part photos, (2) four car angles, " +
|
||||
"(3) chassis/engine/metal-plate via upload-document. Then car-capture walk-around video.",
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["captureType", "captureKey", "file"],
|
||||
properties: {
|
||||
captureType: {
|
||||
type: "string",
|
||||
enum: ["angle", "part"],
|
||||
example: "angle",
|
||||
},
|
||||
captureKey: {
|
||||
type: "string",
|
||||
example: "front",
|
||||
description:
|
||||
"For angle: front/back/left/right. For part: hood/front_bumper/etc.",
|
||||
},
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
async capturePart(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: CapturePartV2Dto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
): Promise<CapturePartV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.capturePartV3(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
fileReviewer.sub,
|
||||
fileReviewer,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Owner signature on expert pricing ───────────────────────────────────────
|
||||
|
||||
@Put("claim-sign/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiBody({
|
||||
description: "Signature file, agreement, and branch",
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["sign", "agree", "branchId"],
|
||||
properties: {
|
||||
sign: { type: "string", format: "binary", description: "Signature image" },
|
||||
agree: { type: "boolean", description: "true to accept, false to reject" },
|
||||
branchId: { type: "string", description: "Insurer branch ID" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Owner signature on expert pricing (V5 — FileReviewer acts on behalf of user)",
|
||||
description:
|
||||
"FileReviewer submits the damaged party's signature during the final approval stage. " +
|
||||
"Delegates to the same service method as the user sign endpoint; the FileReviewer's " +
|
||||
"identity is resolved to the claim owner via `resolveClaimEffectiveUserId`.",
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor("sign", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/claim-sign",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
const base = file.originalname.split(/[.,\s-]/)[0] || "sign";
|
||||
callback(null, `${base}-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
async submitOwnerSign(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body("agree") agree: string | boolean,
|
||||
@Body("branchId") branchId: string,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
@UploadedFile() sign: Express.Multer.File,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForClaim(sign, claimRequestId, "image");
|
||||
const agreed =
|
||||
typeof agree === "string"
|
||||
? agree === "true" || agree === "1"
|
||||
: Boolean(agree);
|
||||
try {
|
||||
return await this.claimRequestManagementService.submitOwnerInsurerApprovalSignV2(
|
||||
claimRequestId,
|
||||
agreed,
|
||||
typeof branchId === "string" ? branchId : "",
|
||||
sign,
|
||||
fileReviewer.sub,
|
||||
fileReviewer,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error ? error.message : "Failed to submit signature",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Walk-around video ────────────────────────────────────────────────────────
|
||||
|
||||
@Patch("car-capture/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId", example: "507f1f77bcf86cd799439011" })
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/car-capture-videos/",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `v5-claim-video-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["file"],
|
||||
properties: { file: { type: "string", format: "binary" } },
|
||||
},
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Walk-around video (final FileReviewer step — V5)",
|
||||
description:
|
||||
"Last FileReviewer action. Upload after capture-part is complete (parts, angles, chassis/engine docs). " +
|
||||
"Finalises the claim → WAITING_FOR_DAMAGE_EXPERT and marks the blame as COMPLETED. " +
|
||||
"After the damage expert review completes the claim moves to WAITING_FOR_FILE_MAKER_APPROVAL.",
|
||||
})
|
||||
async carCapture(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@UploadedFile("file") file: Express.Multer.File,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
|
||||
return this.claimRequestManagementService.setVideoCaptureV3(
|
||||
claimRequestId,
|
||||
file,
|
||||
fileReviewer.sub,
|
||||
fileReviewer,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Final blame video ─────────────────────────────────────────────────────────
|
||||
//
|
||||
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { file: { type: "string", format: "binary" } },
|
||||
},
|
||||
})
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
storage: diskStorage({
|
||||
destination: "./files/video",
|
||||
filename: (req, file, callback) => {
|
||||
const unique = Date.now();
|
||||
const ex = extname(file.originalname);
|
||||
callback(null, `v5-blame-accident-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@Post("upload-video/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiOperation({
|
||||
summary: "Blame accident video (no-op for V5 — car-capture is the final step)",
|
||||
description:
|
||||
"Deprecated for V5. The blame is already COMPLETED by car-capture. " +
|
||||
"Calling this endpoint returns an idempotent success response.",
|
||||
})
|
||||
async uploadBlameVideo(
|
||||
@Param("requestId") requestId: string,
|
||||
@CurrentUser() fileReviewer: any,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
if (file) {
|
||||
await this.mediaPolicyService.assertForBlame(file, requestId, "video");
|
||||
}
|
||||
return this.requestManagementService.expertUploadBlameVideoV5(
|
||||
fileReviewer,
|
||||
requestId,
|
||||
file,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { Types } from "mongoose";
|
||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||
import { SandHubService } from "src/sand-hub/sand-hub.service";
|
||||
import { PlateNormalizerService } from "src/utils/plate-normalizer/plate-normalizer.service";
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
import { BlameRequestDbService } from "./entities/db-service/blame-request.db.service";
|
||||
import { PartyRole } from "./entities/schema/partyRole.enum";
|
||||
@@ -33,6 +34,7 @@ export class InquiryRefreshService {
|
||||
private readonly blameRequestDbService: BlameRequestDbService,
|
||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||
private readonly sandHubService: SandHubService,
|
||||
private readonly plateNormalizer: PlateNormalizerService,
|
||||
) {}
|
||||
|
||||
async reinquiryInquiries(
|
||||
@@ -367,7 +369,8 @@ export class InquiryRefreshService {
|
||||
}
|
||||
|
||||
next.insurance.policyNumber =
|
||||
mapped.LastCompanyDocumentNumber ||
|
||||
mapped.PrntPlcyCmpDocNo ||
|
||||
mapped.printNumber ||
|
||||
mapped.insuranceNumber ||
|
||||
next.insurance.policyNumber;
|
||||
next.insurance.company =
|
||||
@@ -591,7 +594,7 @@ export class InquiryRefreshService {
|
||||
centerDigits !== undefined &&
|
||||
ir !== undefined
|
||||
) {
|
||||
const plateLetter = String(centerAlphabet).trim();
|
||||
const plateLetter = this.plateNormalizer.normalizePlateText(String(centerAlphabet));
|
||||
const parsed: PlateParts = {
|
||||
leftDigits: Number(leftDigits),
|
||||
centerAlphabet: plateLetter,
|
||||
@@ -621,8 +624,7 @@ export class InquiryRefreshService {
|
||||
const ir = Number(irRaw);
|
||||
const leftDigits = Number(leftRaw);
|
||||
const centerDigits = Number(centerRaw);
|
||||
const centerAlphabet = String(alphaRaw || "").trim();
|
||||
|
||||
const centerAlphabet = this.plateNormalizer.normalizePlateText(String(alphaRaw || ""));
|
||||
if (
|
||||
!Number.isFinite(ir) ||
|
||||
!Number.isFinite(leftDigits) ||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { UsersModule } from "src/users/users.module";
|
||||
import { CronModule } from "src/utils/cron/cron.module";
|
||||
import { PublicIdModule } from "src/utils/public-id/public-id.module";
|
||||
import { HashModule } from "src/utils/hash/hash.module";
|
||||
import { PlateNormalizerModule } from "src/utils/plate-normalizer/plate-normalizer.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
import { WorkflowStepManagementModule } from "src/workflow-step-management/workflow-step-management.module";
|
||||
import { AuthModule } from "src/auth/auth.module";
|
||||
@@ -43,8 +44,14 @@ import { ExpertInitiatedBlameMirrorController } from "./expert-initiated-blame.m
|
||||
import { RegistrarInitiatedController } from "./registrar-initiated.controller";
|
||||
import { RegistrarBlameMirrorController } from "./registrar-blame.mirror.controller";
|
||||
import { ExpertInitiatedBlameV3MirrorController } from "./expert-initiated-blame-v3.mirror.controller";
|
||||
import { FileMakerBlameV4Controller } from "./file-maker-blame-v4.controller";
|
||||
import { FileReviewerBlameV4Controller } from "./file-reviewer-blame-v4.controller";
|
||||
import { FileMakerBlameV5Controller } from "./file-maker-blame-v5.controller";
|
||||
import { FileReviewerBlameV5Controller } from "./file-reviewer-blame-v5.controller";
|
||||
import { FileMakerClaimApprovalV5Controller } from "./file-maker-claim-approval-v5.controller";
|
||||
import { InquiryRefreshController } from "./inquiry-refresh.controller";
|
||||
import { InquiryRefreshService } from "./inquiry-refresh.service";
|
||||
import { CallCenterBlameV6Controller } from "./call-center-blame-v6.controller";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -54,6 +61,7 @@ import { InquiryRefreshService } from "./inquiry-refresh.service";
|
||||
SmsOrchestrationModule,
|
||||
PublicIdModule,
|
||||
HashModule,
|
||||
PlateNormalizerModule,
|
||||
WorkflowStepManagementModule,
|
||||
PlatesModule,
|
||||
MulterModule.register({
|
||||
@@ -81,7 +89,13 @@ import { InquiryRefreshService } from "./inquiry-refresh.service";
|
||||
ExpertInitiatedBlameV3MirrorController,
|
||||
RegistrarInitiatedController,
|
||||
RegistrarBlameMirrorController,
|
||||
FileMakerBlameV4Controller,
|
||||
FileReviewerBlameV4Controller,
|
||||
FileMakerBlameV5Controller,
|
||||
FileReviewerBlameV5Controller,
|
||||
FileMakerClaimApprovalV5Controller,
|
||||
InquiryRefreshController,
|
||||
CallCenterBlameV6Controller,
|
||||
],
|
||||
providers: [
|
||||
RequestManagementService,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user