forked from Yara724/api
Compare commits
276 Commits
34142942e5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61684156c6 | ||
|
|
588a92c4b4 | ||
|
|
c94dd27a96 | ||
|
|
e4c3b7a16a | ||
|
|
4b9d946bfd | ||
|
|
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 | |||
| 7e1ae328ac | |||
| 094d9048ba | |||
| 9afb6a8a6e | |||
| 2df25e26bb | |||
| c7fb6174a0 | |||
| 136b22fd81 | |||
| 16cdf2e7b0 | |||
| 488c9180af | |||
| 75c556a013 | |||
| 0f53bfabf5 | |||
| 8bf3cfe5e9 | |||
|
|
523172da67 | ||
|
|
2fc6015213 | ||
| f6ec7644ff | |||
| bc70a8c930 | |||
|
|
cca3ed01a4 | ||
| 4caa958175 | |||
| 7d10c00ce5 | |||
| 114e5e6604 | |||
| f00cb226a7 | |||
|
|
e2b879d943 | ||
| d84bd24682 | |||
|
|
ed2b6948cf | ||
|
|
c6b417ced7 | ||
| 16d3c54613 | |||
|
|
db569db9d1 | ||
| 83e82ea68e | |||
|
|
8f29bb564c | ||
| 89e715b0c9 | |||
| 44f7ce5b54 | |||
| a2396da9e4 | |||
| 64f6245e06 | |||
| df79a4d307 | |||
| 65c30a6cba | |||
| 0dd6c8ff78 | |||
| 0442d04f20 | |||
|
|
d0e7694374 | ||
| 570fa865de | |||
|
|
8741d2ba82 | ||
| 7b53c98791 | |||
|
|
59eddb8e0e | ||
| 4e20fc5c96 | |||
|
|
4fabed77e5 | ||
|
|
2e4b10455b | ||
| 1bbf0de960 | |||
|
|
15fcb011aa | ||
| 2c52c14e03 | |||
|
|
5a89a0ff16 | ||
| d355771518 | |||
| f4301428c7 | |||
| e3406f7645 | |||
| 4d6183fa24 | |||
| ce4945ebb8 | |||
| 5cada3c6c8 | |||
| 761f0cb679 | |||
| 3c61e4397a | |||
| fae7e9b13b | |||
| 3c863bb90c | |||
| 0c5756d325 | |||
| 1670c9d145 | |||
| 92e05d2a49 | |||
| dcc3ee71de | |||
| fa188862e5 | |||
| d8f7766f10 | |||
| 084d0e1360 | |||
| 0111f3acd2 | |||
| 1ef17ce337 | |||
|
|
6df8044c5a | ||
|
|
bc5be99b59 | ||
|
|
a4eb98258b | ||
|
|
ad35d35065 | ||
|
|
4bd88ff0dd | ||
|
|
b008eda11b | ||
| 1680fdc5b4 | |||
|
|
921f9719c7 | ||
|
|
a7849f915a | ||
|
|
19dc2a76f2 | ||
|
|
41f81a2f76 | ||
| 048398d653 | |||
|
|
79905345e5 | ||
|
|
0ed7cd7012 | ||
| 1e7b0d7d06 | |||
|
|
6426233350 | ||
| 3e5e9852ad | |||
|
|
3d6b9e130c | ||
|
|
ec07d42ced | ||
| 407f58f5ee | |||
|
|
c8274d8435 | ||
| f0b24dcd26 | |||
|
|
5414d9717e | ||
|
|
e413991e7c | ||
| b144458943 | |||
|
|
3abbd45fac | ||
| cb47069e90 | |||
| 3c3b5191fb | |||
| 8053e1a088 | |||
|
|
d276c32e87 | ||
| 951ff9b50b | |||
|
|
a59e4c57a5 | ||
| 33c9811f61 | |||
|
|
cab584410f | ||
| c413bd3417 | |||
|
|
249c359898 | ||
| 393d43c4d2 |
28
.env.example
28
.env.example
@@ -3,10 +3,17 @@
|
||||
# ---------------------------------------------
|
||||
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
|
||||
# ---------------------------------------------
|
||||
URL =
|
||||
USER_BASE_PATH =
|
||||
BASE_URL_DEV =
|
||||
|
||||
# ---------------------------------------------
|
||||
@@ -26,11 +33,14 @@ MONGO_DB_NAME =
|
||||
MONGO_OPTIONS =
|
||||
MONGO_TLS =
|
||||
MONGO_TLS_ALLOW_INVALID_CERTS =
|
||||
MONGO_URI = 'mongodb://${MONGO_USER}:${MONGO_PASS}@${MONGO_HOST}:${MONGO_PORT}/${MONGO_DB_NAME}?${MONGO_OPTIONS}'
|
||||
|
||||
# ---------------------------------------------
|
||||
# 🔐 Authentication / Security
|
||||
# ---------------------------------------------
|
||||
JWT_SECRET =
|
||||
JWT_EXPIRY =
|
||||
CAPTCHA_ENABLED = true
|
||||
|
||||
# ---------------------------------------------
|
||||
# 🧩 SanHub Microservice
|
||||
@@ -55,6 +65,24 @@ SMS_PROVIDER =
|
||||
SMS_API_KEY =
|
||||
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
|
||||
14
README.md
14
README.md
@@ -1 +1,15 @@
|
||||
# api
|
||||
|
||||
Current JWT payload:
|
||||
```json
|
||||
{
|
||||
"username": "saman_insurer@gmail.com",
|
||||
"sub": "6a144979799f3c63aa63f67c",
|
||||
"fullName": "بیمه گر سامان",
|
||||
"role": "company",
|
||||
"userType": "UserType",
|
||||
"clientKey": "67f0fd0e53868dc1ff8a2738",
|
||||
"iat": 1781339791,
|
||||
"exp": 1781343391
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
325
package-lock.json
generated
325
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"
|
||||
@@ -179,24 +181,6 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -204,22 +188,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics-cli/node_modules/rxjs": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||
@@ -275,24 +243,6 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -300,22 +250,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics/node_modules/rxjs": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||
@@ -1075,9 +1009,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/tlds": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz",
|
||||
"integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==",
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz",
|
||||
"integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -2905,24 +2839,6 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics/node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -2930,22 +2846,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nestjs/schematics/node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics/node_modules/rxjs": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||
@@ -3044,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"
|
||||
@@ -3492,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",
|
||||
@@ -5219,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",
|
||||
@@ -5475,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",
|
||||
@@ -5794,24 +5731,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/chrome-trace-event": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
|
||||
@@ -6331,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",
|
||||
@@ -6935,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": {
|
||||
@@ -7195,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",
|
||||
@@ -7254,16 +7204,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -7711,9 +7661,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -8810,9 +8760,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "18.2.1",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz",
|
||||
"integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==",
|
||||
"version": "18.2.2",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.2.tgz",
|
||||
"integrity": "sha512-STrV933NPLPME2nfPo+lFIfgCff9T/vhObpoRtFb/vNlLQynrUJPBCS3OePJ4Lunu/Egw9lqtNms72xbPbxrxw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/address": "^5.1.1",
|
||||
@@ -8827,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",
|
||||
@@ -9032,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",
|
||||
@@ -9914,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",
|
||||
@@ -10052,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",
|
||||
@@ -10178,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",
|
||||
@@ -10396,22 +10399,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
@@ -10547,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",
|
||||
@@ -11968,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",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"seed:parsian-tehran": "ts-node scripts/seed-parsian-tehran.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -38,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": {
|
||||
@@ -83,6 +87,9 @@
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
|
||||
185
scripts/data/parsian-tehran/branches.json
Normal file
185
scripts/data/parsian-tehran/branches.json
Normal file
@@ -0,0 +1,185 @@
|
||||
{
|
||||
"clientCode": 8,
|
||||
"branches": [
|
||||
{
|
||||
"code": "100100",
|
||||
"name": "واحدصدورالکترونيکي",
|
||||
"fullName": "واحدصدورالکترونيکي(100100)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "خيابان وليعصر_بلوار ميرداماد_پلاک 22",
|
||||
"phoneNumber": "8259",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "110011",
|
||||
"name": "ستاد مرکزي",
|
||||
"fullName": "ستاد مرکزي(110011)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
|
||||
"phoneNumber": "8259",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "111130",
|
||||
"name": "شعبه ويژه ميرداماد",
|
||||
"fullName": "شعبه ويژه ميرداماد(111130)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران، خيابان وليعصر، بالاتراز ميرداماد، خيابان قباديان غربي، پلاك22",
|
||||
"phoneNumber": "8259",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "120021",
|
||||
"name": "سرپرستي منطقه يک كشور",
|
||||
"fullName": "سرپرستي منطقه يک كشور(120021)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران،خيابان وليعصر ،خيابان قباديان غربي ،پلاک 22 ، طبقه همکف",
|
||||
"phoneNumber": "0218259",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "130031",
|
||||
"name": "سرپرستي منطقه مركزي كشور",
|
||||
"fullName": "سرپرستي منطقه مركزي كشور(130031)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "اصفهان، خيابان امام خميني (ره) - بعد از چهارراه شريف - کوچه شهيد احمدي (85)",
|
||||
"phoneNumber": "03133328257",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "140041",
|
||||
"name": "سرپرستي منطقه شمالغرب کشور",
|
||||
"fullName": "سرپرستي منطقه شمالغرب کشور(140041)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تبريز- خيابان ائل گلي - فلکه خيام - نبش فلکه رجائي - بيمه پارسيان",
|
||||
"phoneNumber": "04133832289",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "150051",
|
||||
"name": "سرپرستي منطقه جنوب كشور",
|
||||
"fullName": "سرپرستي منطقه جنوب كشور(150051)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "شيراز ـ فلکه فرودگاه (ميدان بسيج) ـ ابتداي بلوار سياحتگر",
|
||||
"phoneNumber": "01738315473",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "150053",
|
||||
"name": "سرپرست منطقه جنوب شرقي کشور",
|
||||
"fullName": "سرپرست منطقه جنوب شرقي کشور(150053)",
|
||||
"city": "کرمان",
|
||||
"state": "کرمان",
|
||||
"address": "کرمان، حافظ، بعد از چهارراه جامي، پلاک 153",
|
||||
"phoneNumber": "03432718000",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "160061",
|
||||
"name": "سرپرستي منطقه شرق كشور",
|
||||
"fullName": "سرپرستي منطقه شرق كشور(160061)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "مشهد ـ خيام شمالي ـ نبش خيام شمالي 36",
|
||||
"phoneNumber": "05137659005",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "170071",
|
||||
"name": "سرپرستي منطقه غرب كشور",
|
||||
"fullName": "سرپرستي منطقه غرب كشور(170071)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "کرمانشاه . ميدان مرکزي خيابان خرم نبش کوي بسيج ساختمان عرفان",
|
||||
"phoneNumber": "08338431017",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "180081",
|
||||
"name": "سرپرستي منطقه شمال شرق کشور",
|
||||
"fullName": "سرپرستي منطقه شمال شرق کشور(180081)",
|
||||
"city": "ساري",
|
||||
"state": "مازندران",
|
||||
"address": "ساري، شعبه ساري",
|
||||
"phoneNumber": "01133207241",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "180083",
|
||||
"name": "سرپرست منطقه شمال کشوري",
|
||||
"fullName": "سرپرست منطقه شمال کشوري(180083)",
|
||||
"city": "رشت",
|
||||
"state": "گيلان",
|
||||
"address": "رشت، بلوار آيت اله رودباري،کدپستي:4144761893",
|
||||
"phoneNumber": "01333512135",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "190092",
|
||||
"name": "سرپرستي جنوب غربي کشور",
|
||||
"fullName": "سرپرستي جنوب غربي کشور(190092)",
|
||||
"city": "اهواز",
|
||||
"state": "خوزستان",
|
||||
"address": "اهواز،تقاطع بلوار ساحلي گلستان (خيابان فروردين)،نبش خيابان نصرت شمالي ،پلاک 701 کد پستي 6155977139",
|
||||
"phoneNumber": "06133743877",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "210040",
|
||||
"name": "شعبه شرق تهران",
|
||||
"fullName": "شعبه شرق تهران(210040)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران، خيابان دماوند، بعداز چهارراه تهرانپارس، روبروي تعميرگاه مرکزي شماره يک سايپا، پلاک129",
|
||||
"phoneNumber": "77393783-4",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "210050",
|
||||
"name": "شعبه غرب تهران",
|
||||
"fullName": "شعبه غرب تهران(210050)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران ـخيابان آزادي ( محله تيموري )، نبش خيابان شهيد داود حبيب زادگان پلاک 2 - 1458887853",
|
||||
"phoneNumber": "66021968",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "210110",
|
||||
"name": "شعبه پونک",
|
||||
"fullName": "شعبه پونک(210110)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران، خيابان ميرزا بابايي، نبش خيابان سردارجنگل، پارك سوارپونك",
|
||||
"phoneNumber": "44452270",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "210120",
|
||||
"name": "شعبه والفجر",
|
||||
"fullName": "شعبه والفجر(210120)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران، اميرآبادشمالي، شهرک والفجر، ضلع جنوب غربي ميدان استادخسرو سينايي",
|
||||
"phoneNumber": "86051332",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"code": "210150",
|
||||
"name": "شعبه شمال شرق تهران",
|
||||
"fullName": "شعبه شمال شرق تهران(210150)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"address": "تهران، ضلع شمال غربي ميدان بني هاشم، نبش خيابان كشوري، پلاك13",
|
||||
"phoneNumber": "26244319",
|
||||
"isActive": true
|
||||
}
|
||||
]
|
||||
}
|
||||
153
scripts/data/parsian-tehran/field-experts.json
Normal file
153
scripts/data/parsian-tehran/field-experts.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"clientCode": 8,
|
||||
"fieldExperts": [
|
||||
{
|
||||
"nationalCode": "0013480261",
|
||||
"firstName": "عليرضا",
|
||||
"lastName": "خازني",
|
||||
"branchCode": "210040",
|
||||
"branchName": "شعبه شرق تهران(210040)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت بدنه"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0051967839",
|
||||
"mobile": "09121354859",
|
||||
"firstName": "حسين",
|
||||
"lastName": "جعفري",
|
||||
"branchCode": "210120",
|
||||
"branchName": "شعبه والفجر",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت بدنه"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0056888082",
|
||||
"mobile": "09122406750",
|
||||
"firstName": "قاسم",
|
||||
"lastName": "نصراللهي",
|
||||
"branchCode": "210050",
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||
"expertCode": "4664"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0066868521",
|
||||
"mobile": "09129344240",
|
||||
"firstName": "عليرضا",
|
||||
"lastName": "گودرزي پور",
|
||||
"branchCode": "210050",
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"title": "كارشناس ارزياب خسارت بدنه",
|
||||
"expertCode": "4663"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0076988961",
|
||||
"mobile": "09108357378",
|
||||
"firstName": "مهدي",
|
||||
"lastName": "روشن دل",
|
||||
"branchCode": "210110",
|
||||
"branchName": "شعبه پونک",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت بدنه"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0078209129",
|
||||
"mobile": "09126038117",
|
||||
"firstName": "مهدي",
|
||||
"lastName": "شاملوفرد",
|
||||
"branchCode": "210050",
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||
"expertCode": "4666"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0083730397",
|
||||
"mobile": "09125759960",
|
||||
"firstName": "مجيد",
|
||||
"lastName": "کاظمي دولت سرا",
|
||||
"branchCode": "210120",
|
||||
"branchName": "شعبه والفجر",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت بدنه"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0084130938",
|
||||
"mobile": "09392558640",
|
||||
"firstName": "رسول",
|
||||
"lastName": "کرکي",
|
||||
"branchCode": "210050",
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||
"expertCode": "4662"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0440245151",
|
||||
"mobile": "09130606183",
|
||||
"firstName": "فرهاد",
|
||||
"lastName": "ملکي مونقي",
|
||||
"branchCode": "110011",
|
||||
"branchName": "ستاد مرکزي",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0493217789",
|
||||
"mobile": "09126966943",
|
||||
"firstName": "مصطفي",
|
||||
"lastName": "محمدزاده قورقچي",
|
||||
"branchCode": "210050",
|
||||
"branchName": "شعبه غرب تهران(210050)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي",
|
||||
"expertCode": "4665"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0670358118",
|
||||
"mobile": "09124421539",
|
||||
"firstName": "مجيد",
|
||||
"lastName": "اميري",
|
||||
"branchCode": "210040",
|
||||
"branchName": "شعبه شرق تهران(210040)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||
},
|
||||
{
|
||||
"nationalCode": "0759153981",
|
||||
"firstName": "رضا",
|
||||
"lastName": "صالحي زاده",
|
||||
"branchCode": "210040",
|
||||
"branchName": "شعبه شرق تهران(210040)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت بدنه"
|
||||
},
|
||||
{
|
||||
"nationalCode": "1262982308",
|
||||
"mobile": "09130121246",
|
||||
"firstName": "روح الله",
|
||||
"lastName": "سلمانيان مقدم نياسري",
|
||||
"branchCode": "210120",
|
||||
"branchName": "شعبه والفجر",
|
||||
"city": "کاشان",
|
||||
"state": "اصفهان",
|
||||
"title": "كارشناس ارزياب خسارت بدنه"
|
||||
},
|
||||
{
|
||||
"nationalCode": "3781847039",
|
||||
"firstName": "اکبر",
|
||||
"lastName": "ديني",
|
||||
"branchCode": "210040",
|
||||
"branchName": "شعبه شرق تهران(210040)",
|
||||
"city": "تهران",
|
||||
"state": "تهران",
|
||||
"title": "كارشناس ارزياب خسارت ثالث مالي"
|
||||
}
|
||||
]
|
||||
}
|
||||
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"
|
||||
323
scripts/seed-parsian-tehran.ts
Normal file
323
scripts/seed-parsian-tehran.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* One-time seed for Parsian (clientCode=8) Tehran branches + field experts.
|
||||
*
|
||||
* Usage (before starting the app):
|
||||
* npm run seed:parsian-tehran
|
||||
*
|
||||
* Optional env:
|
||||
* SEED_FIELD_EXPERT_DEFAULT_PASSWORD=Parsian@724
|
||||
*/
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import * as crypto from "node:crypto";
|
||||
import mongoose, { Schema, Types } from "mongoose";
|
||||
|
||||
type BranchSeed = {
|
||||
code: string;
|
||||
name: string;
|
||||
fullName?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
address: string;
|
||||
phoneNumber?: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
type FieldExpertSeed = {
|
||||
nationalCode: string;
|
||||
mobile?: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
branchCode: string;
|
||||
branchName?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
title?: string;
|
||||
expertCode?: string;
|
||||
};
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|
||||
) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function stripInlineComment(value: string): string {
|
||||
const hashIdx = value.indexOf(" #");
|
||||
return hashIdx === -1 ? value : value.slice(0, hashIdx).trim();
|
||||
}
|
||||
|
||||
function expandEnvValue(value: string, env: NodeJS.ProcessEnv): string {
|
||||
return value.replace(/\$\{([^}]+)\}/g, (_, key: string) => env[key] ?? "");
|
||||
}
|
||||
|
||||
function loadEnvFile() {
|
||||
const envPath = join(process.cwd(), ".env");
|
||||
if (!existsSync(envPath)) return;
|
||||
|
||||
const raw: Record<string, string> = {};
|
||||
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = stripInlineComment(trimmed.slice(idx + 1).trim());
|
||||
raw[key] = value;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (process.env[key]) continue;
|
||||
process.env[key] = stripQuotes(value);
|
||||
}
|
||||
|
||||
// Expand ${VAR} placeholders (same as Nest ConfigModule expandVariables).
|
||||
for (let pass = 0; pass < 5; pass++) {
|
||||
let changed = false;
|
||||
for (const key of Object.keys(process.env)) {
|
||||
const current = process.env[key];
|
||||
if (!current || !current.includes("${")) continue;
|
||||
const expanded = expandEnvValue(stripQuotes(current), process.env);
|
||||
if (expanded !== current) {
|
||||
process.env[key] = expanded;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(process.env)) {
|
||||
const value = process.env[key];
|
||||
if (value) process.env[key] = stripQuotes(value);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMongoUri(): string {
|
||||
const uri = process.env.MONGO_URI?.trim();
|
||||
if (!uri) {
|
||||
throw new Error("MONGO_URI is not set in .env");
|
||||
}
|
||||
if (!uri.startsWith("mongodb://") && !uri.startsWith("mongodb+srv://")) {
|
||||
throw new Error(
|
||||
`Invalid MONGO_URI after env expansion: "${uri.slice(0, 40)}..."`,
|
||||
);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
async function ensureFieldExpertIndexes(collection: mongoose.Collection) {
|
||||
const indexes = await collection.indexes();
|
||||
const emailIndex = indexes.find((idx) => idx.key?.email === 1);
|
||||
if (emailIndex && !emailIndex.sparse) {
|
||||
await collection.dropIndex(emailIndex.name);
|
||||
console.log(`Dropped legacy non-sparse index: ${emailIndex.name}`);
|
||||
}
|
||||
await collection.createIndex({ email: 1 }, { unique: true, sparse: true });
|
||||
await collection.createIndex(
|
||||
{ clientKey: 1, nationalCode: 1 },
|
||||
{ unique: true, sparse: true },
|
||||
);
|
||||
}
|
||||
|
||||
function hashPassword(password: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
resolve(`${salt}:${derivedKey.toString("hex")}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const ClientSchema = new Schema(
|
||||
{
|
||||
clientName: { type: Object, required: true },
|
||||
clientCode: { type: Number, required: true },
|
||||
useExpertMode: { type: String, required: true },
|
||||
},
|
||||
{ collection: "clients", versionKey: false },
|
||||
);
|
||||
|
||||
const BranchSchema = new Schema(
|
||||
{
|
||||
clientKey: { type: Schema.Types.ObjectId, required: true, index: true },
|
||||
name: { type: String, required: true },
|
||||
code: { type: String, required: true },
|
||||
city: { type: String, required: true },
|
||||
state: { type: String, required: true },
|
||||
address: { type: String, required: true },
|
||||
phoneNumber: { type: String },
|
||||
isActive: { type: Boolean, default: true },
|
||||
},
|
||||
{ collection: "branches", versionKey: false, timestamps: true },
|
||||
);
|
||||
|
||||
BranchSchema.index({ clientKey: 1, code: 1 }, { unique: true });
|
||||
|
||||
const FieldExpertSchema = new Schema(
|
||||
{
|
||||
firstName: { type: String, required: true },
|
||||
lastName: { type: String, required: true },
|
||||
email: { type: String, unique: true, sparse: true },
|
||||
username: { type: String },
|
||||
nationalCode: { type: String, index: true, sparse: true },
|
||||
clientKey: { type: Schema.Types.ObjectId, index: true },
|
||||
branchId: { type: Schema.Types.ObjectId, index: true },
|
||||
password: { type: String, required: true },
|
||||
mobile: { type: String },
|
||||
phone: { type: String },
|
||||
role: { type: String, default: "field_expert" },
|
||||
otp: { type: String, default: "" },
|
||||
expertCode: { type: String, required: false },
|
||||
},
|
||||
{ collection: "field-expert", versionKey: false, timestamps: true },
|
||||
);
|
||||
|
||||
FieldExpertSchema.index(
|
||||
{ clientKey: 1, nationalCode: 1 },
|
||||
{ unique: true, sparse: true },
|
||||
);
|
||||
|
||||
async function main() {
|
||||
loadEnvFile();
|
||||
const mongoUri = resolveMongoUri();
|
||||
|
||||
const dataDir = join(process.cwd(), "scripts/data/parsian-tehran");
|
||||
const branchesFile = JSON.parse(
|
||||
readFileSync(join(dataDir, "branches.json"), "utf8"),
|
||||
) as { clientCode: number; branches: BranchSeed[] };
|
||||
const expertsFile = JSON.parse(
|
||||
readFileSync(join(dataDir, "field-experts.json"), "utf8"),
|
||||
) as { clientCode: number; fieldExperts: FieldExpertSeed[] };
|
||||
|
||||
const defaultPassword =
|
||||
process.env.SEED_FIELD_EXPERT_DEFAULT_PASSWORD ?? "123321";
|
||||
const hashedPassword = await hashPassword(defaultPassword);
|
||||
|
||||
await mongoose.connect(mongoUri, {
|
||||
tls: process.env.MONGO_TLS === "true",
|
||||
tlsAllowInvalidCertificates:
|
||||
process.env.MONGO_TLS_ALLOW_INVALID_CERTS === "true",
|
||||
});
|
||||
const Client = mongoose.model("ClientSeedClient", ClientSchema);
|
||||
const Branch = mongoose.model("ClientSeedBranch", BranchSchema);
|
||||
const FieldExpert = mongoose.model("ClientSeedFieldExpert", FieldExpertSchema);
|
||||
|
||||
await ensureFieldExpertIndexes(FieldExpert.collection);
|
||||
|
||||
const client = await Client.findOne({
|
||||
clientCode: branchesFile.clientCode,
|
||||
}).lean();
|
||||
if (!client?._id) {
|
||||
throw new Error(
|
||||
`Client with clientCode=${branchesFile.clientCode} not found in database`,
|
||||
);
|
||||
}
|
||||
const clientKey = new Types.ObjectId(String(client._id));
|
||||
|
||||
const branchIdByCode = new Map<string, Types.ObjectId>();
|
||||
let branchesCreated = 0;
|
||||
let branchesUpdated = 0;
|
||||
|
||||
for (const branch of branchesFile.branches) {
|
||||
const existing = await Branch.findOne({
|
||||
clientKey,
|
||||
code: branch.code,
|
||||
});
|
||||
const payload = {
|
||||
clientKey,
|
||||
name: branch.name,
|
||||
code: branch.code,
|
||||
city: branch.city,
|
||||
state: branch.state,
|
||||
address: branch.address,
|
||||
phoneNumber: branch.phoneNumber,
|
||||
isActive: branch.isActive ?? true,
|
||||
};
|
||||
if (existing) {
|
||||
await Branch.updateOne({ _id: existing._id }, { $set: payload });
|
||||
branchIdByCode.set(branch.code, existing._id as Types.ObjectId);
|
||||
branchesUpdated++;
|
||||
} else {
|
||||
const created = await Branch.create(payload);
|
||||
branchIdByCode.set(branch.code, created._id as Types.ObjectId);
|
||||
branchesCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
let expertsCreated = 0;
|
||||
let expertsUpdated = 0;
|
||||
let expertsSkipped = 0;
|
||||
|
||||
for (const expert of expertsFile.fieldExperts) {
|
||||
const branchId = branchIdByCode.get(expert.branchCode);
|
||||
if (!branchId) {
|
||||
console.warn(
|
||||
`Skipping ${expert.nationalCode}: unknown branch ${expert.branchCode}`,
|
||||
);
|
||||
expertsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
firstName: expert.firstName,
|
||||
lastName: expert.lastName,
|
||||
username: expert.nationalCode,
|
||||
nationalCode: expert.nationalCode,
|
||||
clientKey,
|
||||
branchId,
|
||||
password: hashedPassword,
|
||||
mobile: expert.mobile,
|
||||
role: "field_expert",
|
||||
otp: "",
|
||||
expertCode: expert.expertCode,
|
||||
};
|
||||
|
||||
const existing = await FieldExpert.findOne({
|
||||
clientKey,
|
||||
nationalCode: expert.nationalCode,
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await FieldExpert.updateOne(
|
||||
{ _id: existing._id },
|
||||
{
|
||||
$set: {
|
||||
...payload,
|
||||
// Do not rotate password on re-seed unless explicitly desired.
|
||||
password: existing.password,
|
||||
},
|
||||
},
|
||||
);
|
||||
expertsUpdated++;
|
||||
} else {
|
||||
await FieldExpert.create(payload);
|
||||
expertsCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Parsian Tehran seed completed.");
|
||||
console.log({
|
||||
clientCode: branchesFile.clientCode,
|
||||
clientKey: String(clientKey),
|
||||
branchesCreated,
|
||||
branchesUpdated,
|
||||
expertsCreated,
|
||||
expertsUpdated,
|
||||
expertsSkipped,
|
||||
defaultPassword,
|
||||
loginHint: "Use nationalCode + password on POST /actor/login with role field_expert",
|
||||
});
|
||||
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(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",
|
||||
|
||||
@@ -34,7 +34,27 @@ 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, ValidationPipe } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
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";
|
||||
@@ -10,8 +11,10 @@ import { AuthModule } from "./auth/auth.module";
|
||||
import { ClaimRequestManagementModule } from "./claim-request-management/claim-request-management.module";
|
||||
import { ClientModule } from "./client/client.module";
|
||||
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";
|
||||
@@ -23,79 +26,29 @@ import { UsersModule } from "./users/users.module";
|
||||
import { applyIranFaTimestampPlugin } from "./helpers/mongoose-fa-timestamps.plugin";
|
||||
import { CronModule } from "./utils/cron/cron.module";
|
||||
import { WorkflowStepManagementModule } from "./workflow-step-management/workflow-step-management.module";
|
||||
import * as Joi from "joi";
|
||||
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: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validationSchema: Joi.object({
|
||||
NODE_ENV: Joi.string()
|
||||
.valid("development", "production")
|
||||
.default("development"),
|
||||
PORT: Joi.number().port().default(9001),
|
||||
URL: Joi.string().uri().required(),
|
||||
BASE_URL_DEV: Joi.string()
|
||||
.uri()
|
||||
.when("NODE_ENV", {
|
||||
is: Joi.string().valid("development"),
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
SWAGGER_USER_DEV: Joi.string().when("NODE_ENV", {
|
||||
is: Joi.string().valid("development"),
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
SWAGGER_PASSWORD_DEV: Joi.string().when("NODE_ENV", {
|
||||
is: Joi.string().valid("development"),
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
MONGO_HOST: Joi.string().required(),
|
||||
MONGO_PORT: Joi.number().port().required(),
|
||||
MONGO_USER: Joi.string().required(),
|
||||
MONGO_PASS: Joi.string().required(),
|
||||
MONGO_DB_NAME: Joi.string().required(),
|
||||
JWT_SECRET: Joi.string().required(),
|
||||
SANHUB_BASE_URL: Joi.string().uri(),
|
||||
SANHUB_URL_LOGIN: Joi.string().uri(),
|
||||
SANHUB_USERNAME: Joi.string(),
|
||||
SANHUB_PASSWORD: Joi.string(),
|
||||
AI_URL: Joi.string().uri(),
|
||||
AI_URL_V2: Joi.string().uri(),
|
||||
AI_USERNAME: Joi.string(),
|
||||
AI_PASSWORD: Joi.string(),
|
||||
SMS_PROVIDER: Joi.string()
|
||||
.valid("kavenegar", "parsian")
|
||||
.default("kavenegar"),
|
||||
SMS_API_KEY: Joi.string(),
|
||||
AUTH_SMS_TEMPLATE: Joi.string(),
|
||||
EXP_OTP_TIME: Joi.number(),
|
||||
}),
|
||||
HttpModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: createHttpModuleOptions,
|
||||
}),
|
||||
AppConfigModule,
|
||||
DatabaseModule,
|
||||
CronModule,
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, "..", "files"),
|
||||
// process.cwd() is always the project/container root (/app in Docker),
|
||||
// so the volume-mounted /app/files is resolved correctly regardless of
|
||||
// where the compiled dist files live (__dirname would resolve to
|
||||
// /app/dist/src because TypeScript preserves the src/ prefix in outDir).
|
||||
rootPath: join(process.cwd(), "files"),
|
||||
serveRoot: "/files",
|
||||
}),
|
||||
MongooseModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => {
|
||||
return {
|
||||
uri: `mongodb://${configService.get<string>("MONGO_USER")}:${configService.get<string>("MONGO_PASS")}@${configService.get<string>("MONGO_HOST")}:${configService.get<string>("MONGO_PORT")}/${configService.get<string>("MONGO_DB_NAME")}?authSource=admin&${configService.get<string>("MONGO_OPTIONS")}`,
|
||||
tls: configService.get<string>("MONGO_TLS") === "true",
|
||||
tlsAllowInvalidCertificates:
|
||||
configService.get<string>("MONGO_TLS_ALLOW_INVALID_CERTS") ===
|
||||
"true",
|
||||
autoIndex: configService.get<string>("NODE_ENV") !== "production",
|
||||
connectionFactory: (connection) => {
|
||||
applyIranFaTimestampPlugin(connection);
|
||||
return connection;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
ClientModule,
|
||||
@@ -106,12 +59,15 @@ import * as Joi from "joi";
|
||||
SystemSettingsModule,
|
||||
ExpertBlameModule,
|
||||
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) {
|
||||
@@ -150,7 +156,7 @@ export class ActorAuthController {
|
||||
@ApiOperation({
|
||||
summary: "Actor login (returns access + refresh tokens)",
|
||||
description:
|
||||
"Authenticate any non-end-user actor (insurer/company, blame expert, damage expert, registrar, field expert, admin). Submit `role` as an array — e.g. `[\"damage_expert\"]` — together with the actor's email/`username` and password. On success the response contains the JWT pair and the resolved profile.",
|
||||
'Authenticate any non-end-user actor (insurer/company, blame expert, damage expert, registrar, field expert, admin). Submit `role` as an array — e.g. `["damage_expert"]` — together with password and one of `username` / `email` / `nationalCode`. On success the response contains the JWT pair and the resolved profile.',
|
||||
})
|
||||
@ApiBody({
|
||||
type: LoginActorDto,
|
||||
@@ -159,7 +165,8 @@ export class ActorAuthController {
|
||||
examples: {
|
||||
company: {
|
||||
summary: "Insurer / company portal",
|
||||
description: "Sample tenant credentials for the insurer (company) panel.",
|
||||
description:
|
||||
"Sample tenant credentials for the insurer (company) panel.",
|
||||
value: {
|
||||
role: "company",
|
||||
username: "saman_insurer@gmail.com",
|
||||
@@ -190,6 +197,18 @@ export class ActorAuthController {
|
||||
captcha: "a7bx2",
|
||||
},
|
||||
},
|
||||
field_expert: {
|
||||
summary: "Field expert panel",
|
||||
description:
|
||||
"Login with email+password or nationalCode+password for seeded Parsian field experts.",
|
||||
value: {
|
||||
role: "field_expert",
|
||||
nationalCode: "0051967839",
|
||||
password: "Parsian@724",
|
||||
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
captcha: "a7bx2",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
|
||||
@@ -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
|
||||
@@ -63,7 +71,7 @@ export class ActorAuthService {
|
||||
res = await this.expertDbService.findOne({
|
||||
_id: new Types.ObjectId(userId),
|
||||
});
|
||||
else res = await this.expertDbService.findOne({ email: username });
|
||||
else res = await this.findActorByLoginIdentifier(this.expertDbService, username);
|
||||
break;
|
||||
case RoleEnum.DAMAGE_EXPERT:
|
||||
if (username == null && userId)
|
||||
@@ -71,14 +79,18 @@ export class ActorAuthService {
|
||||
_id: new Types.ObjectId(userId),
|
||||
});
|
||||
else
|
||||
res = await this.damageExpertDbService.findOne({ email: username });
|
||||
res = await this.findActorByLoginIdentifier(
|
||||
this.damageExpertDbService,
|
||||
username,
|
||||
);
|
||||
break;
|
||||
case RoleEnum.FIELD_EXPERT:
|
||||
if (username == null && userId)
|
||||
res = await this.fieldExpertDbService.findOne({
|
||||
_id: new Types.ObjectId(userId),
|
||||
});
|
||||
else res = await this.fieldExpertDbService.findOne({ email: username });
|
||||
else
|
||||
res = await this.fieldExpertDbService.findByLoginIdentifier(username);
|
||||
break;
|
||||
case RoleEnum.REGISTRAR:
|
||||
if (username == null && userId)
|
||||
@@ -90,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;
|
||||
}
|
||||
@@ -111,13 +154,27 @@ export class ActorAuthService {
|
||||
}
|
||||
|
||||
parseActorLoginUsername(body: Record<string, unknown>): string {
|
||||
const username = body?.username ?? body?.email;
|
||||
const username = body?.username ?? body?.email ?? body?.nationalCode;
|
||||
if (typeof username !== "string" || !username.trim()) {
|
||||
throw new BadRequestException("username (email) is required");
|
||||
throw new BadRequestException(
|
||||
"username, email, or nationalCode is required",
|
||||
);
|
||||
}
|
||||
return username.trim();
|
||||
}
|
||||
|
||||
private async findActorByLoginIdentifier(
|
||||
dbService: { findOne: (filter: any) => Promise<any> },
|
||||
identifier: string,
|
||||
) {
|
||||
const id = identifier.trim();
|
||||
const or: Record<string, string>[] = [{ email: id }, { username: id }];
|
||||
if (/^\d{10}$/.test(id)) {
|
||||
or.push({ nationalCode: id });
|
||||
}
|
||||
return dbService.findOne({ $or: or });
|
||||
}
|
||||
|
||||
issueActorTokens(actor: {
|
||||
_id: Types.ObjectId;
|
||||
username?: string;
|
||||
@@ -129,12 +186,13 @@ export class ActorAuthService {
|
||||
clientKey?: Types.ObjectId | string | null;
|
||||
}) {
|
||||
const payload = {
|
||||
username: actor.username || actor.email,
|
||||
username:
|
||||
actor.username || actor.email || (actor as any).nationalCode || null,
|
||||
sub: actor._id,
|
||||
fullName: `${actor.firstName || ""} ${actor.lastName || ""}`.trim(),
|
||||
role: actor.role || "User",
|
||||
userType: actor.userType || "UserType",
|
||||
clientKey: actor.clientKey ?? null,
|
||||
clientKey: actor.clientKey ? String(actor.clientKey) : null,
|
||||
};
|
||||
|
||||
const access_token = this.jwtService.sign(payload, {
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
} from "src/helpers/iran-mobile";
|
||||
import {
|
||||
computeOtpExpireMs,
|
||||
FAKE_OTP_CODE,
|
||||
isFakeOtpEnabled,
|
||||
isOtpExpiryActive,
|
||||
readOtpExpireMinutesFromEnv,
|
||||
} from "src/helpers/user-otp-expiry";
|
||||
@@ -109,7 +111,7 @@ export class UserAuthService {
|
||||
const userExist = await this.userDbService.findOne(
|
||||
buildUserLookupByPhone(canonicalMobile),
|
||||
);
|
||||
const otp = this.otpCreator.create();
|
||||
const otp = this.createOtpForRequest();
|
||||
const hashOtp = await this.hashService.hash(otp);
|
||||
const expireMinutes = readOtpExpireMinutesFromEnv();
|
||||
const nowMs = Date.now();
|
||||
@@ -117,6 +119,7 @@ export class UserAuthService {
|
||||
|
||||
if (!userExist) {
|
||||
await this.smsSender(otp, canonicalMobile);
|
||||
// console.log(`OTP for ${canonicalMobile}: ${otp}`);
|
||||
const newUser = await this.userDbService.createUser({
|
||||
mobile: canonicalMobile,
|
||||
username: canonicalMobile,
|
||||
@@ -144,6 +147,7 @@ export class UserAuthService {
|
||||
}
|
||||
|
||||
await this.smsSender(otp, canonicalMobile);
|
||||
// console.log(`OTP for ${canonicalMobile}: ${otp}`);
|
||||
await this.userDbService.findOneAndUpdate(
|
||||
buildUserLookupByPhone(canonicalMobile),
|
||||
{
|
||||
@@ -156,7 +160,23 @@ export class UserAuthService {
|
||||
return new LoginDtoRs(userExist);
|
||||
}
|
||||
|
||||
private createOtpForRequest(): string {
|
||||
if (isFakeOtpEnabled()) {
|
||||
this.logger.warn(
|
||||
"FAKE_OTP=true — using fixed dev OTP; SMS provider is not called",
|
||||
);
|
||||
return FAKE_OTP_CODE;
|
||||
}
|
||||
return this.otpCreator.create();
|
||||
}
|
||||
|
||||
private async smsSender(otp: string, mobile: string) {
|
||||
if (isFakeOtpEnabled()) {
|
||||
this.logger.log(
|
||||
`FAKE_OTP=true — skipped SMS for phone=${mobile} (use OTP ${FAKE_OTP_CODE})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const ok = await this.smsOrchestrationService.sendAuthOtp(
|
||||
mobile,
|
||||
otp,
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsString, MaxLength } from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
|
||||
export class LoginActorDto {
|
||||
@ApiProperty({ example: RoleEnum, type: "array", description: "LOGIN_DTO" })
|
||||
role: RoleEnum[];
|
||||
|
||||
@ApiProperty({})
|
||||
username: string;
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Actor email or username. For field experts you may also send nationalCode instead.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Alias for username when logging in with email.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: "4311402422",
|
||||
description:
|
||||
"10-digit national ID. Alternative login identifier for actors (especially field experts without email).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationalCode?: string;
|
||||
|
||||
@ApiProperty({})
|
||||
password: string;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -8,6 +8,14 @@ import { JwtService } from "@nestjs/jwt";
|
||||
import { Request } from "express";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
|
||||
const GLOBAL_GUARD_ROLES = new Set<string>([
|
||||
RoleEnum.USER,
|
||||
RoleEnum.FIELD_EXPERT,
|
||||
RoleEnum.REGISTRAR,
|
||||
RoleEnum.FILE_MAKER,
|
||||
RoleEnum.FILE_REVIEWER
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class GlobalGuard implements CanActivate {
|
||||
constructor(private readonly jwtService: JwtService) {}
|
||||
@@ -17,7 +25,7 @@ export class GlobalGuard implements CanActivate {
|
||||
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
throw new UnauthorizedException("Missing Bearer token");
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -25,17 +33,19 @@ export class GlobalGuard implements CanActivate {
|
||||
secret: `${process.env.JWT_SECRET}`,
|
||||
});
|
||||
|
||||
if (
|
||||
payload.role !== RoleEnum.USER &&
|
||||
payload.role !== RoleEnum.FIELD_EXPERT
|
||||
) {
|
||||
throw new UnauthorizedException();
|
||||
if (!payload?.role || !GLOBAL_GUARD_ROLES.has(String(payload.role))) {
|
||||
throw new UnauthorizedException(
|
||||
`Role "${payload?.role ?? "unknown"}" is not allowed on user-panel APIs. Use /user/login for USER, or /actor/login for experts.`,
|
||||
);
|
||||
}
|
||||
|
||||
request.user = payload;
|
||||
request.identity = request.user;
|
||||
} catch {
|
||||
throw new UnauthorizedException();
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
throw new UnauthorizedException("Invalid or expired token");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -5,16 +5,19 @@ import { Reflector } from "@nestjs/core";
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
// get the roles required
|
||||
const roles = this.reflector.getAllAndOverride<string[]>("role", [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!roles) {
|
||||
if (!roles?.length) {
|
||||
return false;
|
||||
}
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const userRoles = request.user?.role?.split(",");
|
||||
const role = request.user?.role;
|
||||
if (!role) {
|
||||
return false;
|
||||
}
|
||||
const userRoles = String(role).split(",");
|
||||
return this.validateRoles(roles, userRoles);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiExcludeController,
|
||||
} from "@nestjs/swagger";
|
||||
import { diskStorage } from "multer";
|
||||
import { ClaimAccessGuard } from "src/auth/guards/claim-access.guard";
|
||||
@@ -39,8 +40,9 @@ import { UserObjectionDto } from "./dto/user-objection.dto";
|
||||
import { InPersonVisitDto } from "./dto/in-person-visit.dto";
|
||||
import { UserRatingDto } from "./dto/user-rating.dto";
|
||||
|
||||
@ApiExcludeController()
|
||||
@Controller("claim-request-management")
|
||||
@ApiTags("claim-request-management")
|
||||
// @ApiTags("claim-request-management")
|
||||
@Roles(RoleEnum.USER, RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
|
||||
@UseGuards(ClaimAccessGuard, RolesGuard)
|
||||
@ApiBearerAuth()
|
||||
@@ -49,8 +51,8 @@ export class ClaimRequestManagementController {
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
) {}
|
||||
|
||||
@ApiParam({ name: "blameId" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiParam({ name: "blameId" })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Post("/:blameId")
|
||||
async createClaimRequest(
|
||||
@Param("blameId") requestId: string,
|
||||
@@ -63,10 +65,10 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBody({ type: CarDamagePartDto })
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiBody({ type: CarDamagePartDto })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Patch("/car-part-damage/:claimRequestID")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
// @ApiParam({ name: "claimRequestID" })
|
||||
async carPartDamage(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
@Body() body: CarDamagePartDto,
|
||||
@@ -79,7 +81,7 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("/car-other-part")
|
||||
async getCarOtherParts() {
|
||||
const carOtherPart = await readFile(
|
||||
@@ -89,8 +91,8 @@ export class ClaimRequestManagementController {
|
||||
return carOtherPart;
|
||||
}
|
||||
|
||||
@ApiBody({ type: OtherCarDamagePartDto })
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
// @ApiBody({ type: OtherCarDamagePartDto })
|
||||
// @ApiParam({ name: "claimRequestID" })
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: {
|
||||
@@ -108,7 +110,7 @@ export class ClaimRequestManagementController {
|
||||
}),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Patch("/car-other-part-damage/:claimRequestID")
|
||||
async carOtherPartDamage(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
@@ -124,32 +126,32 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("required-documents-status/:claimRequestID")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
// @ApiParam({ name: "claimRequestID" })
|
||||
async getRequiredDocumentsStatus(@Param("claimRequestID") requestId: string) {
|
||||
return await this.claimRequestManagementService.getRequiredDocumentsStatus(
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("car-part-image-required/:claimRequestID")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
// @ApiParam({ name: "claimRequestID" })
|
||||
async getImageRequired(@Param("claimRequestID") requestId) {
|
||||
return await this.claimRequestManagementService.getImageRequiredList(
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
// @ApiBody({
|
||||
// schema: {
|
||||
// type: "object",
|
||||
// properties: {
|
||||
// file: { type: "string", format: "binary" },
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
@@ -169,14 +171,14 @@ export class ClaimRequestManagementController {
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
@ApiQuery({
|
||||
name: "documentType",
|
||||
enum: ClaimRequiredDocumentType,
|
||||
description: "Type of required document to upload",
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiConsumes("multipart/form-data")
|
||||
// // @ApiParam({ name: "claimRequestID" })
|
||||
// @ApiQuery({
|
||||
// name: "documentType",
|
||||
// enum: ClaimRequiredDocumentType,
|
||||
// description: "Type of required document to upload",
|
||||
// })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Patch("upload-required-document/:claimRequestID")
|
||||
async uploadRequiredDocument(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
@@ -195,14 +197,14 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
// @ApiBody({
|
||||
// schema: {
|
||||
// type: "object",
|
||||
// properties: {
|
||||
// file: { type: "string", format: "binary" },
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
@@ -223,13 +225,13 @@ export class ClaimRequestManagementController {
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
@ApiParam({
|
||||
name: "partId",
|
||||
description: "The ID of the specific car part being photographed.",
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiConsumes("multipart/form-data")
|
||||
// @ApiParam({ name: "claimRequestID" })
|
||||
// @ApiParam({
|
||||
// name: "partId",
|
||||
// description: "The ID of the specific car part being photographed.",
|
||||
// })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Patch("capture-car-part-damage/:claimRequestID/:partId")
|
||||
async captureCarPartDamage(
|
||||
@Param("partId") partId: string,
|
||||
@@ -246,14 +248,14 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
// @ApiBody({
|
||||
// schema: {
|
||||
// type: "object",
|
||||
// properties: {
|
||||
// file: { type: "string", format: "binary" },
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
@@ -268,9 +270,9 @@ export class ClaimRequestManagementController {
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiConsumes("multipart/form-data")
|
||||
// @ApiParam({ name: "claimRequestID" })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Patch("car-capture/:claimRequestID")
|
||||
async captureVideoCapture(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
@@ -282,22 +284,22 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("requests/")
|
||||
async getRequest(@CurrentUser() currentUser) {
|
||||
return await this.claimRequestManagementService.myRequests(currentUser);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("request/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
// @ApiParam({ name: "claimRequestId" })
|
||||
myRequests(@Param("claimRequestId") requestId: string, @CurrentUser() user) {
|
||||
return this.claimRequestManagementService.requestDetails(requestId, user);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Put("request/reply/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
// @ApiParam({ name: "claimRequestId" })
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: {
|
||||
@@ -314,12 +316,12 @@ export class ClaimRequestManagementController {
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiBody({
|
||||
type: UserCommentDto,
|
||||
description: "if partId null , you can upload video capture",
|
||||
})
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
// @ApiBody({
|
||||
// type: UserCommentDto,
|
||||
// description: "if partId null , you can upload video capture",
|
||||
// })
|
||||
// @ApiConsumes("multipart/form-data")
|
||||
// @ApiParam({ name: "claimRequestId" })
|
||||
async submitReply(
|
||||
@Param("claimRequestId") requestId,
|
||||
@Body() body,
|
||||
@@ -334,14 +336,14 @@ export class ClaimRequestManagementController {
|
||||
// );
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Put("request/resend/:claimRequestId/objection")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiConsumes("application/json")
|
||||
@ApiBody({
|
||||
type: UserObjectionDto,
|
||||
description: "Objection details with optional new parts",
|
||||
})
|
||||
// @ApiParam({ name: "claimRequestId" })
|
||||
// @ApiConsumes("application/json")
|
||||
// @ApiBody({
|
||||
// type: UserObjectionDto,
|
||||
// description: "Objection details with optional new parts",
|
||||
// })
|
||||
async handleUserObjection(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() userObjectionDto: UserObjectionDto,
|
||||
@@ -352,25 +354,25 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Patch("request/resend/:claimRequestId")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiQuery({ name: "fields", enum: ["resendDocuments", "resendCarParts"] })
|
||||
@ApiQuery({ name: "partId", required: false })
|
||||
@ApiQuery({ name: "documentName", required: false })
|
||||
@ApiQuery({ name: "side", required: false })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// @ApiConsumes("multipart/form-data")
|
||||
// @ApiParam({ name: "claimRequestId" })
|
||||
// @ApiQuery({ name: "fields", enum: ["resendDocuments", "resendCarParts"] })
|
||||
// @ApiQuery({ name: "partId", required: false })
|
||||
// @ApiQuery({ name: "documentName", required: false })
|
||||
// @ApiQuery({ name: "side", required: false })
|
||||
// @ApiBody({
|
||||
// schema: {
|
||||
// type: "object",
|
||||
// properties: {
|
||||
// file: {
|
||||
// type: "string",
|
||||
// format: "binary",
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
storage: diskStorage({
|
||||
@@ -403,10 +405,10 @@ export class ClaimRequestManagementController {
|
||||
* User satisfaction rating for a completed claim file.
|
||||
* Only the damaged user (claim owner) can rate their claim after it is closed.
|
||||
*/
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Put("request/:claimRequestId/user-rating")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiBody({ type: UserRatingDto })
|
||||
// @ApiParam({ name: "claimRequestId" })
|
||||
// @ApiBody({ type: UserRatingDto })
|
||||
async addUserRating(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() ratingDto: UserRatingDto,
|
||||
@@ -419,22 +421,22 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Patch("request/reply/:claimRequestId/:partId/upload-factor")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
@ApiParam({ name: "partId" })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
// @ApiConsumes("multipart/form-data")
|
||||
// @ApiParam({ name: "claimRequestId" })
|
||||
// @ApiParam({ name: "partId" })
|
||||
// @ApiBody({
|
||||
// schema: {
|
||||
// type: "object",
|
||||
// properties: {
|
||||
// file: {
|
||||
// type: "string",
|
||||
// format: "binary",
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
storage: diskStorage({
|
||||
@@ -462,9 +464,9 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBody({ type: InPersonVisitDto })
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiBody({ type: InPersonVisitDto })
|
||||
// @ApiParam({ name: "id" })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Patch(":id/visit")
|
||||
async inPersonVisit(
|
||||
@Param("id") requestId: string,
|
||||
@@ -479,26 +481,25 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("branches/:insuranceId")
|
||||
// @ApiParam({ name: "insuranceId" })
|
||||
async insuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||
return await this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||
insuranceId,
|
||||
);
|
||||
return await this.claimRequestManagementService.retrieveInsuranceBranches(insuranceId);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("fanavaran-submit/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
// @ApiParam({ name: "claimRequestId" })
|
||||
async fanavaranSubmit(@Param("claimRequestId") claimRequestId: string) {
|
||||
return await this.claimRequestManagementService.fanavaranSubmit(
|
||||
claimRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Post("fanavaran-submit/:claimRequestId")
|
||||
@ApiParam({ name: "claimRequestId" })
|
||||
// @ApiParam({ name: "claimRequestId" })
|
||||
async submitToFanavaran(@Param("claimRequestId") claimRequestId: string) {
|
||||
return await this.claimRequestManagementService.submitToFanavaran(
|
||||
claimRequestId,
|
||||
|
||||
@@ -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";
|
||||
@@ -7,6 +10,8 @@ import { UsersModule } from "src/users/users.module";
|
||||
import { ClaimRequestManagementController } from "./claim-request-management.controller";
|
||||
import { ClaimRequestManagementV2Controller } from "./claim-request-management.v2.controller";
|
||||
import { RegistrarClaimV1Controller } from "./registrar-claim.v1.controller";
|
||||
import { ExpertInitiatedClaimMirrorController } from "./expert-initiated-claim.mirror.controller";
|
||||
import { RegistrarClaimMirrorController } from "./registrar-claim.mirror.controller";
|
||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
import { CarGreenCardDbService } from "./entites/db-service/car-green-card.db.service";
|
||||
import { ClaimRequestManagementDbService } from "./entites/db-service/claim-request-management.db.service";
|
||||
@@ -50,11 +55,18 @@ 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";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule,
|
||||
HttpModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: createHttpModuleOptions,
|
||||
}),
|
||||
FanavaranAuditModule,
|
||||
FanavaranLookupModule,
|
||||
PublicIdModule,
|
||||
UsersModule,
|
||||
RequestManagementModule,
|
||||
@@ -97,6 +109,8 @@ import { HttpModule } from "@nestjs/axios";
|
||||
ClaimRequestManagementController,
|
||||
ClaimRequestManagementV2Controller,
|
||||
RegistrarClaimV1Controller,
|
||||
ExpertInitiatedClaimMirrorController,
|
||||
RegistrarClaimMirrorController,
|
||||
],
|
||||
exports: [
|
||||
ClaimRequestManagementService,
|
||||
|
||||
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)
|
||||
@Roles(
|
||||
RoleEnum.USER,
|
||||
RoleEnum.FIELD_EXPERT,
|
||||
RoleEnum.REGISTRAR,
|
||||
RoleEnum.FILE_MAKER,
|
||||
RoleEnum.FILE_REVIEWER,
|
||||
)
|
||||
export class ClaimRequestManagementV2Controller {
|
||||
constructor(
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
@@ -63,7 +84,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
@ApiOperation({
|
||||
summary: "Get My Claims (V2)",
|
||||
description:
|
||||
"Claims for the current user (or field-expert in-person files). Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.",
|
||||
"Claims for the current user, or claims from blame files initiated by the current FIELD_EXPERT / REGISTRAR (LINK and IN_PERSON). Optional query: `search`, `sortBy`, `sortOrder`, `page`, `limit`.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -97,7 +118,7 @@ export class ClaimRequestManagementV2Controller {
|
||||
@ApiOperation({
|
||||
summary: "Get Claim Details (V2)",
|
||||
description:
|
||||
"Returns the claim snapshot for **USER** (owner) or **FIELD_EXPERT** when permitted. Owners get `ownerGuidance`: `{ phaseKey, headline, nextActions[] (method + pathTemplate), objectionAllowed }` mapped from `status` / `claimStatus` / workflow so the client can show the correct screen without duplicating orchestration logic. FIELD_EXPERT does not receive `ownerGuidance`. Core payload includes documents, captures, evaluation replies, optional expert resend, and masked bank info.",
|
||||
"Returns the claim snapshot for **USER** (owner), **FIELD_EXPERT**, or **REGISTRAR** when permitted. Initiating experts/registrars see unmasked money fields; owners get `ownerGuidance`.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -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,7 +1112,9 @@ 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;
|
||||
}
|
||||
|
||||
@@ -106,14 +106,16 @@ export class SelectOtherPartsV2ResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Sheba number (masked for security)',
|
||||
example: 'IR12************1234',
|
||||
required: false,
|
||||
})
|
||||
shebaNumber: string;
|
||||
shebaNumber?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'National code of owner (masked)',
|
||||
example: '12******90',
|
||||
required: false,
|
||||
})
|
||||
nationalCodeOfOwner: string;
|
||||
nationalCodeOfOwner?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Current workflow step',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
} from "class-validator";
|
||||
import { OtherCarPart } from "./select-other-parts-v2.dto";
|
||||
|
||||
/**
|
||||
* V3 in-person expert flow: other parts only (sheba/national code collected during run-inquiries).
|
||||
*/
|
||||
export class SelectOtherPartsV3Dto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Array of selected other damaged parts (non-body parts)",
|
||||
example: ["engine", "suspension", "headlight"],
|
||||
enum: OtherCarPart,
|
||||
isArray: true,
|
||||
maxItems: 11,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray({ message: "otherParts must be an array" })
|
||||
@ArrayMaxSize(11, { message: "Maximum 11 other parts can be selected" })
|
||||
@IsEnum(OtherCarPart, {
|
||||
each: true,
|
||||
message: "Invalid part name. Must be one of the valid other car parts",
|
||||
})
|
||||
otherParts?: OtherCarPart[];
|
||||
}
|
||||
@@ -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,
|
||||
@@ -108,6 +166,22 @@ export class ClaimCase {
|
||||
@Prop({ type: String, index: true })
|
||||
blameRequestNo?: string;
|
||||
|
||||
/**
|
||||
* Set when this claim was created by a field expert on behalf of the damaged
|
||||
* party (expert-initiated IN_PERSON flow). Used to scope the expert's panel
|
||||
* access to only their own initiated files.
|
||||
*/
|
||||
@Prop({ type: Types.ObjectId, index: true })
|
||||
initiatedByFieldExpertId?: Types.ObjectId;
|
||||
|
||||
/**
|
||||
* The damaged party's userId, resolved from the blame at claim-creation time.
|
||||
* Stored here so view/list access does not require an extra blame lookup.
|
||||
* For CAR_BODY this is the FIRST party; for THIRD_PARTY it is the non-guilty party.
|
||||
*/
|
||||
@Prop({ type: Types.ObjectId, index: true })
|
||||
damagedPartyUserId?: Types.ObjectId;
|
||||
|
||||
@Prop({ type: ClaimWorkflowSchema, default: () => ({}) })
|
||||
workflow?: ClaimWorkflow;
|
||||
|
||||
@@ -126,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;
|
||||
|
||||
@@ -168,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.
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { extname } from "node:path";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
InternalServerErrorException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
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 { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
import {
|
||||
OuterPartCatalogItemDto,
|
||||
SelectOuterPartsV2Dto,
|
||||
SelectOuterPartsV2ResponseDto,
|
||||
} from "./dto/select-outer-parts-v2.dto";
|
||||
import {
|
||||
SelectOtherPartsV2Dto,
|
||||
SelectOtherPartsV2ResponseDto,
|
||||
} from "./dto/select-other-parts-v2.dto";
|
||||
import {
|
||||
UploadRequiredDocumentV2Dto,
|
||||
UploadRequiredDocumentV2ResponseDto,
|
||||
} from "./dto/upload-document-v2.dto";
|
||||
import {
|
||||
CapturePartV2Dto,
|
||||
CapturePartV2ResponseDto,
|
||||
} from "./dto/capture-part-v2.dto";
|
||||
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||
|
||||
/**
|
||||
* Expert-initiated claim flow that mirrors the normal user claim API
|
||||
* (`v2/claim-request-management`) one-to-one. The frontend reuses the same
|
||||
* claim pages by only swapping the route prefix:
|
||||
*
|
||||
* `v2/claim-request-management/*` -> `v2/expert-initiated/claim-request-management/*`
|
||||
*
|
||||
* The field expert fills the claim (parts, captures, documents) on behalf of the
|
||||
* damaged party for a claim created from an expert-initiated IN_PERSON blame.
|
||||
* After the expert submits the data, the file follows the exact normal review
|
||||
* lifecycle: the damage expert prices it, and the owner can later object / resend
|
||||
* from their own account through the normal user endpoints.
|
||||
*/
|
||||
@ApiTags("expert-initiated claim (mirror v2)")
|
||||
@Controller("v2/expert-initiated/claim-request-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.FIELD_EXPERT)
|
||||
export class ExpertInitiatedClaimMirrorController {
|
||||
constructor(
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
private readonly mediaPolicyService: MediaPolicyService,
|
||||
) {}
|
||||
|
||||
@Post("create-from-blame/:blameRequestId")
|
||||
@ApiParam({ name: "blameRequestId" })
|
||||
@ApiOperation({
|
||||
summary: "[Expert mirror] Create claim from expert-initiated IN_PERSON blame",
|
||||
description:
|
||||
"Blame must be COMPLETED. Creates a ClaimCase owned by the damaged (non-guilty) party.",
|
||||
})
|
||||
async createFromBlame(
|
||||
@Param("blameRequestId") blameRequestId: string,
|
||||
@CurrentUser() expert: any,
|
||||
) {
|
||||
return this.claimRequestManagementService.createClaimFromBlameForExpertV2(
|
||||
blameRequestId,
|
||||
expert,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("outer-parts-catalog")
|
||||
@ApiOperation({
|
||||
summary: "Get outer parts catalog (V2)",
|
||||
description:
|
||||
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Outer parts catalog",
|
||||
type: [OuterPartCatalogItemDto],
|
||||
})
|
||||
async getOuterPartsCatalog(@Query("carType") carType?: ClaimVehicleTypeV2) {
|
||||
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
|
||||
}
|
||||
|
||||
@Get("car-other-part")
|
||||
@ApiOperation({
|
||||
summary: "Get other (non-body) parts catalog",
|
||||
description:
|
||||
"Returns legacy other-parts catalog used by frontend. Response is parsed JSON.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Other parts catalog" })
|
||||
async getCarOtherParts() {
|
||||
const raw = await readFile(
|
||||
`${process.cwd()}/src/static/car-part.json`,
|
||||
"utf-8",
|
||||
);
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
@Get("branches/:insuranceId")
|
||||
@ApiParam({
|
||||
name: "insuranceId",
|
||||
description: "Insurer client id (MongoDB ObjectId)",
|
||||
example: "60d5ec49e7b2f8001c8e4d2a",
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Get insurer branches (V2)",
|
||||
description:
|
||||
"Returns branch list for a given insurer/client id so frontend can render branch options (name/code/address/city/state) and submit selected branchId in daghi part options.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "List of branches for insurer" })
|
||||
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||
return this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||
insuranceId,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("select-outer-parts/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
|
||||
description: `
|
||||
**Workflow Step:** SELECT_OUTER_PARTS (Step 2 of Claim)
|
||||
|
||||
**Purpose:** Expert selects which outer car parts (body parts) were damaged on behalf of the damaged party.
|
||||
|
||||
**Validations:**
|
||||
- Claim must exist
|
||||
- Current workflow step must be CLAIM_CREATED
|
||||
- Parts array must contain at least 1 part
|
||||
- No duplicate parts allowed
|
||||
- Parts cannot be re-selected once submitted
|
||||
|
||||
**After Success:**
|
||||
- Workflow moves to: SELECT_OTHER_PARTS (Step 3)
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiBody({
|
||||
type: SelectOuterPartsV2Dto,
|
||||
description:
|
||||
"Selected vehicle type + selected outer part IDs from catalog",
|
||||
examples: {
|
||||
example1: {
|
||||
summary: "Sedan - minor front damage",
|
||||
value: {
|
||||
carType: "sedan",
|
||||
selectedPartIds: [19, 21, 16],
|
||||
},
|
||||
},
|
||||
example2: {
|
||||
summary: "SUV - left side impact",
|
||||
value: {
|
||||
carType: "suv",
|
||||
selectedPartIds: [102, 103, 104, 107],
|
||||
},
|
||||
},
|
||||
example3: {
|
||||
summary: "Hatchback - rear-end collision",
|
||||
value: {
|
||||
carType: "hatchback",
|
||||
selectedPartIds: [225, 226, 210],
|
||||
},
|
||||
},
|
||||
example4: {
|
||||
summary: "Pickup - two sides + roof",
|
||||
value: {
|
||||
carType: "pickup",
|
||||
selectedPartIds: [319, 312, 330],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Outer parts selected successfully",
|
||||
type: SelectOuterPartsV2ResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" })
|
||||
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||
@ApiResponse({ status: 409, description: "Outer parts already selected" })
|
||||
async selectOuterParts(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOuterPartsV2Dto,
|
||||
@CurrentUser() expert: any,
|
||||
): Promise<SelectOuterPartsV2ResponseDto> {
|
||||
return this.claimRequestManagementService.selectOuterPartsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
expert.sub,
|
||||
expert,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("select-other-parts/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Select Other Parts & Bank Information (V2 - Step 3)",
|
||||
description: `
|
||||
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
|
||||
|
||||
**Purpose:** Expert selects non-body damaged parts and provides bank information for payment on behalf of the damaged party.
|
||||
Optional: upload car green card file in the same step.
|
||||
|
||||
**Validations:**
|
||||
- Claim must exist
|
||||
- Current workflow step must be SELECT_OTHER_PARTS
|
||||
- Bank information must not have been submitted previously
|
||||
- Sheba (sheba) accepted as IR + 24 digits or only 24 digits
|
||||
- National code must be exactly 10 digits
|
||||
|
||||
**Valid Other Parts (Optional):**
|
||||
- engine, suspension, brake_system, electrical
|
||||
- radiator, transmission, exhaust
|
||||
- headlight, taillight, mirror, glass
|
||||
|
||||
**After Success:**
|
||||
- Workflow moves to: CAPTURE_PART_DAMAGES (Step 4)
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
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, `other-parts-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiBody({
|
||||
description:
|
||||
"Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer`. Optional file can be uploaded as car green card.",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
otherParts: {
|
||||
oneOf: [
|
||||
{ type: "array", items: { type: "string" } },
|
||||
{ type: "string", description: "JSON string array for multipart" },
|
||||
],
|
||||
example: ["engine", "suspension"],
|
||||
},
|
||||
sheba: { type: "string", example: "IR123456789012345678901234" },
|
||||
nationalCodeOfInsurer: { type: "string", example: "1234567890" },
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
required: ["sheba", "nationalCodeOfInsurer"],
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Other parts and bank information saved successfully",
|
||||
type: SelectOtherPartsV2ResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" })
|
||||
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||
@ApiResponse({ status: 409, description: "Bank information already submitted" })
|
||||
async selectOtherParts(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOtherPartsV2Dto,
|
||||
@CurrentUser() expert: any,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||
// Green-card photo is optional here — the helper no-ops on missing file.
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.selectOtherPartsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
expert.sub,
|
||||
expert,
|
||||
file,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("capture-requirements/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Get Capture Requirements (V2)",
|
||||
description: `
|
||||
**Get list of what needs to be captured:**
|
||||
- Required documents (10 remaining at the documents step for third-party; 3 damaged-party items should be uploaded during capture — see \`preferUploadDuringCapture\` on each item)
|
||||
- Car angles (4 items: front, back, left, right)
|
||||
- Damaged parts (based on selected outer parts)
|
||||
|
||||
Returns status of each item (uploaded/captured or not).
|
||||
|
||||
**V2 order (enforced by API):** During \`CAPTURE_PART_DAMAGES\`, (1) all damaged-part photos, (2) four car angles, (3) chassis/engine/metal-plate via upload-document, then walk-around video. Remaining documents in \`UPLOAD_REQUIRED_DOCUMENTS\`. Use \`captureSequencePhase\` / \`captureSequenceHint\` in the response.
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Capture requirements retrieved successfully",
|
||||
type: GetCaptureRequirementsV2ResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 403, description: "User is not the claim owner" })
|
||||
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||
async getCaptureRequirements(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() expert: any,
|
||||
): Promise<GetCaptureRequirementsV2ResponseDto> {
|
||||
return this.claimRequestManagementService.getCaptureRequirementsV2(
|
||||
claimRequestId,
|
||||
expert.sub,
|
||||
expert,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("upload-document/:claimRequestId")
|
||||
@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);
|
||||
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
|
||||
callback(null, filename);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Upload Required Document (V2 - Step 5)",
|
||||
description: `
|
||||
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
|
||||
|
||||
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
|
||||
- damaged_driving_license_front/back
|
||||
- damaged_chassis_number, damaged_engine_photo
|
||||
- damaged_car_card_front/back, damaged_metal_plate
|
||||
- guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
|
||||
|
||||
**When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE and the claim is ready for damage expert review.
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["documentKey", "file"],
|
||||
properties: {
|
||||
documentKey: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"damaged_driving_license_front",
|
||||
"damaged_driving_license_back",
|
||||
"damaged_chassis_number",
|
||||
"damaged_engine_photo",
|
||||
"damaged_car_card_front",
|
||||
"damaged_car_card_back",
|
||||
"damaged_metal_plate",
|
||||
"guilty_driving_license_front",
|
||||
"guilty_driving_license_back",
|
||||
"guilty_car_card_front",
|
||||
"guilty_car_card_back",
|
||||
"guilty_metal_plate",
|
||||
],
|
||||
example: "damaged_driving_license_front",
|
||||
},
|
||||
file: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Document uploaded successfully",
|
||||
type: UploadRequiredDocumentV2ResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Invalid workflow step" })
|
||||
@ApiResponse({ status: 409, description: "Document already uploaded" })
|
||||
async uploadDocument(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UploadRequiredDocumentV2Dto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() expert: any,
|
||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.uploadRequiredDocumentV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
expert.sub,
|
||||
expert,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("capture-part/:claimRequestId")
|
||||
@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);
|
||||
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
|
||||
callback(null, filename);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Capture Car Angle or Damaged Part (V2 - Step 4)",
|
||||
description: `
|
||||
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim)
|
||||
|
||||
**Capture types:**
|
||||
1. **angle**: Car angles (front, back, left, right) - 4 required
|
||||
2. **part**: Damaged parts based on selectedParts from Step 2
|
||||
|
||||
**When all captures are complete (parts, angles, capture-phase docs):** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5). Angles are blocked until all parts are captured; capture-phase documents are blocked until all angles are captured.
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@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",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Capture saved successfully",
|
||||
type: CapturePartV2ResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Invalid workflow step" })
|
||||
async capturePart(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: CapturePartV2Dto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() expert: any,
|
||||
): Promise<CapturePartV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.capturePartV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
expert.sub,
|
||||
expert,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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(
|
||||
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, `claim-video-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["file"],
|
||||
properties: { file: { type: "string", format: "binary" } },
|
||||
},
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Upload Car Walk-Around Video (V2 - Step 4b)",
|
||||
description:
|
||||
"Upload a walk-around video of the damaged car during the capture phase. Allowed at any point after outer parts are selected.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Video uploaded successfully" })
|
||||
async carCapture(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@UploadedFile("file") file: Express.Multer.File,
|
||||
@CurrentUser() expert: any,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
|
||||
return this.claimRequestManagementService.setVideoCaptureV2(
|
||||
claimRequestId,
|
||||
file,
|
||||
expert.sub,
|
||||
expert,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { extname } from "node:path";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
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 { ClaimVehicleTypeV2 } from "src/static/outer-car-parts-catalog";
|
||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
import {
|
||||
OuterPartCatalogItemDto,
|
||||
SelectOuterPartsV2Dto,
|
||||
SelectOuterPartsV2ResponseDto,
|
||||
} from "./dto/select-outer-parts-v2.dto";
|
||||
import {
|
||||
SelectOtherPartsV2Dto,
|
||||
SelectOtherPartsV2ResponseDto,
|
||||
} from "./dto/select-other-parts-v2.dto";
|
||||
import {
|
||||
UploadRequiredDocumentV2Dto,
|
||||
UploadRequiredDocumentV2ResponseDto,
|
||||
} from "./dto/upload-document-v2.dto";
|
||||
import {
|
||||
CapturePartV2Dto,
|
||||
CapturePartV2ResponseDto,
|
||||
} from "./dto/capture-part-v2.dto";
|
||||
import { GetCaptureRequirementsV2ResponseDto } from "./dto/capture-requirements-v2.dto";
|
||||
|
||||
/**
|
||||
* Registrar claim flow that mirrors the normal user claim API
|
||||
* (`v2/claim-request-management`) one-to-one. The frontend reuses the same
|
||||
* claim pages by only swapping the route prefix:
|
||||
*
|
||||
* `v2/claim-request-management/*` -> `v2/registrar/claim-request-management/*`
|
||||
*
|
||||
* The registrar fills the claim (parts, captures, documents) on behalf of the
|
||||
* damaged party for a claim created from a registrar-initiated IN_PERSON blame.
|
||||
* After submission the file follows the exact normal review lifecycle:
|
||||
* the damage expert prices it, and the owner can later object/resend.
|
||||
* The registrar's job ends here — no reviewing panel is needed.
|
||||
*/
|
||||
@ApiTags("registrar claim (mirror v2)")
|
||||
@Controller("v2/registrar/claim-request-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@Roles(RoleEnum.REGISTRAR)
|
||||
export class RegistrarClaimMirrorController {
|
||||
constructor(
|
||||
private readonly claimRequestManagementService: ClaimRequestManagementService,
|
||||
private readonly mediaPolicyService: MediaPolicyService,
|
||||
) {}
|
||||
|
||||
@Post("create-from-blame/:blameRequestId")
|
||||
@ApiParam({ name: "blameRequestId" })
|
||||
@ApiOperation({
|
||||
summary: "[Registrar mirror] Create claim from registrar-initiated IN_PERSON blame",
|
||||
description:
|
||||
"Blame must be COMPLETED. Creates a ClaimCase owned by the damaged (non-guilty) party.",
|
||||
})
|
||||
async createFromBlame(
|
||||
@Param("blameRequestId") blameRequestId: string,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1(
|
||||
blameRequestId,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("outer-parts-catalog")
|
||||
@ApiOperation({
|
||||
summary: "Get outer parts catalog (V2)",
|
||||
description:
|
||||
"Returns outer-damage parts with id/key/side. Optional `carType` filter returns only that type catalog.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Outer parts catalog",
|
||||
type: [OuterPartCatalogItemDto],
|
||||
})
|
||||
async getOuterPartsCatalog(@Query("carType") carType?: ClaimVehicleTypeV2) {
|
||||
return this.claimRequestManagementService.getOuterPartsCatalogV2(carType);
|
||||
}
|
||||
|
||||
@Get("car-other-part")
|
||||
@ApiOperation({
|
||||
summary: "Get other (non-body) parts catalog",
|
||||
description:
|
||||
"Returns legacy other-parts catalog used by frontend. Response is parsed JSON.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Other parts catalog" })
|
||||
async getCarOtherParts() {
|
||||
const raw = await readFile(
|
||||
`${process.cwd()}/src/static/car-part.json`,
|
||||
"utf-8",
|
||||
);
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
@Get("branches/:insuranceId")
|
||||
@ApiParam({
|
||||
name: "insuranceId",
|
||||
description: "Insurer client id (MongoDB ObjectId)",
|
||||
example: "60d5ec49e7b2f8001c8e4d2a",
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Get insurer branches (V2)",
|
||||
description:
|
||||
"Returns branch list for a given insurer/client id so frontend can render branch options.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "List of branches for insurer" })
|
||||
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||
return this.claimRequestManagementService.retrieveInsuranceBranches(
|
||||
insuranceId,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("select-outer-parts/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Select Damaged Outer Car Parts (V2 - Step 2)",
|
||||
description: `
|
||||
**Workflow Step:** SELECT_OUTER_PARTS (Step 2 of Claim)
|
||||
|
||||
**Purpose:** Registrar selects which outer car parts (body parts) were damaged on behalf of the damaged party.
|
||||
|
||||
**After Success:**
|
||||
- Workflow moves to: SELECT_OTHER_PARTS (Step 3)
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiBody({
|
||||
type: SelectOuterPartsV2Dto,
|
||||
description: "Selected vehicle type + selected outer part IDs from catalog",
|
||||
examples: {
|
||||
example1: {
|
||||
summary: "Sedan - minor front damage",
|
||||
value: { carType: "sedan", selectedPartIds: [19, 21, 16] },
|
||||
},
|
||||
example2: {
|
||||
summary: "SUV - left side impact",
|
||||
value: { carType: "suv", selectedPartIds: [102, 103, 104, 107] },
|
||||
},
|
||||
example3: {
|
||||
summary: "Hatchback - rear-end collision",
|
||||
value: { carType: "hatchback", selectedPartIds: [225, 226, 210] },
|
||||
},
|
||||
example4: {
|
||||
summary: "Pickup - two sides + roof",
|
||||
value: { carType: "pickup", selectedPartIds: [319, 312, 330] },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Outer parts selected successfully",
|
||||
type: SelectOuterPartsV2ResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" })
|
||||
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||
@ApiResponse({ status: 409, description: "Outer parts already selected" })
|
||||
async selectOuterParts(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOuterPartsV2Dto,
|
||||
@CurrentUser() registrar: any,
|
||||
): Promise<SelectOuterPartsV2ResponseDto> {
|
||||
return this.claimRequestManagementService.selectOuterPartsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("select-other-parts/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Select Other Parts & Bank Information (V2 - Step 3)",
|
||||
description: `
|
||||
**Workflow Step:** SELECT_OTHER_PARTS (Step 3 of Claim)
|
||||
|
||||
**Purpose:** Registrar selects non-body damaged parts and provides bank information on behalf of the damaged party.
|
||||
Optional: upload car green card file in the same step.
|
||||
|
||||
**Valid Other Parts (Optional):**
|
||||
- engine, suspension, brake_system, electrical
|
||||
- radiator, transmission, exhaust
|
||||
- headlight, taillight, mirror, glass
|
||||
|
||||
**After Success:**
|
||||
- Workflow moves to: CAPTURE_PART_DAMAGES (Step 4)
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
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, `other-parts-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiBody({
|
||||
description:
|
||||
"Other parts + bank information. Use `sheba` and `nationalCodeOfInsurer`. Optional file can be uploaded as car green card.",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
otherParts: {
|
||||
oneOf: [
|
||||
{ type: "array", items: { type: "string" } },
|
||||
{ type: "string", description: "JSON string array for multipart" },
|
||||
],
|
||||
example: ["engine", "suspension"],
|
||||
},
|
||||
sheba: { type: "string", example: "IR123456789012345678901234" },
|
||||
nationalCodeOfInsurer: { type: "string", example: "1234567890" },
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
required: ["sheba", "nationalCodeOfInsurer"],
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Other parts and bank information saved successfully",
|
||||
type: SelectOtherPartsV2ResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Invalid workflow step or validation failed" })
|
||||
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||
@ApiResponse({ status: 409, description: "Bank information already submitted" })
|
||||
async selectOtherParts(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOtherPartsV2Dto,
|
||||
@CurrentUser() registrar: any,
|
||||
@UploadedFile() file?: Express.Multer.File,
|
||||
): Promise<SelectOtherPartsV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.selectOtherPartsV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
file,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("capture-requirements/:claimRequestId")
|
||||
@ApiOperation({
|
||||
summary: "Get Capture Requirements (V2)",
|
||||
description: `
|
||||
**Get list of what needs to be captured:**
|
||||
- Required documents (10 remaining at the documents step for third-party)
|
||||
- Car angles (4 items: front, back, left, right)
|
||||
- Damaged parts (based on selected outer parts)
|
||||
|
||||
Returns status of each item (uploaded/captured or not).
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID (MongoDB ObjectId)",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Capture requirements retrieved successfully",
|
||||
type: GetCaptureRequirementsV2ResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 403, description: "Access denied" })
|
||||
@ApiResponse({ status: 404, description: "Claim case not found" })
|
||||
async getCaptureRequirements(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@CurrentUser() registrar: any,
|
||||
): Promise<GetCaptureRequirementsV2ResponseDto> {
|
||||
return this.claimRequestManagementService.getCaptureRequirementsV2(
|
||||
claimRequestId,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("upload-document/:claimRequestId")
|
||||
@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);
|
||||
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
|
||||
callback(null, filename);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Upload Required Document (V2 - Step 5)",
|
||||
description: `
|
||||
**Workflow Step:** UPLOAD_REQUIRED_DOCUMENTS (Step 5 of Claim)
|
||||
|
||||
**Upload one of the required documents** (12 for THIRD_PARTY; CAR_BODY may require fewer — see capture-requirements):
|
||||
- damaged_driving_license_front/back
|
||||
- damaged_chassis_number, damaged_engine_photo
|
||||
- damaged_car_card_front/back, damaged_metal_plate
|
||||
- guilty_driving_license_front/back, guilty_car_card_front/back, guilty_metal_plate (THIRD_PARTY)
|
||||
|
||||
**When all required documents are uploaded:** Workflow moves to USER_SUBMISSION_COMPLETE.
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["documentKey", "file"],
|
||||
properties: {
|
||||
documentKey: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"damaged_driving_license_front",
|
||||
"damaged_driving_license_back",
|
||||
"damaged_chassis_number",
|
||||
"damaged_engine_photo",
|
||||
"damaged_car_card_front",
|
||||
"damaged_car_card_back",
|
||||
"damaged_metal_plate",
|
||||
"guilty_driving_license_front",
|
||||
"guilty_driving_license_back",
|
||||
"guilty_car_card_front",
|
||||
"guilty_car_card_back",
|
||||
"guilty_metal_plate",
|
||||
],
|
||||
example: "damaged_driving_license_front",
|
||||
},
|
||||
file: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Document uploaded successfully",
|
||||
type: UploadRequiredDocumentV2ResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Invalid workflow step" })
|
||||
@ApiResponse({ status: 409, description: "Document already uploaded" })
|
||||
async uploadDocument(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UploadRequiredDocumentV2Dto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() registrar: any,
|
||||
): Promise<UploadRequiredDocumentV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.uploadRequiredDocumentV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("capture-part/:claimRequestId")
|
||||
@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);
|
||||
const filename = `${file.originalname.split(".")[0]}-${unique}${ex}`;
|
||||
callback(null, filename);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Capture Car Angle or Damaged Part (V2 - Step 4)",
|
||||
description: `
|
||||
**Workflow Step:** CAPTURE_PART_DAMAGES (Step 4 of Claim)
|
||||
|
||||
**Capture types:**
|
||||
1. **angle**: Car angles (front, back, left, right) - 4 required
|
||||
2. **part**: Damaged parts based on selectedParts from Step 2
|
||||
|
||||
**When all captures are complete:** Workflow moves to UPLOAD_REQUIRED_DOCUMENTS (Step 5).
|
||||
`,
|
||||
})
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@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" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Capture saved successfully",
|
||||
type: CapturePartV2ResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Invalid workflow step" })
|
||||
async capturePart(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: CapturePartV2Dto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() registrar: any,
|
||||
): Promise<CapturePartV2ResponseDto> {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "image");
|
||||
return this.claimRequestManagementService.capturePartV2(
|
||||
claimRequestId,
|
||||
body,
|
||||
file,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("car-capture/:claimRequestId")
|
||||
@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, `claim-video-${unique}${ex}`);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiParam({
|
||||
name: "claimRequestId",
|
||||
description: "The claim case ID",
|
||||
example: "507f1f77bcf86cd799439011",
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["file"],
|
||||
properties: { file: { type: "string", format: "binary" } },
|
||||
},
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Upload Car Walk-Around Video (V2 - Step 4b)",
|
||||
description:
|
||||
"Upload a walk-around video of the damaged car during the capture phase.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Video uploaded successfully" })
|
||||
async carCapture(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@UploadedFile("file") file: Express.Multer.File,
|
||||
@CurrentUser() registrar: any,
|
||||
) {
|
||||
await this.mediaPolicyService.assertForClaim(file, claimRequestId, "video");
|
||||
return this.claimRequestManagementService.setVideoCaptureV2(
|
||||
claimRequestId,
|
||||
file,
|
||||
registrar.sub,
|
||||
registrar,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Patch, Post, UploadedFile, UseGuards, Use
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { diskStorage } from "multer";
|
||||
import { extname } from "path";
|
||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiTags } from "@nestjs/swagger";
|
||||
import { ApiBearerAuth, ApiConsumes, ApiExcludeController } 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";
|
||||
@@ -15,7 +15,8 @@ import { UploadRequiredDocumentV2Dto } from "./dto/upload-document-v2.dto";
|
||||
import { CapturePartV2Dto } from "./dto/capture-part-v2.dto";
|
||||
import { ClaimRequestManagementService } from "./claim-request-management.service";
|
||||
|
||||
@ApiTags("registrar-claim (v1)")
|
||||
@ApiExcludeController()
|
||||
// @ApiTags("registrar-claim (v1)")
|
||||
@Controller("registrar-claim")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@@ -24,7 +25,7 @@ export class RegistrarClaimV1Controller {
|
||||
constructor(private readonly claimRequestManagementService: ClaimRequestManagementService) {}
|
||||
|
||||
@Post("create-from-blame/:blameRequestId")
|
||||
@ApiParam({ name: "blameRequestId" })
|
||||
// @ApiParam({ name: "blameRequestId" })
|
||||
createFromBlame(@Param("blameRequestId") blameRequestId: string, @CurrentUser() registrar: any) {
|
||||
return this.claimRequestManagementService.createClaimFromBlameForRegistrarV1(
|
||||
blameRequestId,
|
||||
@@ -42,7 +43,7 @@ export class RegistrarClaimV1Controller {
|
||||
}
|
||||
|
||||
@Patch("select-outer-parts/:claimRequestId")
|
||||
@ApiBody({ type: SelectOuterPartsV2Dto })
|
||||
// @ApiBody({ type: SelectOuterPartsV2Dto })
|
||||
selectOuter(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOuterPartsV2Dto,
|
||||
@@ -57,7 +58,7 @@ export class RegistrarClaimV1Controller {
|
||||
}
|
||||
|
||||
@Patch("select-other-parts/:claimRequestId")
|
||||
@ApiBody({ type: SelectOtherPartsV2Dto })
|
||||
// @ApiBody({ type: SelectOtherPartsV2Dto })
|
||||
selectOther(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: SelectOtherPartsV2Dto,
|
||||
@@ -91,7 +92,7 @@ export class RegistrarClaimV1Controller {
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@ApiOperation({ summary: "Registrar uploads required claim document" })
|
||||
// @ApiOperation({ summary: "Registrar uploads required claim document" })
|
||||
uploadDoc(
|
||||
@Param("claimRequestId") claimRequestId: string,
|
||||
@Body() body: UploadRequiredDocumentV2Dto,
|
||||
@@ -108,7 +109,7 @@ export class RegistrarClaimV1Controller {
|
||||
}
|
||||
|
||||
@Post("capture-part/:claimRequestId")
|
||||
@ApiConsumes("multipart/form-data")
|
||||
// @ApiConsumes("multipart/form-data")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", {
|
||||
limits: { fileSize: DEFAULT_MEDIA_MAX_BYTES },
|
||||
|
||||
97
src/client/client-external-inquiries.controller.ts
Normal file
97
src/client/client-external-inquiries.controller.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Put,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { ClientService } from "./client.service";
|
||||
import {
|
||||
ClientExternalInquiriesCatalogDto,
|
||||
ClientExternalInquiriesListDto,
|
||||
ClientExternalInquiriesViewDto,
|
||||
ExternalInquiryFlagsDto,
|
||||
UpdateClientExternalInquiriesDto,
|
||||
} from "./dto/client-external-inquiries.dto";
|
||||
|
||||
/**
|
||||
* Per-insurer external inquiry toggles (public for now — lock down when super-admin exists).
|
||||
*/
|
||||
@ApiTags("client-external-inquiries")
|
||||
@Controller("client")
|
||||
export class ClientExternalInquiriesController {
|
||||
constructor(private readonly clientService: ClientService) {}
|
||||
|
||||
@Get("external-inquiries/catalog")
|
||||
@ApiOperation({
|
||||
summary: "List supported external inquiry kinds and API paths",
|
||||
description: "No auth (temporary). Describes global master switch + per-client flags.",
|
||||
})
|
||||
@ApiResponse({ status: 200, type: ClientExternalInquiriesCatalogDto })
|
||||
getCatalog(): ClientExternalInquiriesCatalogDto {
|
||||
return this.clientService.getExternalInquiriesCatalog();
|
||||
}
|
||||
|
||||
@Get("external-inquiries")
|
||||
@ApiOperation({
|
||||
summary: "List external inquiry settings for all insurers",
|
||||
description:
|
||||
"No auth (temporary). Returns stored flags and effective live flags (after global master switch).",
|
||||
})
|
||||
@ApiResponse({ status: 200, type: ClientExternalInquiriesListDto })
|
||||
listAll(): Promise<ClientExternalInquiriesListDto> {
|
||||
return this.clientService.listExternalInquirySettings();
|
||||
}
|
||||
|
||||
@Get(":clientId/external-inquiries")
|
||||
@ApiOperation({
|
||||
summary: "Get external inquiry settings for one insurer",
|
||||
description: "No auth (temporary).",
|
||||
})
|
||||
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
|
||||
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
|
||||
getOne(
|
||||
@Param("clientId") clientId: string,
|
||||
): Promise<ClientExternalInquiriesViewDto> {
|
||||
return this.clientService.getExternalInquirySettings(clientId);
|
||||
}
|
||||
|
||||
@Put(":clientId/external-inquiries")
|
||||
@ApiOperation({
|
||||
summary: "Replace external inquiry flags for one insurer",
|
||||
description:
|
||||
"No auth (temporary). Omitted inquiry keys default to `false` (mock). Global master switch still applies at runtime.",
|
||||
})
|
||||
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
|
||||
@ApiBody({ type: ExternalInquiryFlagsDto })
|
||||
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
|
||||
replace(
|
||||
@Param("clientId") clientId: string,
|
||||
@Body() body: ExternalInquiryFlagsDto,
|
||||
): Promise<ClientExternalInquiriesViewDto> {
|
||||
return this.clientService.replaceExternalInquirySettings(clientId, body);
|
||||
}
|
||||
|
||||
@Patch(":clientId/external-inquiries")
|
||||
@ApiOperation({
|
||||
summary: "Partially update external inquiry flags for one insurer",
|
||||
description: "No auth (temporary). Only supplied flags are changed.",
|
||||
})
|
||||
@ApiParam({ name: "clientId", description: "Insurer client Mongo ObjectId" })
|
||||
@ApiBody({ type: UpdateClientExternalInquiriesDto })
|
||||
@ApiResponse({ status: 200, type: ClientExternalInquiriesViewDto })
|
||||
patch(
|
||||
@Param("clientId") clientId: string,
|
||||
@Body() body: UpdateClientExternalInquiriesDto,
|
||||
): Promise<ClientExternalInquiriesViewDto> {
|
||||
return this.clientService.patchExternalInquirySettings(clientId, body);
|
||||
}
|
||||
}
|
||||
@@ -2,38 +2,84 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
import { Roles } from "src/decorators/roles.decorator";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
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";
|
||||
import { ClientDto } from "./dto/create-client.dto";
|
||||
import { SetExternalInquiriesLiveDto } from "./dto/external-inquiries-live.dto";
|
||||
|
||||
@Controller("client")
|
||||
@ApiTags("client-management")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(GlobalGuard, RolesGuard)
|
||||
@Roles(RoleEnum.ADMIN)
|
||||
export class ClientController {
|
||||
constructor(private readonly clientService: ClientService) {}
|
||||
constructor(
|
||||
private readonly clientService: ClientService,
|
||||
private readonly systemSettingsService: SystemSettingsService,
|
||||
) {}
|
||||
|
||||
@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 (super-admin only)",
|
||||
description:
|
||||
"Updates `system_settings.externalApis.sandHubUseLiveApi`. Use the request examples below to switch between live Tejarat/SandHub HTTP and offline mock mode.",
|
||||
})
|
||||
@ApiBody({
|
||||
type: SetExternalInquiriesLiveDto,
|
||||
examples: {
|
||||
enableLive: {
|
||||
summary: "Enable live inquiries",
|
||||
description: "Call real SandHub/Tejarat HTTP APIs.",
|
||||
value: { enabled: true },
|
||||
},
|
||||
disableLive: {
|
||||
summary: "Disable live inquiries (mock mode)",
|
||||
description:
|
||||
"Use mocked inquiry responses; flows continue without external connectivity.",
|
||||
value: { enabled: false },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, type: SystemSettingsResponseDto })
|
||||
async setExternalInquiriesLive(@Body() body?: SetExternalInquiriesLiveDto) {
|
||||
return this.systemSettingsService.updateSettings({
|
||||
externalApis: { sandHubUseLiveApi: body?.enabled === true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { SystemSettingsModule } from "src/system-settings/system-settings.module";
|
||||
import { ClientController } from "./client.controller";
|
||||
import { ClientExternalInquiriesController } from "./client-external-inquiries.controller";
|
||||
import { ClientPanelController } from "./client-panel.controller";
|
||||
import { ClientService } from "./client.service";
|
||||
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
|
||||
import { BranchDbService } from "./entities/db-service/branch.db.service";
|
||||
import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||
import { BranchModel, BranchSchema } from "./entities/schema/branch.schema";
|
||||
@@ -10,6 +13,7 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SystemSettingsModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: ClientModel.name, schema: ClientDbSchema },
|
||||
{
|
||||
@@ -18,8 +22,12 @@ import { ClientDbSchema, ClientModel } from "./entities/schema/client.schema";
|
||||
},
|
||||
]),
|
||||
],
|
||||
controllers: [ClientController, ClientPanelController],
|
||||
providers: [ClientService, ClientDbService, BranchDbService],
|
||||
exports: [ClientService, ClientDbService, BranchDbService],
|
||||
controllers: [
|
||||
ClientController,
|
||||
ClientPanelController,
|
||||
ClientExternalInquiriesController,
|
||||
],
|
||||
providers: [ClientService, ClientDbService, BranchDbService, ExternalInquirySettingsService],
|
||||
exports: [ClientService, ClientDbService, BranchDbService, ExternalInquirySettingsService],
|
||||
})
|
||||
export class ClientModule {}
|
||||
|
||||
@@ -17,6 +17,19 @@ import {
|
||||
} from "src/client/dto/client-settings.dto";
|
||||
import type { ClientMediaLimits } from "./entities/schema/client.schema";
|
||||
import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||
import {
|
||||
ClientExternalInquiriesCatalogDto,
|
||||
ClientExternalInquiriesListDto,
|
||||
ClientExternalInquiriesViewDto,
|
||||
toClientExternalInquiriesView,
|
||||
UpdateClientExternalInquiriesDto,
|
||||
} from "./dto/client-external-inquiries.dto";
|
||||
import {
|
||||
EXTERNAL_INQUIRY_TYPES,
|
||||
mergeExternalInquiryFlags,
|
||||
} from "src/common/types/external-inquiry.types";
|
||||
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
|
||||
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||
|
||||
/**
|
||||
* System-wide default applied when a client document has no
|
||||
@@ -94,9 +107,14 @@ const MEDIA_KINDS: MediaKind[] = ["video", "image", "voice"];
|
||||
|
||||
@Injectable()
|
||||
export class ClientService {
|
||||
constructor(private readonly clientDbService: ClientDbService) {}
|
||||
constructor(
|
||||
private readonly clientDbService: ClientDbService,
|
||||
private readonly externalInquirySettingsService: ExternalInquirySettingsService,
|
||||
private readonly systemSettingsService: SystemSettingsService,
|
||||
) {}
|
||||
async addClient(client: ClientDto): Promise<ClientDtoRs> {
|
||||
try {
|
||||
const smsApiKey = client.property?.smsApiKey?.trim();
|
||||
const newClient = await this.clientDbService.create({
|
||||
clientCode: client.clientCode,
|
||||
|
||||
@@ -112,9 +130,7 @@ export class ClientService {
|
||||
: (client.clientName?.english ?? null),
|
||||
},
|
||||
|
||||
property: {
|
||||
smsApiKey: client.property?.smsApiKey ?? null,
|
||||
},
|
||||
...(smsApiKey ? { property: { smsApiKey } } : {}),
|
||||
|
||||
useExpertMode: client.useExpertMode ?? null,
|
||||
});
|
||||
@@ -142,6 +158,38 @@ export class ClientService {
|
||||
return await this.clientDbService.find({ clientCode: companyCode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a client by insurer company code from an external inquiry.
|
||||
* Creates the client when missing so inquiry flows don't fail on unknown codes.
|
||||
*/
|
||||
async findOrCreateClientByCompanyCode(
|
||||
companyCode: number | string | null | undefined,
|
||||
companyName: string | null | undefined,
|
||||
) {
|
||||
const name = typeof companyName === "string" ? companyName.trim() : "";
|
||||
const code = Number(companyCode);
|
||||
if (!name || !Number.isFinite(code)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let client = await this.clientDbService.find({ clientCode: code });
|
||||
if (client) return client;
|
||||
|
||||
try {
|
||||
await this.addClient({
|
||||
clientName: { persian: name, english: null },
|
||||
clientCode: code,
|
||||
useExpertMode: "legal",
|
||||
});
|
||||
} catch (err) {
|
||||
client = await this.clientDbService.find({ clientCode: code });
|
||||
if (client) return client;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return this.clientDbService.find({ clientCode: code });
|
||||
}
|
||||
|
||||
async getClients(): Promise<ClientDtoRs[]> {
|
||||
const clients = await this.clientDbService.findAll();
|
||||
const show = clients.map((c) => new ClientDtoRs(c));
|
||||
@@ -361,4 +409,74 @@ export class ClientService {
|
||||
|
||||
return this.getPanelSettings(client._id);
|
||||
}
|
||||
|
||||
getExternalInquiriesCatalog(): ClientExternalInquiriesCatalogDto {
|
||||
return {
|
||||
inquiryTypes: [...EXTERNAL_INQUIRY_TYPES],
|
||||
globalSettingPath: "/system-settings (PATCH externalApis.sandHubUseLiveApi)",
|
||||
perClientSettingPath: "/client/{clientId}/external-inquiries",
|
||||
};
|
||||
}
|
||||
|
||||
async listExternalInquirySettings(): Promise<ClientExternalInquiriesListDto> {
|
||||
const global = await this.systemSettingsService.isSandHubLiveEnabled();
|
||||
const clients = await this.clientDbService.findAll();
|
||||
return {
|
||||
items: clients.map((c) => toClientExternalInquiriesView(c, global)),
|
||||
};
|
||||
}
|
||||
|
||||
async getExternalInquirySettings(
|
||||
clientKey: string | Types.ObjectId,
|
||||
): Promise<ClientExternalInquiriesViewDto> {
|
||||
const client = await this.loadClientOrThrow(clientKey);
|
||||
const global = await this.systemSettingsService.isSandHubLiveEnabled();
|
||||
return toClientExternalInquiriesView(client, global);
|
||||
}
|
||||
|
||||
async replaceExternalInquirySettings(
|
||||
clientKey: string | Types.ObjectId,
|
||||
body: UpdateClientExternalInquiriesDto,
|
||||
): Promise<ClientExternalInquiriesViewDto> {
|
||||
const client = await this.loadClientOrThrow(clientKey);
|
||||
const flags = mergeExternalInquiryFlags(body as Partial<Record<string, boolean>>);
|
||||
const $set: Record<string, unknown> = {};
|
||||
for (const key of EXTERNAL_INQUIRY_TYPES) {
|
||||
$set[`settings.externalInquiries.${key}`] = flags[key];
|
||||
}
|
||||
const updated = await this.clientDbService.findByIdAndUpdate(
|
||||
client._id.toString(),
|
||||
{ $set },
|
||||
);
|
||||
if (!updated) throw new NotFoundException("Client not found");
|
||||
this.externalInquirySettingsService.invalidateClientCache(
|
||||
client._id.toString(),
|
||||
);
|
||||
return this.getExternalInquirySettings(client._id);
|
||||
}
|
||||
|
||||
async patchExternalInquirySettings(
|
||||
clientKey: string | Types.ObjectId,
|
||||
body: UpdateClientExternalInquiriesDto,
|
||||
): Promise<ClientExternalInquiriesViewDto> {
|
||||
const client = await this.loadClientOrThrow(clientKey);
|
||||
const $set: Record<string, unknown> = {};
|
||||
for (const key of EXTERNAL_INQUIRY_TYPES) {
|
||||
if (body[key] !== undefined) {
|
||||
$set[`settings.externalInquiries.${key}`] = body[key];
|
||||
}
|
||||
}
|
||||
if (Object.keys($set).length === 0) {
|
||||
throw new BadRequestException("No external inquiry flags to update.");
|
||||
}
|
||||
const updated = await this.clientDbService.findByIdAndUpdate(
|
||||
client._id.toString(),
|
||||
{ $set },
|
||||
);
|
||||
if (!updated) throw new NotFoundException("Client not found");
|
||||
this.externalInquirySettingsService.invalidateClientCache(
|
||||
client._id.toString(),
|
||||
);
|
||||
return this.getExternalInquirySettings(client._id);
|
||||
}
|
||||
}
|
||||
|
||||
162
src/client/dto/client-external-inquiries.dto.ts
Normal file
162
src/client/dto/client-external-inquiries.dto.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
|
||||
import {
|
||||
EXTERNAL_INQUIRY_TYPES,
|
||||
ExternalInquiryFlags,
|
||||
mergeExternalInquiryFlags,
|
||||
} from "src/common/types/external-inquiry.types";
|
||||
import { IsBoolean, IsMongoId, IsOptional, ValidateNested } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
export class ExternalInquiryFlagsDto implements ExternalInquiryFlags {
|
||||
@ApiProperty({
|
||||
description: "Third-party plate / policy block inquiry (Tejarat or ESG policyByPlate).",
|
||||
example: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
thirdPartyPlate: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: "CAR_BODY (badane) plate inquiry.",
|
||||
example: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
carBodyPlate: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Personal identity inquiry (national code + birth date).",
|
||||
example: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
personalIdentity: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Sheba account validation.",
|
||||
example: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
sheba: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Driving licence check.",
|
||||
example: false,
|
||||
})
|
||||
@IsBoolean()
|
||||
drivingLicense: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Vehicle ownership validation.",
|
||||
example: false,
|
||||
})
|
||||
@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(
|
||||
ExternalInquiryFlagsDto,
|
||||
) {}
|
||||
|
||||
export class ClientExternalInquiriesViewDto {
|
||||
@ApiProperty({ example: "664a1b2c3d4e5f6789012345" })
|
||||
clientId: string;
|
||||
|
||||
@ApiProperty({ example: 8 })
|
||||
clientCode: number;
|
||||
|
||||
@ApiProperty({ example: "بیمه پارسیان" })
|
||||
clientName: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Global master switch from `system_settings.externalApis.sandHubUseLiveApi`. When false, all inquiries use mocks regardless of these flags.",
|
||||
})
|
||||
globalSandHubLiveEnabled: boolean;
|
||||
|
||||
@ApiProperty({ type: ExternalInquiryFlagsDto })
|
||||
externalInquiries: ExternalInquiryFlags;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Effective live flags after applying the global master switch.",
|
||||
type: ExternalInquiryFlagsDto,
|
||||
})
|
||||
effectiveLive: ExternalInquiryFlags;
|
||||
}
|
||||
|
||||
export class ClientExternalInquiriesListDto {
|
||||
@ApiProperty({ type: [ClientExternalInquiriesViewDto] })
|
||||
items: ClientExternalInquiriesViewDto[];
|
||||
}
|
||||
|
||||
export class ClientExternalInquiriesCatalogDto {
|
||||
@ApiProperty({
|
||||
enum: EXTERNAL_INQUIRY_TYPES,
|
||||
isArray: true,
|
||||
description: "Supported inquiry kinds stored on each client document.",
|
||||
})
|
||||
inquiryTypes: readonly string[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Global master switch path: PATCH /system-settings or PATCH /client/external-inquiries-live",
|
||||
})
|
||||
globalSettingPath: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Per-insurer CRUD base path (public for now).",
|
||||
example: "/client/{clientId}/external-inquiries",
|
||||
})
|
||||
perClientSettingPath: string;
|
||||
}
|
||||
|
||||
/** Build a view DTO from a lean client document. */
|
||||
export function toClientExternalInquiriesView(
|
||||
client: {
|
||||
_id?: unknown;
|
||||
clientCode?: number;
|
||||
clientName?: { persian?: string; english?: string } | string;
|
||||
settings?: { externalInquiries?: Partial<ExternalInquiryFlags> };
|
||||
},
|
||||
globalSandHubLiveEnabled: boolean,
|
||||
): ClientExternalInquiriesViewDto {
|
||||
const flags = mergeExternalInquiryFlags(client.settings?.externalInquiries);
|
||||
const effectiveLive = {} as ExternalInquiryFlags;
|
||||
for (const key of EXTERNAL_INQUIRY_TYPES) {
|
||||
effectiveLive[key] = globalSandHubLiveEnabled && flags[key] === true;
|
||||
}
|
||||
const name =
|
||||
typeof client.clientName === "string"
|
||||
? client.clientName
|
||||
: client.clientName?.persian ||
|
||||
client.clientName?.english ||
|
||||
String(client.clientCode ?? "");
|
||||
|
||||
return {
|
||||
clientId: String(client._id ?? ""),
|
||||
clientCode: Number(client.clientCode ?? 0),
|
||||
clientName: name,
|
||||
globalSandHubLiveEnabled,
|
||||
externalInquiries: flags,
|
||||
effectiveLive,
|
||||
};
|
||||
}
|
||||
|
||||
export class ClientIdParamDto {
|
||||
@ApiProperty({ description: "Mongo ObjectId of the insurer client document" })
|
||||
@IsMongoId()
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
/** Optional nested patch used internally when validating partial bodies. */
|
||||
export class ExternalInquiryFlagsPatchDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => UpdateClientExternalInquiriesDto)
|
||||
externalInquiries?: UpdateClientExternalInquiriesDto;
|
||||
}
|
||||
@@ -23,8 +23,9 @@ class ClientName {
|
||||
}
|
||||
class Property {
|
||||
@ApiPropertyOptional({})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
smsApiKey: string;
|
||||
smsApiKey?: string;
|
||||
}
|
||||
|
||||
export class ClientDto {
|
||||
|
||||
10
src/client/dto/external-inquiries-live.dto.ts
Normal file
10
src/client/dto/external-inquiries-live.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class SetExternalInquiriesLiveDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"When true, SandHub/Tejarat live HTTP inquiries run. When false, mocked inquiry data is used.",
|
||||
example: true,
|
||||
})
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -1,15 +1,49 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||
import { InjectModel } from "@nestjs/mongoose";
|
||||
import { FilterQuery, Model, UpdateQuery } from "mongoose";
|
||||
import { ClientModel, ClientDocument } from "../schema/client.schema";
|
||||
|
||||
@Injectable()
|
||||
export class ClientDbService {
|
||||
export class ClientDbService implements OnModuleInit {
|
||||
private readonly logger = new Logger(ClientDbService.name);
|
||||
|
||||
constructor(
|
||||
@InjectModel(ClientModel.name)
|
||||
private readonly clientModel: Model<ClientModel>,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.dropLegacyPropertyUniqueIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* `property` was once declared `unique: true` in the schema. Production keeps
|
||||
* `autoIndex: false`, so the stale unique index remained and rejected a second
|
||||
* client with a missing/null SMS key (E11000 duplicate key).
|
||||
*/
|
||||
private async dropLegacyPropertyUniqueIndex(): Promise<void> {
|
||||
try {
|
||||
const indexes = await this.clientModel.collection.indexes();
|
||||
for (const index of indexes) {
|
||||
if (!index.unique || !index.name || index.name === "_id_") continue;
|
||||
|
||||
const keys = Object.keys(index.key ?? {});
|
||||
const isLegacyPropertyIndex =
|
||||
keys.length === 1 &&
|
||||
(keys[0] === "property" || keys[0] === "property.smsApiKey");
|
||||
|
||||
if (!isLegacyPropertyIndex) continue;
|
||||
|
||||
await this.clientModel.collection.dropIndex(index.name);
|
||||
this.logger.warn(
|
||||
`Dropped legacy unique index on clients.${keys[0]}: ${index.name}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error("Failed to drop legacy client property index", err);
|
||||
}
|
||||
}
|
||||
|
||||
async create(client: ClientModel): Promise<ClientModel> {
|
||||
return await this.clientModel.create(client);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import type { ExternalInquiryFlags } from "src/common/types/external-inquiry.types";
|
||||
|
||||
export type ClientDocument = ClientModel & Document;
|
||||
|
||||
@@ -37,6 +38,30 @@ export class ClientMediaSettings {
|
||||
voice?: ClientMediaLimits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-insurer toggles for outbound inquiry HTTP. Each flag is AND-ed with the
|
||||
* global `system_settings.externalApis.sandHubUseLiveApi` master switch.
|
||||
*/
|
||||
export class ClientExternalInquirySettings implements Partial<ExternalInquiryFlags> {
|
||||
@Prop({ type: Boolean, required: false, default: false })
|
||||
thirdPartyPlate?: boolean;
|
||||
|
||||
@Prop({ type: Boolean, required: false, default: false })
|
||||
carBodyPlate?: boolean;
|
||||
|
||||
@Prop({ type: Boolean, required: false, default: false })
|
||||
personalIdentity?: boolean;
|
||||
|
||||
@Prop({ type: Boolean, required: false, default: false })
|
||||
sheba?: boolean;
|
||||
|
||||
@Prop({ type: Boolean, required: false, default: false })
|
||||
drivingLicense?: boolean;
|
||||
|
||||
@Prop({ type: Boolean, required: false, default: false })
|
||||
carOwnership?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tenant tunables. Add new policy fields here; consumers read them via
|
||||
* `ClientService` with documented defaults so older client documents keep
|
||||
@@ -57,6 +82,10 @@ export class ClientSettings {
|
||||
*/
|
||||
@Prop({ type: ClientMediaSettings, required: false })
|
||||
media?: ClientMediaSettings;
|
||||
|
||||
/** Per-inquiry live/mock toggles (see {@link ClientExternalInquirySettings}). */
|
||||
@Prop({ type: ClientExternalInquirySettings, required: false })
|
||||
externalInquiries?: ClientExternalInquirySettings;
|
||||
}
|
||||
|
||||
@Schema({ collection: "clients", versionKey: false })
|
||||
@@ -67,9 +96,9 @@ export class ClientModel {
|
||||
english: string;
|
||||
};
|
||||
|
||||
@Prop({ required: false, unique: true, type: Object })
|
||||
property: {
|
||||
smsApiKey: string;
|
||||
@Prop({ required: false, type: Object })
|
||||
property?: {
|
||||
smsApiKey?: string;
|
||||
};
|
||||
|
||||
@Prop({ required: true, unique: false })
|
||||
|
||||
83
src/client/external-inquiry-settings.service.spec.ts
Normal file
83
src/client/external-inquiry-settings.service.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Types } from "mongoose";
|
||||
import { ExternalInquirySettingsService } from "./external-inquiry-settings.service";
|
||||
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||
|
||||
describe("ExternalInquirySettingsService", () => {
|
||||
const systemSettings = {
|
||||
isSandHubLiveEnabled: jest.fn(),
|
||||
};
|
||||
const clientDb = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
let service: ExternalInquirySettingsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env.CLIENT_ID;
|
||||
delete process.env.CLIENT_NAME;
|
||||
service = new ExternalInquirySettingsService(
|
||||
systemSettings as unknown as SystemSettingsService,
|
||||
clientDb as any,
|
||||
);
|
||||
service.invalidateClientCache();
|
||||
});
|
||||
|
||||
it("returns false when global master switch is off", async () => {
|
||||
systemSettings.isSandHubLiveEnabled.mockResolvedValue(false);
|
||||
clientDb.findOne.mockResolvedValue({
|
||||
clientCode: 8,
|
||||
settings: { externalInquiries: { sheba: true } },
|
||||
});
|
||||
|
||||
await expect(service.isInquiryLive("sheba", "client-id")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true only when global and per-insurer flags are on", async () => {
|
||||
const clientId = new Types.ObjectId().toString();
|
||||
systemSettings.isSandHubLiveEnabled.mockResolvedValue(true);
|
||||
clientDb.findOne.mockResolvedValue({
|
||||
clientCode: 8,
|
||||
clientName: { persian: "بیمه پارسیان" },
|
||||
settings: {
|
||||
externalInquiries: {
|
||||
thirdPartyPlate: true,
|
||||
sheba: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.isInquiryLive("thirdPartyPlate", clientId),
|
||||
).resolves.toBe(true);
|
||||
await expect(service.isInquiryLive("sheba", clientId)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses client document for mock company context", async () => {
|
||||
const clientId = new Types.ObjectId().toString();
|
||||
clientDb.findOne.mockResolvedValue({
|
||||
clientCode: 8,
|
||||
clientName: { persian: "بیمه پارسیان" },
|
||||
});
|
||||
|
||||
await expect(service.getMockCompanyContext(clientId)).resolves.toEqual({
|
||||
companyId: "8",
|
||||
companyName: "بیمه پارسیان",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to deployment env when client is missing", async () => {
|
||||
process.env.CLIENT_ID = "15";
|
||||
process.env.CLIENT_NAME = "بیمه سامان";
|
||||
clientDb.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getMockCompanyContext()).resolves.toEqual({
|
||||
companyId: "15",
|
||||
companyName: "بیمه سامان",
|
||||
});
|
||||
});
|
||||
});
|
||||
120
src/client/external-inquiry-settings.service.ts
Normal file
120
src/client/external-inquiry-settings.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
DEFAULT_EXTERNAL_INQUIRY_FLAGS,
|
||||
ExternalInquiryFlags,
|
||||
ExternalInquiryType,
|
||||
mergeExternalInquiryFlags,
|
||||
MockInquiryCompanyContext,
|
||||
} from "src/common/types/external-inquiry.types";
|
||||
import { SystemSettingsService } from "src/system-settings/system-settings.service";
|
||||
import { ClientDbService } from "./entities/db-service/client.db.service";
|
||||
|
||||
@Injectable()
|
||||
export class ExternalInquirySettingsService {
|
||||
private readonly logger = new Logger(ExternalInquirySettingsService.name);
|
||||
private clientCache = new Map<string, { doc: any; at: number }>();
|
||||
private readonly cacheTtlMs = 15_000;
|
||||
|
||||
constructor(
|
||||
private readonly systemSettingsService: SystemSettingsService,
|
||||
private readonly clientDbService: ClientDbService,
|
||||
) {}
|
||||
|
||||
private cacheKey(ref?: string | Types.ObjectId | null): string {
|
||||
if (ref != null && String(ref).trim()) return `id:${String(ref)}`;
|
||||
const code = process.env.CLIENT_ID;
|
||||
return code ? `env:${code}` : "env:default";
|
||||
}
|
||||
|
||||
private async loadClient(
|
||||
clientRef?: string | Types.ObjectId | null,
|
||||
): Promise<any | null> {
|
||||
const key = this.cacheKey(clientRef);
|
||||
const cached = this.clientCache.get(key);
|
||||
const now = Date.now();
|
||||
if (cached && now - cached.at < this.cacheTtlMs) {
|
||||
return cached.doc;
|
||||
}
|
||||
|
||||
let doc: any = null;
|
||||
if (clientRef != null && Types.ObjectId.isValid(String(clientRef))) {
|
||||
doc = await this.clientDbService.findOne({
|
||||
_id: new Types.ObjectId(String(clientRef)),
|
||||
});
|
||||
}
|
||||
if (!doc) {
|
||||
const code = Number(process.env.CLIENT_ID);
|
||||
if (Number.isFinite(code)) {
|
||||
doc = await this.clientDbService.findOne({ clientCode: code });
|
||||
}
|
||||
}
|
||||
|
||||
this.clientCache.set(key, { doc, at: now });
|
||||
return doc;
|
||||
}
|
||||
|
||||
invalidateClientCache(clientId?: string): void {
|
||||
if (clientId) {
|
||||
this.clientCache.delete(`id:${clientId}`);
|
||||
} else {
|
||||
this.clientCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async getFlagsForClient(
|
||||
clientRef?: string | Types.ObjectId | null,
|
||||
): Promise<ExternalInquiryFlags> {
|
||||
const client = await this.loadClient(clientRef);
|
||||
return mergeExternalInquiryFlags(client?.settings?.externalInquiries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live HTTP when global master switch is on AND the per-insurer flag is true.
|
||||
*/
|
||||
async isInquiryLive(
|
||||
type: ExternalInquiryType,
|
||||
clientRef?: string | Types.ObjectId | null,
|
||||
): Promise<boolean> {
|
||||
if (!(await this.systemSettingsService.isSandHubLiveEnabled())) {
|
||||
return false;
|
||||
}
|
||||
const flags = await this.getFlagsForClient(clientRef);
|
||||
return flags[type] === true;
|
||||
}
|
||||
|
||||
/** Company fields injected into mocked plate/car-body inquiry payloads. */
|
||||
async getMockCompanyContext(
|
||||
clientRef?: string | Types.ObjectId | null,
|
||||
): Promise<MockInquiryCompanyContext> {
|
||||
const client = await this.loadClient(clientRef);
|
||||
if (client) {
|
||||
const name =
|
||||
typeof client.clientName === "string"
|
||||
? client.clientName
|
||||
: client.clientName?.persian ||
|
||||
client.clientName?.english ||
|
||||
"";
|
||||
return {
|
||||
companyId: String(client.clientCode ?? process.env.CLIENT_ID ?? "15"),
|
||||
companyName: name || process.env.CLIENT_NAME || "بیمه",
|
||||
};
|
||||
}
|
||||
return {
|
||||
companyId: String(process.env.CLIENT_ID ?? "15"),
|
||||
companyName: process.env.CLIENT_NAME ?? "بیمه سامان",
|
||||
};
|
||||
}
|
||||
|
||||
async getEffectiveLiveFlags(
|
||||
clientRef?: string | Types.ObjectId | null,
|
||||
): Promise<ExternalInquiryFlags> {
|
||||
const global = await this.systemSettingsService.isSandHubLiveEnabled();
|
||||
const flags = await this.getFlagsForClient(clientRef);
|
||||
const effective = { ...DEFAULT_EXTERNAL_INQUIRY_FLAGS };
|
||||
for (const key of Object.keys(effective) as ExternalInquiryType[]) {
|
||||
effective[key] = global && flags[key] === true;
|
||||
}
|
||||
return effective;
|
||||
}
|
||||
}
|
||||
18
src/common/auth/constants/hash.constants.ts
Normal file
18
src/common/auth/constants/hash.constants.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ScryptOptions } from "node:crypto";
|
||||
|
||||
export const CURRENT_ALGORITHM = "scrypt";
|
||||
export const CURRENT_VERSION = 1; // 0 = legacy salt:hash ; 1 = scrypt with different salt and hash and differnet format of salt:hash!
|
||||
|
||||
export const KEY_LENGTH = 64;
|
||||
export const SCRYPT_PARAMS: ScryptOptions = {
|
||||
N: 19456, // CPU/memory cost
|
||||
r: 8, // block size
|
||||
p: 1, // parallelization
|
||||
};
|
||||
|
||||
export const KEY_LENGTH_FOR_OTP = 32;
|
||||
export const SCRYPT_PARAMS_FOR_OTP: ScryptOptions = {
|
||||
N: 1024,
|
||||
r: 8,
|
||||
p: 1,
|
||||
};
|
||||
8
src/common/auth/constants/index.ts
Normal file
8
src/common/auth/constants/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
CURRENT_ALGORITHM,
|
||||
CURRENT_VERSION,
|
||||
KEY_LENGTH,
|
||||
KEY_LENGTH_FOR_OTP,
|
||||
SCRYPT_PARAMS,
|
||||
SCRYPT_PARAMS_FOR_OTP,
|
||||
} from "./hash.constants";
|
||||
2
src/common/auth/decorators/index.ts
Normal file
2
src/common/auth/decorators/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { IS_PUBLIC_KEY, Public } from "./public.decorator";
|
||||
export { ROLES_KEY, Roles } from "./roles.decorator";
|
||||
4
src/common/auth/decorators/public.decorator.ts
Normal file
4
src/common/auth/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const IS_PUBLIC_KEY = "isPublic";
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
5
src/common/auth/decorators/roles.decorator.ts
Normal file
5
src/common/auth/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
import { Role } from "../enums/roles.enum";
|
||||
|
||||
export const ROLES_KEY = "roles";
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
1
src/common/auth/enums/index.ts
Normal file
1
src/common/auth/enums/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Role } from "./roles.enum";
|
||||
9
src/common/auth/enums/roles.enum.ts
Normal file
9
src/common/auth/enums/roles.enum.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export enum Role {
|
||||
SUPER_ADMIN = "super_admin",
|
||||
INSURER = "insurer",
|
||||
ACCIDENT_EXPERT = "accident_expert",
|
||||
CLAIM_EXPERT = "claim_expert",
|
||||
FIELD_EXPERT = "field_expert",
|
||||
REGISTRAR = "registrar",
|
||||
USER = "user",
|
||||
}
|
||||
37
src/common/auth/guards/auth.guard.ts
Normal file
37
src/common/auth/guards/auth.guard.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Request } from "express";
|
||||
import { JwtPayload } from "../types/payload.types";
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private readonly jwtService: JwtService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException("No token provided");
|
||||
}
|
||||
try {
|
||||
const payload: JwtPayload =
|
||||
await this.jwtService.verifyAsync<JwtPayload>(token, {
|
||||
secret: `${process.env.JWT_SECRET}`,
|
||||
});
|
||||
request["user"] = payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException("Invalid or expired token");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(" ") ?? [];
|
||||
return type === "Bearer" ? token : undefined;
|
||||
}
|
||||
}
|
||||
2
src/common/auth/guards/index.ts
Normal file
2
src/common/auth/guards/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AuthGuard } from "./auth.guard";
|
||||
export { RolesGuard } from "./role.guard";
|
||||
47
src/common/auth/guards/role.guard.ts
Normal file
47
src/common/auth/guards/role.guard.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
import { IS_PUBLIC_KEY, ROLES_KEY } from "../decorators";
|
||||
import { Role } from "../enums";
|
||||
import { JwtPayload } from "../types";
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user: JwtPayload = request["user"] as JwtPayload | undefined;
|
||||
|
||||
if (!user?.role) {
|
||||
throw new ForbiddenException("No role found in token");
|
||||
}
|
||||
|
||||
const hasRole = requiredRoles.includes(user.role as Role);
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException(
|
||||
`Access denied. Required: [${requiredRoles.join(", ")}]. Your role: ${user.role}`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
2
src/common/auth/types/index.ts
Normal file
2
src/common/auth/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { JwtPayload } from "./payload.types";
|
||||
export { PasswordHash } from "./password.types";
|
||||
8
src/common/auth/types/password.types.ts
Normal file
8
src/common/auth/types/password.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface PasswordHash {
|
||||
hash: string;
|
||||
salt: string;
|
||||
algorithm: string;
|
||||
version: number;
|
||||
params: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
}
|
||||
6
src/common/auth/types/payload.types.ts
Normal file
6
src/common/auth/types/payload.types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
role: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
@@ -3,14 +3,17 @@ import { Type } from "class-transformer";
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from "class-validator";
|
||||
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
|
||||
import { UNIFIED_FILE_STATUS_KEYS } from "src/helpers/unified-file-status";
|
||||
|
||||
/** Allowed sort keys for V2 list endpoints (claims / blame). */
|
||||
/** Allowed sort keys for V2 list endpoints (claims / blame / insurer files). */
|
||||
export const LIST_SORT_BY_V2 = [
|
||||
"publicId",
|
||||
"createdAt",
|
||||
@@ -20,9 +23,16 @@ export const LIST_SORT_BY_V2 = [
|
||||
|
||||
export type ListSortByV2 = (typeof LIST_SORT_BY_V2)[number];
|
||||
|
||||
export const LIST_FILE_TYPE_V2 = [
|
||||
BlameRequestType.THIRD_PARTY,
|
||||
BlameRequestType.CAR_BODY,
|
||||
] as const;
|
||||
|
||||
export type ListFileTypeV2 = (typeof LIST_FILE_TYPE_V2)[number];
|
||||
|
||||
/**
|
||||
* Optional query params for V2 lists. If neither `page` nor `limit` is sent,
|
||||
* the full filtered+sorted list is returned (backward compatible).
|
||||
* Optional query params for V2 expert/insurer file lists.
|
||||
* If neither `page` nor `limit` is sent, the full filtered+sorted list is returned.
|
||||
*/
|
||||
export class ListQueryV2Dto {
|
||||
@ApiPropertyOptional({
|
||||
@@ -67,7 +77,7 @@ export class ListQueryV2Dto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Case-insensitive substring match on `publicId` and `requestNo` (and other endpoint-specific fields).",
|
||||
"Case-insensitive substring match on `publicId`, `requestNo`, status, and other endpoint-specific fields.",
|
||||
example: "A142",
|
||||
maxLength: 64,
|
||||
})
|
||||
@@ -75,4 +85,38 @@ export class ListQueryV2Dto {
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: UNIFIED_FILE_STATUS_KEYS,
|
||||
description:
|
||||
"Filter by calculated unified file status (blame + claim combined).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...UNIFIED_FILE_STATUS_KEYS])
|
||||
unifiedStatus?: (typeof UNIFIED_FILE_STATUS_KEYS)[number];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: LIST_FILE_TYPE_V2,
|
||||
description:
|
||||
"Filter by blame file type: third-party liability (`THIRD_PARTY`) or car body (`CAR_BODY`).",
|
||||
})
|
||||
@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;
|
||||
}
|
||||
|
||||
68
src/common/dto/unified-file-status-report.dto.ts
Normal file
68
src/common/dto/unified-file-status-report.dto.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsISO8601, IsOptional } from "class-validator";
|
||||
import {
|
||||
LIST_FILE_TYPE_V2,
|
||||
ListFileTypeV2,
|
||||
} from "src/common/dto/list-query-v2.dto";
|
||||
import {
|
||||
UNIFIED_FILE_STATUS_KEYS,
|
||||
UnifiedFileStatusDefinition,
|
||||
UnifiedFileStatusKey,
|
||||
} from "src/helpers/unified-file-status";
|
||||
|
||||
export class UnifiedFileStatusReportQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Optional start datetime (ISO 8601) for portfolio date filter.",
|
||||
example: "2026-01-01T00:00:00.000Z",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: false })
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Optional end datetime (ISO 8601) for portfolio date filter.",
|
||||
example: "2026-12-31T23:59:59.999Z",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: false })
|
||||
to?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: LIST_FILE_TYPE_V2,
|
||||
description:
|
||||
"Count only files of this blame type (`THIRD_PARTY` or `CAR_BODY`).",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...LIST_FILE_TYPE_V2])
|
||||
fileType?: ListFileTypeV2;
|
||||
}
|
||||
|
||||
export class UnifiedFileStatusDefinitionDto implements UnifiedFileStatusDefinition {
|
||||
@ApiProperty({ enum: UNIFIED_FILE_STATUS_KEYS })
|
||||
key: UnifiedFileStatusKey;
|
||||
|
||||
@ApiProperty()
|
||||
labelEn: string;
|
||||
|
||||
@ApiProperty()
|
||||
labelFa: string;
|
||||
|
||||
@ApiProperty()
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class UnifiedFileStatusReportDto {
|
||||
@ApiProperty({ type: [UnifiedFileStatusDefinitionDto] })
|
||||
statuses: UnifiedFileStatusDefinitionDto[];
|
||||
|
||||
@ApiProperty({
|
||||
description: "Counts per unified status key plus `all`",
|
||||
example: {
|
||||
all: 42,
|
||||
IN_PROGRESS: 10,
|
||||
WAITING_FOR_DAMAGE_EXPERT: 5,
|
||||
COMPLETED: 12,
|
||||
},
|
||||
})
|
||||
counts: Record<string, number>;
|
||||
}
|
||||
39
src/common/types/external-inquiry.types.ts
Normal file
39
src/common/types/external-inquiry.types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/** External inquiry kinds toggled per insurer (`clients.settings.externalInquiries`). */
|
||||
export const EXTERNAL_INQUIRY_TYPES = [
|
||||
"thirdPartyPlate",
|
||||
"carBodyPlate",
|
||||
"personalIdentity",
|
||||
"sheba",
|
||||
"drivingLicense",
|
||||
"carOwnership",
|
||||
"vinChassis",
|
||||
] as const;
|
||||
|
||||
export type ExternalInquiryType = (typeof EXTERNAL_INQUIRY_TYPES)[number];
|
||||
|
||||
/** Safe default: mock/off until explicitly enabled for an insurer. */
|
||||
export const DEFAULT_EXTERNAL_INQUIRY_FLAGS: Record<
|
||||
ExternalInquiryType,
|
||||
boolean
|
||||
> = {
|
||||
thirdPartyPlate: false,
|
||||
carBodyPlate: false,
|
||||
personalIdentity: false,
|
||||
sheba: false,
|
||||
drivingLicense: false,
|
||||
carOwnership: false,
|
||||
vinChassis: false,
|
||||
};
|
||||
|
||||
export type ExternalInquiryFlags = Record<ExternalInquiryType, boolean>;
|
||||
|
||||
export interface MockInquiryCompanyContext {
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
export function mergeExternalInquiryFlags(
|
||||
partial?: Partial<ExternalInquiryFlags> | null,
|
||||
): ExternalInquiryFlags {
|
||||
return { ...DEFAULT_EXTERNAL_INQUIRY_FLAGS, ...(partial ?? {}) };
|
||||
}
|
||||
@@ -10,3 +10,27 @@ export const REPAIR_LINE_AMOUNT_TOMAN = {
|
||||
|
||||
/** Max sum of all priced + factor lines in one expert reply / validation (Toman). */
|
||||
export const CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN = REPAIR_LINE_AMOUNT_TOMAN.MAX;
|
||||
|
||||
const ENABLED_VALUES = new Set(["1", "true", "yes", "on", "enabled"]);
|
||||
|
||||
/**
|
||||
* Returns null when the claim v2 total cap is disabled.
|
||||
*
|
||||
* Set CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED=true to enforce the cap again.
|
||||
* Optionally set CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN to override the amount.
|
||||
*/
|
||||
export function getClaimV2TotalPaymentCapToman(): number | null {
|
||||
const capEnabled = ENABLED_VALUES.has(
|
||||
String(process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED ?? "")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!capEnabled) return null;
|
||||
|
||||
const configuredCap = Number(process.env.CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN);
|
||||
|
||||
return Number.isFinite(configuredCap) && configuredCap > 0
|
||||
? configuredCap
|
||||
: CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN;
|
||||
}
|
||||
|
||||
14
src/core/config/config.module.ts
Normal file
14
src/core/config/config.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { validate } from "./config.validation";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
expandVariables: true,
|
||||
validate,
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppConfigModule {}
|
||||
111
src/core/config/config.schema.ts
Normal file
111
src/core/config/config.schema.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
IsBooleanString,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { StringValue } from "ms";
|
||||
|
||||
export enum Environment {
|
||||
Development = "development",
|
||||
Production = "production",
|
||||
}
|
||||
|
||||
export class EnvironmentVariables {
|
||||
@IsEnum(Environment, {
|
||||
message: "NODE_ENV should be development or production",
|
||||
})
|
||||
NODE_ENV: Environment;
|
||||
|
||||
// --------------------------------------------------------- //
|
||||
|
||||
@IsNumber(
|
||||
{
|
||||
allowNaN: false,
|
||||
allowInfinity: false,
|
||||
},
|
||||
{
|
||||
message: "PORT must be a valid number",
|
||||
},
|
||||
)
|
||||
@IsPositive({ message: "PORT must be positive" })
|
||||
@Min(0, { message: "PORT must be between 0 and 65535" })
|
||||
@Max(65535, { message: "PORT must be between 0 and 65535" })
|
||||
PORT: number;
|
||||
|
||||
// --------------------------------------------------------- //
|
||||
|
||||
@IsString({ message: "MONGO_URI must be string" })
|
||||
@IsUrl(
|
||||
{
|
||||
require_tld: false,
|
||||
protocols: ["mongodb", "mongodb+srv"],
|
||||
},
|
||||
{
|
||||
message: "MONGO_URI must be in the correct format (mongodb://...)",
|
||||
},
|
||||
)
|
||||
MONGO_URI: string;
|
||||
|
||||
// --------------------------------------------------------- //
|
||||
|
||||
@IsBooleanString({ message: "MONGO_TLS must be boolean" })
|
||||
MONGO_TLS: string;
|
||||
|
||||
// --------------------------------------------------------- //
|
||||
|
||||
@IsBooleanString({ message: "MONGO_TLS_ALLOW_INVALID_CERTS must be boolean" })
|
||||
MONGO_TLS_ALLOW_INVALID_CERTS: string;
|
||||
|
||||
// --------------------------------------------------------- //
|
||||
|
||||
@IsString({ message: "JWT_SECRET must be string" })
|
||||
@MinLength(32, {
|
||||
message: "JWT_SECRET must be at least 32 characters",
|
||||
})
|
||||
JWT_SECRET: string;
|
||||
|
||||
// --------------------------------------------------------- //
|
||||
|
||||
@Matches(/^\d+(ms|s|m|h|d|w|y)$/, {
|
||||
message: "JWT_EXPIRY must be in the correct format",
|
||||
})
|
||||
JWT_EXPIRY: StringValue;
|
||||
|
||||
// --------------------------------------------------------- //
|
||||
|
||||
@IsString({ message: "HASH_PEPPER must be string" })
|
||||
@IsOptional()
|
||||
HASH_PEPPER?: string;
|
||||
|
||||
// --------------------------------------------------------- //
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED must be string" })
|
||||
CLAIM_V2_TOTAL_PAYMENT_CAP_ENABLED?: string;
|
||||
|
||||
// --------------------------------------------------------- //
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber(
|
||||
{
|
||||
allowNaN: false,
|
||||
allowInfinity: false,
|
||||
},
|
||||
{
|
||||
message: "CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN must be a valid number",
|
||||
},
|
||||
)
|
||||
@IsPositive({
|
||||
message: "CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN must be a positive number",
|
||||
})
|
||||
CLAIM_V2_TOTAL_PAYMENT_CAP_TOMAN?: number;
|
||||
// --------------------------------------------------------- //
|
||||
}
|
||||
23
src/core/config/config.validation.ts
Normal file
23
src/core/config/config.validation.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { validateSync } from "class-validator";
|
||||
import { EnvironmentVariables } from "./config.schema";
|
||||
|
||||
export function validate(config: Record<string, unknown>) {
|
||||
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
|
||||
const errors = validateSync(validatedConfig, {
|
||||
skipMissingProperties: false,
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
const messages = errors
|
||||
.flatMap((error) => Object.values(error.constraints ?? {}))
|
||||
.join("\n");
|
||||
|
||||
throw new Error(`Environment validation failed:\n${messages}`);
|
||||
}
|
||||
|
||||
return validatedConfig;
|
||||
}
|
||||
168
src/core/config/fanavaran-client.config.ts
Normal file
168
src/core/config/fanavaran-client.config.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export type FanavaranClientKey = "parsian" | "tejaratno";
|
||||
|
||||
export const FANAVARAN_CLIENT_KEYS: readonly FanavaranClientKey[] = [
|
||||
"parsian",
|
||||
"tejaratno",
|
||||
] as const;
|
||||
|
||||
export function isFanavaranClientKey(
|
||||
value: string,
|
||||
): value is FanavaranClientKey {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "parsian" || normalized === "tejaratno";
|
||||
}
|
||||
|
||||
export function normalizeFanavaranClientKey(value: string): FanavaranClientKey {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "parsian" || normalized === "tejaratno") {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
`Invalid Fanavaran client "${value}". Expected one of: ${FANAVARAN_CLIENT_KEYS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export interface FanavaranAuthConfig {
|
||||
appName: string;
|
||||
secret: string;
|
||||
username: string;
|
||||
password: string;
|
||||
corpId: string;
|
||||
contractId: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface FanavaranPayloadDefaults {
|
||||
AccidentCityId: number;
|
||||
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 {
|
||||
key: FanavaranClientKey;
|
||||
auth: FanavaranAuthConfig;
|
||||
defaults: FanavaranPayloadDefaults;
|
||||
}
|
||||
|
||||
const FANAVARAN_CLIENT_PROFILES: Record<
|
||||
FanavaranClientKey,
|
||||
FanavaranClientProfile
|
||||
> = {
|
||||
tejaratno: {
|
||||
key: "tejaratno",
|
||||
auth: {
|
||||
appName: "fanhab",
|
||||
secret: "5Fa@N#A2B",
|
||||
username: "fanhabUser",
|
||||
password: "Fan#@2U$3er",
|
||||
corpId: "3539",
|
||||
contractId: "263",
|
||||
location: "100",
|
||||
},
|
||||
defaults: {
|
||||
AccidentCityId: 701,
|
||||
AccidentReportTypeId: 155,
|
||||
AccidentVehicleUsedId: 1,
|
||||
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: {
|
||||
key: "parsian",
|
||||
auth: {
|
||||
appName: "ParsianService",
|
||||
secret: "P@r30@n$erv!ce",
|
||||
username: "ParsianServiceUser",
|
||||
password: "P@r30@n123",
|
||||
corpId: "543",
|
||||
contractId: "28",
|
||||
location: "210050",
|
||||
},
|
||||
defaults: {
|
||||
AccidentCityId: 701,
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Resolve active Fanavaran tenant from env (`FANAVARAN_CLIENT`) with optional CLIENT_ID fallback. */
|
||||
export function resolveFanavaranClientKey(): FanavaranClientKey {
|
||||
const explicit = process.env.FANAVARAN_CLIENT?.trim().toLowerCase();
|
||||
if (explicit === "parsian" || explicit === "tejaratno") {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
// Optional deployment hint when FANAVARAN_CLIENT is not set.
|
||||
if (String(process.env.CLIENT_ID ?? "") === "8") {
|
||||
return "parsian";
|
||||
}
|
||||
|
||||
return "tejaratno";
|
||||
}
|
||||
|
||||
export function resolveFanavaranClientProfile(): FanavaranClientProfile {
|
||||
return FANAVARAN_CLIENT_PROFILES[resolveFanavaranClientKey()];
|
||||
}
|
||||
|
||||
export function getFanavaranClientProfile(
|
||||
clientKey: FanavaranClientKey,
|
||||
): FanavaranClientProfile {
|
||||
return FANAVARAN_CLIENT_PROFILES[clientKey];
|
||||
}
|
||||
|
||||
export function listFanavaranClientProfiles(): FanavaranClientProfile[] {
|
||||
return FANAVARAN_CLIENT_KEYS.map((key) => FANAVARAN_CLIENT_PROFILES[key]);
|
||||
}
|
||||
|
||||
export function fanavaranPreviewPath(
|
||||
clientKey: FanavaranClientKey,
|
||||
claimCaseId: string,
|
||||
): string {
|
||||
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}/base-claim/submit`;
|
||||
}
|
||||
|
||||
export function fanavaranManualSubmitPath(claimCaseId: string): string {
|
||||
return fanavaranSubmitPath(resolveFanavaranClientKey(), claimCaseId);
|
||||
}
|
||||
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 {};
|
||||
}
|
||||
19
src/core/database/database.module.ts
Normal file
19
src/core/database/database.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
uri: configService.get<string>("MONGO_URI"),
|
||||
tls: configService.get<string>("MONGO_TLS") === "true",
|
||||
tlsAllowInvalidCertificates:
|
||||
configService.get<string>("MONGO_TLS_ALLOW_INVALID_CERTS") === "true",
|
||||
autoIndex: configService.get<string>("NODE_ENV") !== "production",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
@@ -117,8 +117,16 @@ export class AllRequestDtoV2 {
|
||||
lockTime: string | null;
|
||||
type: string;
|
||||
blameStatus: string;
|
||||
/** Calculated blame + linked claim lifecycle status */
|
||||
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 {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ApiProduces,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiExcludeController,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response, Request } from "express";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
@@ -35,7 +36,8 @@ import {
|
||||
} from "./dto/reply.dto";
|
||||
import { ExpertBlameService } from "./expert-blame.service";
|
||||
|
||||
@ApiTags("expert-blame-panel")
|
||||
@ApiExcludeController()
|
||||
// @ApiTags("expert-blame-panel")
|
||||
@Controller("expert-blame")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@@ -45,35 +47,35 @@ export class ExpertBlameController {
|
||||
|
||||
// TODO role guard for expert fix
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get()
|
||||
async findAll(@CurrentUser() actor, @ClientKey() client) {
|
||||
return await this.expertBlameService.findAll(actor);
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get(":id")
|
||||
async findOne(@Param("id") id: string, @CurrentUser() actor) {
|
||||
return await this.expertBlameService.findOne(id, actor.sub);
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Put("lock/:id")
|
||||
async lockRequest(@Param("id") id: string, @CurrentUser() actor) {
|
||||
return await this.expertBlameService.lockRequest(id, actor);
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("request/accident-fields")
|
||||
async getAccidentFields() {
|
||||
return await this.expertBlameService.getAccidentField();
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Put("reply/submit/:id")
|
||||
@ApiBody({ type: SubmitReplyDto })
|
||||
@ApiParam({ name: "id" })
|
||||
@@ -100,10 +102,10 @@ export class ExpertBlameController {
|
||||
}
|
||||
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Put("reply/resend/first/:id")
|
||||
@ApiBody({ type: ResendFirstPartyDto })
|
||||
@ApiParam({ name: "id" })
|
||||
// @ApiBody({ type: ResendFirstPartyDto })
|
||||
// @ApiParam({ name: "id" })
|
||||
async resendFirstParty(
|
||||
@Param("id") id: string,
|
||||
@Body() body: SendAginIF,
|
||||
@@ -113,11 +115,11 @@ export class ExpertBlameController {
|
||||
return this.handleResendRequest(id, body, actor.sub, req);
|
||||
}
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Put("reply/resend/second/:id")
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiBody({ type: ResendSecondPartyDto })
|
||||
@ApiParam({ name: "id" })
|
||||
// @ApiBody({ type: ResendSecondPartyDto })
|
||||
// @ApiParam({ name: "id" })
|
||||
async resendSecondParty(
|
||||
@Param("id") id: string,
|
||||
@Body() body: SendAginIF,
|
||||
@@ -127,25 +129,25 @@ export class ExpertBlameController {
|
||||
return this.handleResendRequest(id, body, actor.sub, req);
|
||||
}
|
||||
@Roles(RoleEnum.EXPERT)
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("stream/:requestId")
|
||||
@ApiParam({ name: "requestId" })
|
||||
async streamVideo(@Param("requestId") requestId: string) {
|
||||
return this.expertBlameService.streamVideo(requestId);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("voice/:requestId/:voiceId")
|
||||
@Roles(RoleEnum.EXPERT, RoleEnum.DAMAGE_EXPERT)
|
||||
@ApiParam({ name: "requestId" })
|
||||
@ApiParam({ name: "voiceId" })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
type: "string",
|
||||
format: "binary",
|
||||
},
|
||||
})
|
||||
@ApiProduces("mp3")
|
||||
// @ApiParam({ name: "requestId" })
|
||||
// @ApiParam({ name: "voiceId" })
|
||||
// @ApiOkResponse({
|
||||
// schema: {
|
||||
// type: "string",
|
||||
// format: "binary",
|
||||
// },
|
||||
// })
|
||||
// @ApiProduces("mp3")
|
||||
async downloadVoice(
|
||||
@Param("voiceId") voiceId,
|
||||
@Param("requestId") requestId: string,
|
||||
@@ -156,8 +158,8 @@ export class ExpertBlameController {
|
||||
return await this.expertBlameService.streamVoice(requestId, voiceId);
|
||||
}
|
||||
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiParam({ name: "id" })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Patch(":id/visit")
|
||||
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||
return await this.expertBlameService.inPersonVisit(requestId, actor);
|
||||
|
||||
@@ -7,6 +7,8 @@ import { ExpertBlameV2Controller } from "./expert-blame.v2.controller";
|
||||
import { ExpertBlameService } from "./expert-blame.service";
|
||||
import { RequestManagementModule } from "src/request-management/request-management.module";
|
||||
import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.module";
|
||||
import { LookupsModule } from "src/lookups/lookups.module";
|
||||
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -15,6 +17,8 @@ import { SmsOrchestrationModule } from "src/sms-orchestration/sms-orchestration.
|
||||
RequestManagementModule,
|
||||
PlatesModule,
|
||||
SmsOrchestrationModule,
|
||||
ClaimRequestManagementModule,
|
||||
LookupsModule,
|
||||
],
|
||||
controllers: [ExpertBlameController, ExpertBlameV2Controller],
|
||||
providers: [ExpertBlameService],
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import {
|
||||
assertBlameCaseForExpertTenant,
|
||||
blameCaseAccessibleToExpert,
|
||||
blameCaseInitiatedByFieldExpert,
|
||||
blameCaseTouchesClient,
|
||||
requireActorClientKey,
|
||||
} from "src/helpers/tenant-scope";
|
||||
import {
|
||||
@@ -22,8 +24,19 @@ import {
|
||||
expertPortfolioFileIdsFromActivityEvents,
|
||||
objectIdsFromStringSet,
|
||||
} from "src/helpers/expert-portfolio";
|
||||
import { applyListQueryV2 } from "src/helpers/list-query-v2";
|
||||
import {
|
||||
countUnifiedFileStatuses,
|
||||
getUnifiedFileStatusCatalog,
|
||||
resolveUnifiedFileStatus,
|
||||
} from "src/helpers/unified-file-status";
|
||||
import { applyListQueryV2, isInListDateRange, parseListDateRange } from "src/helpers/list-query-v2";
|
||||
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
|
||||
import { LookupsService } from "src/lookups/lookups.service";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import {
|
||||
UnifiedFileStatusReportDto,
|
||||
UnifiedFileStatusReportQueryDto,
|
||||
} from "src/common/dto/unified-file-status-report.dto";
|
||||
import {
|
||||
ExpertFileAssignResultDto,
|
||||
ExpertFileAssignStatus,
|
||||
@@ -34,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 {
|
||||
@@ -68,6 +85,7 @@ import { snapshotFromFieldExpert } from "src/helpers/expert-profile-snapshot";
|
||||
import { ExpertModel } from "src/users/entities/schema/expert.schema";
|
||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
|
||||
import {
|
||||
ExpertFileActivityType,
|
||||
@@ -98,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,
|
||||
@@ -113,8 +128,127 @@ export class ExpertBlameService {
|
||||
private readonly requestManagementService: RequestManagementService,
|
||||
private readonly smsOrchestrationService: SmsOrchestrationService,
|
||||
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
|
||||
private readonly claimCaseDbService: ClaimCaseDbService,
|
||||
private readonly lookupsService: LookupsService,
|
||||
) {}
|
||||
|
||||
private async loadClaimsByPublicIds(
|
||||
publicIds: string[],
|
||||
): Promise<Map<string, { status?: string }>> {
|
||||
const unique = [...new Set(publicIds.filter(Boolean))];
|
||||
if (unique.length === 0) return new Map();
|
||||
const claims = (await this.claimCaseDbService.find(
|
||||
{ publicId: { $in: unique } },
|
||||
{ lean: true, select: "publicId status" },
|
||||
)) as Array<{ publicId?: string; status?: string }>;
|
||||
return new Map(
|
||||
claims.map((c) => [String(c.publicId), { status: c.status }]),
|
||||
);
|
||||
}
|
||||
|
||||
private filterBlameDocsByUnifiedStatus(
|
||||
docs: Record<string, unknown>[],
|
||||
claimByPublicId: Map<string, { status?: string }>,
|
||||
unifiedStatus?: string,
|
||||
): Record<string, unknown>[] {
|
||||
if (!unifiedStatus) return docs;
|
||||
return docs.filter((doc) => {
|
||||
const publicId = String((doc as { publicId?: string }).publicId ?? "");
|
||||
const claim = claimByPublicId.get(publicId);
|
||||
return (
|
||||
resolveUnifiedFileStatus({
|
||||
blameStatus: String((doc as { status?: string }).status ?? ""),
|
||||
claimStatus: claim?.status,
|
||||
}) === unifiedStatus
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getUnifiedFileStatusReportV2(
|
||||
actor: any,
|
||||
query: UnifiedFileStatusReportQueryDto = {},
|
||||
): Promise<UnifiedFileStatusReportDto> {
|
||||
let rows = await this.collectBlamePortfolioDocs(actor);
|
||||
const { fromDate, toDate } = parseListDateRange(query.from, query.to);
|
||||
rows = rows.filter((doc) =>
|
||||
isInListDateRange(
|
||||
(doc as { createdAt?: Date }).createdAt,
|
||||
fromDate,
|
||||
toDate,
|
||||
),
|
||||
);
|
||||
if (query.fileType) {
|
||||
rows = rows.filter(
|
||||
(doc) =>
|
||||
String((doc as { type?: string }).type ?? "") === query.fileType,
|
||||
);
|
||||
}
|
||||
const claimByPublicId = await this.loadClaimsByPublicIds(
|
||||
rows.map((d) => String((d as { publicId?: string }).publicId ?? "")),
|
||||
);
|
||||
return {
|
||||
statuses: getUnifiedFileStatusCatalog(),
|
||||
counts: countUnifiedFileStatuses(
|
||||
rows.map((doc) => {
|
||||
const publicId = String((doc as { publicId?: string }).publicId ?? "");
|
||||
const claim = claimByPublicId.get(publicId);
|
||||
return {
|
||||
blameStatus: String((doc as { status?: string }).status ?? ""),
|
||||
claimStatus: claim?.status,
|
||||
};
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async collectBlamePortfolioDocs(
|
||||
actor: any,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
if (actor.role === RoleEnum.FIELD_EXPERT) {
|
||||
return (await this.blameRequestDbService.find(
|
||||
{
|
||||
expertInitiated: true,
|
||||
initiatedByFieldExpertId: new Types.ObjectId(actor.sub),
|
||||
},
|
||||
{ lean: true },
|
||||
)) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
requireActorClientKey(actor);
|
||||
const expertId = String(actor.sub);
|
||||
const expertOid = new Types.ObjectId(expertId);
|
||||
const activityEvents = await this.expertFileActivityDbService.findByExpert(
|
||||
expertId,
|
||||
ExpertFileKind.BLAME,
|
||||
);
|
||||
const activityFileIds =
|
||||
expertPortfolioFileIdsFromActivityEvents(activityEvents);
|
||||
const orClauses: Record<string, unknown>[] = [
|
||||
{ initiatedByFieldExpertId: expertOid },
|
||||
{ "expert.decision.decidedByExpertId": expertOid },
|
||||
{ "expert.resend.requestedByExpertId": expertOid },
|
||||
{ "workflow.lockedBy.actorId": expertOid },
|
||||
];
|
||||
const activityOids = objectIdsFromStringSet(activityFileIds);
|
||||
if (activityOids.length > 0) {
|
||||
orClauses.push({ _id: { $in: activityOids } });
|
||||
}
|
||||
const rows = (await this.blameRequestDbService.find(
|
||||
{ blameStatus: BlameStatus.DISAGREEMENT, $or: orClauses },
|
||||
{ lean: true },
|
||||
)) as Record<string, unknown>[];
|
||||
const seen = new Set<string>();
|
||||
const out: Record<string, unknown>[] = [];
|
||||
for (const doc of rows) {
|
||||
const id = String(doc._id ?? "");
|
||||
if (seen.has(id)) continue;
|
||||
if (!blameDocInExpertPortfolio(doc, actor, activityFileIds)) continue;
|
||||
seen.add(id);
|
||||
out.push(doc);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Load immutable profile fields from `expert` for the acting field expert. */
|
||||
private async snapshotFieldExpert(actorId: string) {
|
||||
const expertDoc = await this.expertDbService.findOne({
|
||||
@@ -243,7 +377,9 @@ export class ExpertBlameService {
|
||||
* Portfolio = file-activity (checked/handled), lock, decision, or expert-initiated file.
|
||||
*/
|
||||
async getStatusReportBucketsV2(actor: any): Promise<Record<string, number>> {
|
||||
requireActorClientKey(actor);
|
||||
if (actor.role !== RoleEnum.FIELD_EXPERT) {
|
||||
requireActorClientKey(actor);
|
||||
}
|
||||
const expertId = String(actor.sub);
|
||||
const expertOid = new Types.ObjectId(expertId);
|
||||
|
||||
@@ -300,6 +436,12 @@ export class ExpertBlameService {
|
||||
query: ListQueryV2Dto = {},
|
||||
): Promise<AllRequestDtoRsV2> {
|
||||
try {
|
||||
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;
|
||||
|
||||
@@ -379,44 +521,8 @@ export class ExpertBlameService {
|
||||
}
|
||||
}
|
||||
|
||||
const paged = applyListQueryV2(
|
||||
visibleCases,
|
||||
{
|
||||
publicId: (doc) =>
|
||||
String((doc as { publicId?: string }).publicId ?? ""),
|
||||
createdAt: (doc) => (doc as { createdAt?: Date }).createdAt,
|
||||
requestNo: (doc) =>
|
||||
String(
|
||||
(doc as { requestNo?: string }).requestNo ??
|
||||
(doc as { publicId?: string }).publicId ??
|
||||
"",
|
||||
),
|
||||
status: (doc) => String((doc as { status?: string }).status ?? ""),
|
||||
searchExtras: (doc) => {
|
||||
const d = doc as {
|
||||
_id?: unknown;
|
||||
blameStatus?: string;
|
||||
type?: string;
|
||||
};
|
||||
return [String(d._id ?? ""), d.blameStatus, d.type].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
},
|
||||
},
|
||||
query,
|
||||
);
|
||||
|
||||
const items: AllRequestDtoV2[] = paged.list.map((doc) =>
|
||||
this.mapBlameRequestToListItemV2(doc),
|
||||
);
|
||||
|
||||
return new AllRequestDtoRsV2({
|
||||
data: items,
|
||||
total: paged.total,
|
||||
page: paged.page,
|
||||
limit: paged.limit,
|
||||
totalPages: paged.totalPages,
|
||||
});
|
||||
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
|
||||
return pagedResult;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
this.logger.error(
|
||||
@@ -429,8 +535,181 @@ export class ExpertBlameService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blame inbox for FIELD_EXPERT — all expert-initiated blame files they created
|
||||
* (LINK disputes, IN_PERSON, in-progress and completed). Claim work is usually
|
||||
* via expert-claim after blame completion.
|
||||
*/
|
||||
private async getFieldExpertBlameListV2(
|
||||
actor: { sub: string },
|
||||
query: ListQueryV2Dto = {},
|
||||
): Promise<AllRequestDtoRsV2> {
|
||||
const expertId = actor.sub;
|
||||
const expertOid = new Types.ObjectId(expertId);
|
||||
|
||||
const visibleCases = (await this.blameRequestDbService.find(
|
||||
{
|
||||
expertInitiated: true,
|
||||
initiatedByFieldExpertId: expertOid,
|
||||
},
|
||||
{ lean: true },
|
||||
)) as Record<string, unknown>[];
|
||||
|
||||
const staleIds = new Set<string>();
|
||||
for (const doc of visibleCases) {
|
||||
const w = doc.workflow as Record<string, unknown> | undefined;
|
||||
if (!w?.locked) continue;
|
||||
if (!this.isBlameV2WorkflowLockCurrentlyEnforced(doc as any)) {
|
||||
staleIds.add(String(doc._id));
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
[...staleIds].map((id) =>
|
||||
this.expireBlameCaseWorkflowLockV2IfStale(id),
|
||||
),
|
||||
);
|
||||
for (const doc of visibleCases) {
|
||||
if (!staleIds.has(String(doc._id))) continue;
|
||||
const w = doc.workflow as Record<string, unknown>;
|
||||
if (w) {
|
||||
w.locked = false;
|
||||
delete w.lockedAt;
|
||||
delete w.expiredAt;
|
||||
delete w.lockedBy;
|
||||
}
|
||||
}
|
||||
|
||||
const pagedResult = await this.paginateBlameListV2(visibleCases, query);
|
||||
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,
|
||||
): Promise<AllRequestDtoRsV2> {
|
||||
const claimByPublicId = await this.loadClaimsByPublicIds(
|
||||
visibleCases.map((d) =>
|
||||
String((d as { publicId?: string }).publicId ?? ""),
|
||||
),
|
||||
);
|
||||
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,
|
||||
{
|
||||
publicId: (doc) =>
|
||||
String((doc as { publicId?: string }).publicId ?? ""),
|
||||
createdAt: (doc) => (doc as { createdAt?: Date }).createdAt,
|
||||
requestNo: (doc) =>
|
||||
String(
|
||||
(doc as { requestNo?: string }).requestNo ??
|
||||
(doc as { publicId?: string }).publicId ??
|
||||
"",
|
||||
),
|
||||
status: (doc) => {
|
||||
const publicId = String((doc as { publicId?: string }).publicId ?? "");
|
||||
const claim = claimByPublicId.get(publicId);
|
||||
return resolveUnifiedFileStatus({
|
||||
blameStatus: String((doc as { status?: string }).status ?? ""),
|
||||
claimStatus: claim?.status,
|
||||
});
|
||||
},
|
||||
fileType: (doc) =>
|
||||
String((doc as { type?: string }).type ?? "") || undefined,
|
||||
searchExtras: (doc) => {
|
||||
const d = doc as {
|
||||
_id?: unknown;
|
||||
blameStatus?: string;
|
||||
type?: string;
|
||||
publicId?: string;
|
||||
status?: string;
|
||||
};
|
||||
const publicId = String(d.publicId ?? "");
|
||||
const claim = claimByPublicId.get(publicId);
|
||||
const unified = resolveUnifiedFileStatus({
|
||||
blameStatus: String(d.status ?? ""),
|
||||
claimStatus: claim?.status,
|
||||
});
|
||||
return [
|
||||
String(d._id ?? ""),
|
||||
d.blameStatus,
|
||||
d.type,
|
||||
unified,
|
||||
claim?.status,
|
||||
].filter(Boolean) as string[];
|
||||
},
|
||||
},
|
||||
query,
|
||||
);
|
||||
|
||||
const items: AllRequestDtoV2[] = paged.list.map((doc) =>
|
||||
this.mapBlameRequestToListItemV2(doc, claimByPublicId),
|
||||
);
|
||||
|
||||
return new AllRequestDtoRsV2({
|
||||
data: items,
|
||||
total: paged.total,
|
||||
page: paged.page,
|
||||
limit: paged.limit,
|
||||
totalPages: paged.totalPages,
|
||||
});
|
||||
}
|
||||
|
||||
private mapBlameRequestToListItemV2(
|
||||
doc: Record<string, unknown>,
|
||||
claimByPublicId?: Map<string, { status?: string }>,
|
||||
): AllRequestDtoV2 {
|
||||
const createdAt = doc.createdAt
|
||||
? new Date(doc.createdAt as string)
|
||||
@@ -461,10 +740,17 @@ export class ExpertBlameService {
|
||||
|
||||
const firstParty = parties.find((p) => p.role === "FIRST");
|
||||
const secondParty = parties.find((p) => p.role === "SECOND");
|
||||
const publicId = String(doc.publicId ?? "");
|
||||
const linkedClaim = claimByPublicId?.get(publicId);
|
||||
const unifiedFileStatus = resolveUnifiedFileStatus({
|
||||
blameStatus: String(doc.status ?? ""),
|
||||
claimStatus: linkedClaim?.status,
|
||||
});
|
||||
|
||||
return {
|
||||
requestId: String(doc._id),
|
||||
status: String(doc.status ?? ""),
|
||||
unifiedFileStatus,
|
||||
userComment: null,
|
||||
requestCode: String(doc.publicId ?? ""),
|
||||
date,
|
||||
@@ -493,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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -512,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(
|
||||
@@ -534,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;
|
||||
}
|
||||
@@ -859,7 +1149,12 @@ export class ExpertBlameService {
|
||||
// Tenant check
|
||||
assertBlameCaseForExpertTenant(doc, actor);
|
||||
|
||||
if (doc.type === BlameRequestType.CAR_BODY) {
|
||||
const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(doc, actor);
|
||||
|
||||
if (
|
||||
doc.type === BlameRequestType.CAR_BODY &&
|
||||
!isFieldExpertOwner
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
"CAR_BODY type requests are automatically handled and do not require expert review.",
|
||||
);
|
||||
@@ -888,6 +1183,7 @@ export class ExpertBlameService {
|
||||
(doc.workflow as any)?.assignedForReviewBy?.actorId ?? "",
|
||||
);
|
||||
|
||||
if (!isFieldExpertOwner) {
|
||||
// Access gates — must satisfy at least one bucket
|
||||
const isAvailable =
|
||||
doc.status === CaseStatus.WAITING_FOR_EXPERT && !decidedByExpertId;
|
||||
@@ -912,6 +1208,7 @@ export class ExpertBlameService {
|
||||
"You do not have permission to view this request.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build evidence URLs
|
||||
const parties = Array.isArray(doc.parties) ? doc.parties : [];
|
||||
@@ -1006,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(
|
||||
@@ -1018,7 +1315,7 @@ export class ExpertBlameService {
|
||||
$set: {
|
||||
lockFile: true,
|
||||
blameStatus: ReqBlameStatus.ReviewRequest,
|
||||
unlockTime: fifteenMinutes,
|
||||
unlockTime: lockExpiresAt,
|
||||
lockTime: new Date(),
|
||||
actorLocked: {
|
||||
fullName: actorDetail.fullName,
|
||||
@@ -1068,7 +1365,12 @@ export class ExpertBlameService {
|
||||
*/
|
||||
async assignBlameCaseForReviewV2(
|
||||
requestId: string,
|
||||
actor: { sub: string; fullName?: string; clientKey?: string },
|
||||
actor: {
|
||||
sub: string;
|
||||
fullName?: string;
|
||||
clientKey?: string;
|
||||
role?: string;
|
||||
},
|
||||
): Promise<ExpertFileAssignResultDto> {
|
||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||
|
||||
@@ -1079,6 +1381,8 @@ export class ExpertBlameService {
|
||||
|
||||
assertBlameCaseForExpertTenant(request, actor);
|
||||
|
||||
const isFieldExpertOwner = blameCaseInitiatedByFieldExpert(request, actor);
|
||||
|
||||
if (request.type === BlameRequestType.CAR_BODY) {
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
@@ -1087,9 +1391,17 @@ export class ExpertBlameService {
|
||||
});
|
||||
}
|
||||
|
||||
if (request.blameStatus !== BlameStatus.DISAGREEMENT) {
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||
message: "Request is not available for expert review",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
request.status !== CaseStatus.WAITING_FOR_EXPERT ||
|
||||
request.blameStatus !== BlameStatus.DISAGREEMENT
|
||||
!isFieldExpertOwner &&
|
||||
request.status !== CaseStatus.WAITING_FOR_EXPERT
|
||||
) {
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
@@ -1098,6 +1410,26 @@ export class ExpertBlameService {
|
||||
});
|
||||
}
|
||||
|
||||
if (isFieldExpertOwner && request.expert?.decision) {
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||
message: "This request already has an expert decision",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isFieldExpertOwner &&
|
||||
request.status !== CaseStatus.WAITING_FOR_EXPERT
|
||||
) {
|
||||
throw new BadRequestException({
|
||||
success: false,
|
||||
status: "unavailable" satisfies ExpertFileAssignStatus,
|
||||
message:
|
||||
"Parties are still completing this file. Lock and review become available when status is WAITING_FOR_EXPERT (e.g. after both parties finish via link).",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
request.expertInitiated &&
|
||||
request.initiatedByFieldExpertId &&
|
||||
@@ -1160,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: {
|
||||
@@ -1182,7 +1514,6 @@ export class ExpertBlameService {
|
||||
|
||||
const assignFilter: Record<string, unknown> = {
|
||||
_id: new Types.ObjectId(requestId),
|
||||
status: CaseStatus.WAITING_FOR_EXPERT,
|
||||
blameStatus: BlameStatus.DISAGREEMENT,
|
||||
$and: [
|
||||
{
|
||||
@@ -1203,8 +1534,11 @@ export class ExpertBlameService {
|
||||
],
|
||||
};
|
||||
|
||||
if (request.expertInitiated) {
|
||||
if (isFieldExpertOwner) {
|
||||
assignFilter.initiatedByFieldExpertId = expertOid;
|
||||
assignFilter.status = CaseStatus.WAITING_FOR_EXPERT;
|
||||
} else {
|
||||
assignFilter.status = CaseStatus.WAITING_FOR_EXPERT;
|
||||
}
|
||||
|
||||
const updated = await this.blameRequestDbService.findOneAndUpdate(
|
||||
@@ -1289,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(
|
||||
@@ -1344,7 +1678,9 @@ export class ExpertBlameService {
|
||||
try {
|
||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||
|
||||
requireActorClientKey(actor);
|
||||
if (actor.role !== RoleEnum.FIELD_EXPERT) {
|
||||
requireActorClientKey(actor);
|
||||
}
|
||||
const actorId = actor.sub;
|
||||
const request = await this.blameRequestDbService.findById(requestId);
|
||||
|
||||
@@ -1372,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.",
|
||||
@@ -1535,6 +1871,7 @@ export class ExpertBlameService {
|
||||
link: this.smsOrchestrationService.buildBlamePartyLink(
|
||||
requestIdToken,
|
||||
role,
|
||||
"v1",
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -1584,7 +1921,9 @@ export class ExpertBlameService {
|
||||
): Promise<{ requestId: string; status: string }> {
|
||||
try {
|
||||
await this.expireBlameCaseWorkflowLockV2IfStale(requestId);
|
||||
requireActorClientKey(actor);
|
||||
if (actor.role !== RoleEnum.FIELD_EXPERT) {
|
||||
requireActorClientKey(actor);
|
||||
}
|
||||
const actorId = actor.sub;
|
||||
const request = await this.blameRequestDbService.findById(requestId);
|
||||
|
||||
@@ -1612,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;
|
||||
}
|
||||
|
||||
@@ -1727,6 +2066,7 @@ export class ExpertBlameService {
|
||||
link: this.smsOrchestrationService.buildBlamePartyLink(
|
||||
requestIdToken,
|
||||
linkRole,
|
||||
"v1",
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -1778,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;
|
||||
}
|
||||
|
||||
@@ -2145,21 +2485,7 @@ export class ExpertBlameService {
|
||||
}
|
||||
|
||||
async getAccidentField() {
|
||||
try {
|
||||
const ac_reason = await readFile(
|
||||
"src/static/ACCIDENT_REASON.json",
|
||||
"utf-8",
|
||||
);
|
||||
const ac_type = await readFile("src/static/ACCIDENT_TYPE.json", "utf-8");
|
||||
const ac_way = await readFile("src/static/ACCIDENT_WAY.json", "utf-8");
|
||||
return {
|
||||
accidentReason: JSON.parse(ac_reason),
|
||||
accidentType: JSON.parse(ac_type),
|
||||
accidentWay: JSON.parse(ac_way),
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
return await this.lookupsService.getAccidentFields();
|
||||
}
|
||||
|
||||
async inPersonVisit(requestId: string, actorDetail: any) {
|
||||
|
||||
@@ -26,6 +26,10 @@ import { CurrentUser } from "src/decorators/user.decorator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { ExpertBlameService } from "./expert-blame.service";
|
||||
import { ListQueryV2Dto } from "src/common/dto/list-query-v2.dto";
|
||||
import {
|
||||
UnifiedFileStatusReportDto,
|
||||
UnifiedFileStatusReportQueryDto,
|
||||
} from "src/common/dto/unified-file-status-report.dto";
|
||||
import { AllRequestDtoRsV2 } from "./dto/all-request.dto";
|
||||
import { SubmitReplyDto } from "./dto/reply.dto";
|
||||
import { ResendRequestDto } from "./dto/resend.dto";
|
||||
@@ -40,10 +44,13 @@ export class ExpertBlameV2Controller {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "List blame cases for field expert (V2)",
|
||||
summary: "List blame cases for expert review (V2)",
|
||||
description:
|
||||
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`. Without `page`/`limit`, returns the full filtered list.",
|
||||
"Damage experts (`expert`): tenant-scoped **DISAGREEMENT** queue (available, locked, or decided by you). " +
|
||||
"Field experts (`field_expert`): all expert-initiated blame files they created (LINK + IN_PERSON). Use expert-claim for claims after blame completion. " +
|
||||
"Optional query: `search`, `sortBy` (publicId | createdAt | requestNo | status), `sortOrder`, `page`, `limit`, `unifiedStatus`, `fileType` (THIRD_PARTY | CAR_BODY).",
|
||||
})
|
||||
@ApiResponse({ status: 200, type: AllRequestDtoRsV2 })
|
||||
async findAll(
|
||||
@CurrentUser() actor: any,
|
||||
@Query() query: ListQueryV2Dto,
|
||||
@@ -58,11 +65,38 @@ export class ExpertBlameV2Controller {
|
||||
}
|
||||
}
|
||||
|
||||
@Get("report/unified-file-statuses")
|
||||
@ApiOperation({
|
||||
summary: "Unified file status catalog + counts (blame + linked claim)",
|
||||
description:
|
||||
"Calculatable statuses for this expert's blame portfolio. Filter lists with `unifiedStatus` and `fileType` query params. Optional `from` / `to` ISO dates narrow the counted portfolio.",
|
||||
})
|
||||
@ApiResponse({ status: 200, type: UnifiedFileStatusReportDto })
|
||||
async getUnifiedFileStatusReportV2(
|
||||
@CurrentUser() actor: any,
|
||||
@Query() query: UnifiedFileStatusReportQueryDto,
|
||||
): Promise<UnifiedFileStatusReportDto> {
|
||||
try {
|
||||
return await this.expertBlameService.getUnifiedFileStatusReportV2(
|
||||
actor,
|
||||
query,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) throw error;
|
||||
throw new InternalServerErrorException(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load unified file status report",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get("report/status-counts")
|
||||
@ApiOperation({
|
||||
summary: "Count blame cases by grouped status bucket (this field expert)",
|
||||
deprecated: true,
|
||||
description:
|
||||
"Counts only files in the acting expert’s portfolio: expertFileActivities (checked or handled), workflow lock, expert decision, or expert-initiated blame. IN_PROGRESS groups OPEN and WAITING_FOR_SECOND_PARTY. Other keys match CaseStatus. Does not include the open WAITING_FOR_EXPERT queue unless this expert has touched the file.",
|
||||
"Legacy blame-only buckets. Prefer GET report/unified-file-statuses for blame+claim calculatable statuses.",
|
||||
})
|
||||
async getStatusReportBucketsV2(@CurrentUser() actor: any) {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// claim-damaged-part.enricher.ts
|
||||
|
||||
import { resolveStoredFileUrl } from "src/helpers/urlCreator";
|
||||
import {
|
||||
DamagedPartCaptureStatus,
|
||||
EnrichedDamagedPart,
|
||||
@@ -14,6 +15,9 @@ export function buildEnrichedDamagedParts(params: {
|
||||
hasDamagedPartCapture: (data: any, key: string, parts: any[]) => boolean;
|
||||
catalogLikeKeyFromPart: (part: any) => string;
|
||||
buildFileLink: (path: string) => string;
|
||||
resolveStoredFileUrl?: (
|
||||
stored?: { url?: string; path?: string } | null,
|
||||
) => string | undefined;
|
||||
}): EnrichedDamagedPart[] {
|
||||
const {
|
||||
selectedParts,
|
||||
@@ -24,6 +28,7 @@ export function buildEnrichedDamagedParts(params: {
|
||||
hasDamagedPartCapture,
|
||||
catalogLikeKeyFromPart,
|
||||
buildFileLink,
|
||||
resolveStoredFileUrl: resolveUrl = resolveStoredFileUrl,
|
||||
} = params;
|
||||
|
||||
const priceDropLines: any[] = evaluationBlock?.priceDrop?.partLines ?? [];
|
||||
@@ -109,17 +114,13 @@ export function buildEnrichedDamagedParts(params: {
|
||||
: false);
|
||||
|
||||
const capUrl =
|
||||
cap?.url ||
|
||||
(cap?.path ? buildFileLink(cap.path) : undefined) ||
|
||||
resolveUrl(cap) ||
|
||||
(sp.id == null && Array.isArray(damagedPartsData)
|
||||
? (() => {
|
||||
const match = (damagedPartsData as any[]).find(
|
||||
(d) => d.name === sp.name && d.side === sp.side && d.path,
|
||||
);
|
||||
return (
|
||||
match?.url ||
|
||||
(match?.path ? buildFileLink(match.path) : undefined)
|
||||
);
|
||||
return resolveUrl(match);
|
||||
})()
|
||||
: undefined);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,12 @@ export class ClaimListItemV2Dto {
|
||||
})
|
||||
status: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Calculated unified blame+claim lifecycle status",
|
||||
example: "WAITING_FOR_DAMAGE_EXPERT",
|
||||
})
|
||||
unifiedFileStatus?: string;
|
||||
|
||||
@ApiProperty({ description: 'Workflow step', example: 'USER_SUBMISSION_COMPLETE' })
|
||||
currentStep: string;
|
||||
|
||||
@@ -50,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;
|
||||
|
||||
@@ -58,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;
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiExcludeController,
|
||||
} from "@nestjs/swagger";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
@@ -33,7 +34,8 @@ import { ParseJsonPipe } from "src/utils/pipes/parse-json.pipe";
|
||||
import { ExpertClaimService } from "./expert-claim.service";
|
||||
import { FactorValidationDto } from "./dto/factor-validation.dto";
|
||||
|
||||
@ApiTags("expert-claim-panel")
|
||||
@ApiExcludeController()
|
||||
// @ApiTags("expert-claim-panel")
|
||||
@Controller("expert-claim")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(LocalActorAuthGuard, RolesGuard)
|
||||
@@ -41,20 +43,20 @@ import { FactorValidationDto } from "./dto/factor-validation.dto";
|
||||
export class ExpertClaimController {
|
||||
constructor(private readonly expertClaimService: ExpertClaimService) {}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get()
|
||||
async getAllClaimRequests(@CurrentUser() actor, @ClientKey() client) {
|
||||
return await this.expertClaimService.getClaimRequestsListForExpert(actor);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiQuery({
|
||||
name: "updateDropPrice",
|
||||
required: false,
|
||||
description: "A stringified JSON object with price drop override values.",
|
||||
})
|
||||
// @ApiParam({ name: "id" })
|
||||
// @ApiQuery({
|
||||
// name: "updateDropPrice",
|
||||
// required: false,
|
||||
// description: "A stringified JSON object with price drop override values.",
|
||||
// })
|
||||
async getClaimRequestPerId(
|
||||
@Param("id") id: string,
|
||||
@CurrentUser() currentUser,
|
||||
@@ -67,16 +69,16 @@ export class ExpertClaimController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("imageRequired/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
// @ApiParam({ name: "id" })
|
||||
async getImageRequired(@Param("id") id: string, @CurrentUser() currentUser) {
|
||||
return await this.expertClaimService.imageRequired(id, currentUser);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("required-documents/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
// @ApiParam({ name: "id" })
|
||||
async getRequiredDocuments(
|
||||
@Param("id") id: string,
|
||||
@CurrentUser() currentUser,
|
||||
@@ -84,7 +86,7 @@ export class ExpertClaimController {
|
||||
return await this.expertClaimService.getRequiredDocuments(id, currentUser);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Put("lock/:id")
|
||||
async lockClaimRequest(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||
return await this.expertClaimService.lockClaimRequest(requestId, actor);
|
||||
@@ -92,7 +94,7 @@ export class ExpertClaimController {
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
@Put("reply/submit/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
// @ApiParam({ name: "id" })
|
||||
async submitReplyRequest(
|
||||
@Param("id") requestId,
|
||||
@Body() body: ClaimSubmitReplyDto,
|
||||
@@ -105,9 +107,9 @@ export class ExpertClaimController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Put("resend/submit/:id")
|
||||
@ApiParam({ name: "id" })
|
||||
// @ApiParam({ name: "id" })
|
||||
async submitResend(
|
||||
@Param("id") requestId,
|
||||
@Body() body: ClaimSubmitResendDto,
|
||||
@@ -120,12 +122,12 @@ export class ExpertClaimController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiQuery({
|
||||
name: "query",
|
||||
enum: ["car-capture", "accident"],
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiQuery({
|
||||
// name: "query",
|
||||
//enum: ["car-capture", "accident"],
|
||||
//required: true,
|
||||
//})
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("stream/:id/video")
|
||||
@Header("Accept-Ranges", "bytes")
|
||||
@Header("Content-Type", "video/mp4")
|
||||
@@ -143,7 +145,7 @@ export class ExpertClaimController {
|
||||
enum: ["car-capture", "accident"],
|
||||
required: true,
|
||||
})
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("link/:id/video")
|
||||
@ApiParam({ name: "id" })
|
||||
async getClaimVideoLink(
|
||||
@@ -153,17 +155,17 @@ export class ExpertClaimController {
|
||||
return this.expertClaimService.getVideoLink(id, query);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("stream/:id/voice")
|
||||
@ApiParam({ name: "id" })
|
||||
// @ApiParam({ name: "id" })
|
||||
async getClaimVoiceLink(@Param("id") id: string) {
|
||||
return await this.expertClaimService.getVoiceLink(id);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Patch("validate-factors/:claimId")
|
||||
@ApiParam({ name: "claimId" })
|
||||
@ApiBody({ type: FactorValidationDto })
|
||||
// @ApiParam({ name: "claimId" })
|
||||
// @ApiBody({ type: FactorValidationDto })
|
||||
@Roles(RoleEnum.DAMAGE_EXPERT)
|
||||
async validateFactors(
|
||||
@Param("claimId") claimId: string,
|
||||
@@ -177,16 +179,16 @@ export class ExpertClaimController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiParam({ name: "id" })
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiParam({ name: "id" })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Patch(":id/visit")
|
||||
async inPersonVisit(@Param("id") requestId: string, @CurrentUser() actor) {
|
||||
return await this.expertClaimService.inPersonVisit(requestId, actor);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("branches/:insuranceId")
|
||||
@ApiParam({ name: "insuranceId" })
|
||||
// @ApiParam({ name: "insuranceId" })
|
||||
async getInsuranceBranches(@Param("insuranceId") insuranceId: string) {
|
||||
return await this.expertClaimService.retrieveInsuranceBranches(insuranceId);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
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