forked from Yara724/api
Compare commits
349 Commits
4d4106a8ab
...
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 | |||
|
|
34142942e5 | ||
|
|
f023d0f3e7 | ||
| 2d7afba75d | |||
|
|
9ee933cb76 | ||
|
|
16c598118d | ||
| 2b1edd64c1 | |||
|
|
d92231e517 | ||
| dc14698823 | |||
|
|
8236f0440d | ||
| ffcedcd5f1 | |||
|
|
bd5a33e2ba | ||
|
|
0b47e8789b | ||
|
|
2c810afcb6 | ||
|
|
077bae429e | ||
| 456135ad08 | |||
| eae46c3212 | |||
| fe82455562 | |||
| f2d7e39487 | |||
| 8730e9af62 | |||
| cf07122710 | |||
|
|
a0247d7769 | ||
| fcf3e63f9b | |||
| 519d91102f | |||
| 4bc5889ccd | |||
| a47c6f1c96 | |||
|
|
5fab0a00b6 | ||
| e650abdf40 | |||
|
|
6261af8a29 | ||
|
|
fde6464739 | ||
| a07b5c3c1e | |||
|
|
06af79fa47 | ||
| 1a8181a872 | |||
|
|
4a189ba4ef | ||
| 859244940c | |||
|
|
cec349b7c2 | ||
| 8d8f76eadd | |||
|
|
b9e7373225 | ||
| 6ddb06594b | |||
|
|
e90c6a5c50 | ||
| 97f26d400f | |||
|
|
02a69f3db2 | ||
| c137d6c6c4 | |||
|
|
2b7192151d | ||
| 389133e1c9 | |||
|
|
768d6d12fe | ||
|
|
84b752c6cc | ||
| 0622ceeaf4 | |||
|
|
3b0db0d250 | ||
| c502adbe76 | |||
|
|
7aada14551 | ||
| b3bf1b85f8 | |||
|
|
d6f1cb9eeb | ||
|
|
da298a3350 | ||
| 722cbbf5c9 | |||
|
|
245160bfc2 | ||
| 9c760d59f7 | |||
|
|
6ac0bf060e | ||
| 10df869efb | |||
| 5c372947dd | |||
|
|
9003a7abb6 | ||
| a994331439 | |||
|
|
ae12049e1b | ||
| 02031efdb5 | |||
|
|
48cc4d8a8d | ||
| cbbb45378d | |||
|
|
680f3c1798 | ||
|
|
64fa560f73 | ||
|
|
ff94fa35bf | ||
| 037d9fa934 | |||
|
|
2bdd0d507e | ||
| f60efa52b1 | |||
|
|
866696094f | ||
| ed936ad7b1 |
100
.env.example
Normal file
100
.env.example
Normal file
@@ -0,0 +1,100 @@
|
||||
# ---------------------------------------------
|
||||
# 🔧 Application Environment
|
||||
# ---------------------------------------------
|
||||
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 =
|
||||
|
||||
# ---------------------------------------------
|
||||
# 📄 Swagger / API Documentation
|
||||
# ---------------------------------------------
|
||||
SWAGGER_USER_DEV =
|
||||
SWAGGER_PASSWORD_DEV =
|
||||
|
||||
# ---------------------------------------------
|
||||
# 🗄️ Database (MongoDB)
|
||||
# ---------------------------------------------
|
||||
MONGO_HOST =
|
||||
MONGO_PORT =
|
||||
MONGO_USER =
|
||||
MONGO_PASS =
|
||||
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
|
||||
# ---------------------------------------------
|
||||
SANHUB_BASE_URL =
|
||||
SANHUB_URL_LOGIN =
|
||||
SANHUB_USERNAME =
|
||||
SANHUB_PASSWORD =
|
||||
|
||||
# ---------------------------------------------
|
||||
# 🤖 AI Services
|
||||
# ---------------------------------------------
|
||||
AI_URL =
|
||||
AI_URL_V2 =
|
||||
AI_USERNAME =
|
||||
AI_PASSWORD =
|
||||
|
||||
# ---------------------------------------------
|
||||
# 📩 SMS
|
||||
# ---------------------------------------------
|
||||
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
|
||||
# ---------------------------------------------
|
||||
AUTO_CLIENT_KEY_ENABLED =
|
||||
|
||||
# ---------------------------------------------
|
||||
# 🔗 Other Internal Services
|
||||
# ---------------------------------------------
|
||||
TEJARAT_INQUIRY_EMAIL =
|
||||
TEJARAT_INQUIRY_PASSWORD =
|
||||
|
||||
PARSIAN_API_KEY =
|
||||
PARSIAN_BASIC_TOKEN =
|
||||
PARSIAN_SMS_URL =
|
||||
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
|
||||
10
LICENSE
10
LICENSE
@@ -1,10 +0,0 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <http://unlicense.org/>
|
||||
13
README.md
13
README.md
@@ -1,2 +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"
|
||||
}'
|
||||
```
|
||||
1
err.json
1
err.json
@@ -1 +0,0 @@
|
||||
{"1000":{"info":"start","message":"start"},"1001":{"info":"Access Denied Other Actor Lock File","message":""},"1004":{"info":"g","message":""},"1005":{"info":"fSF","message":""},"1006":{"info":"request not found ","message":""}}
|
||||
@@ -3,6 +3,13 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{
|
||||
"include": "../assets/fonts/**/*",
|
||||
"outDir": "dist",
|
||||
"watchAssets": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
15997
package-lock.json
generated
15997
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
108
package.json
108
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "yara724",
|
||||
"version": "0.0.1",
|
||||
"version": "2.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -12,85 +12,69 @@
|
||||
"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",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/jest/bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=10.0.0",
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arashioz/errjson-talieh": "^2.2.5",
|
||||
"@fraybabak/kavenegar_nest": "^1.0.5",
|
||||
"@nestjs-modules/mailer": "^1.8.1",
|
||||
"@nestjs/axios": "^3.1.3",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.17",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/mongoose": "^10.1.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/platform-fastify": "^10.4.15",
|
||||
"@nestjs/platform-socket.io": "^10.4.15",
|
||||
"@nestjs/schedule": "^4.1.2",
|
||||
"@nestjs/serve-static": "^4.0.2",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/websockets": "^10.4.15",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"axios": "^1.9.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/mongoose": "^11.0.4",
|
||||
"@nestjs/platform-express": "^11.1.11",
|
||||
"@nestjs/serve-static": "^5.0.5",
|
||||
"@nestjs/swagger": "^11.4.4",
|
||||
"axios": "^1.16.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"crypto": "^1.0.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.22.1",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"express": "^5.2.1",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"form-data": "^4.0.2",
|
||||
"jalali-moment": "^3.3.11",
|
||||
"kavenegar": "^1.1.4",
|
||||
"form-data": "^4.0.6",
|
||||
"joi": "^18.2.1",
|
||||
"mongoose": "^8.9.2",
|
||||
"nestjs-command": "^3.1.4",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pdfkit": "^0.19.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"short-unique-id": "^5.2.0",
|
||||
"standard": "^17.1.2",
|
||||
"standardjs": "^1.0.0-alpha",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"uuid": "^11.0.3",
|
||||
"yargs": "^17.7.2"
|
||||
"socks-proxy-agent": "^8.0.4",
|
||||
"svg-captcha": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.14",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@swc/cli": "^0.8.1",
|
||||
"@swc/core": "^1.10.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"globals": "^15.14.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-standard": "^12.0.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
@@ -103,13 +87,19 @@
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@nestjs/core",
|
||||
"@swc/core"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
920
scripts/refresh-blame-inquiries.js
Normal file
920
scripts/refresh-blame-inquiries.js
Normal file
@@ -0,0 +1,920 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/*
|
||||
* One-time script to replace mocked blameCases party vehicle inquiry data.
|
||||
* THIRD_PARTY cases use Tejarat block inquiry; CAR_BODY cases use both
|
||||
* Tejarat third-party block inquiry and car-body inquiry. All cases also
|
||||
* refresh available party personal inquiries into inquiries.person.
|
||||
*
|
||||
* Defaults to DRY_RUN=true. Set DRY_RUN=false to write changes.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const mongoose = require("mongoose");
|
||||
|
||||
const loadedEnvFiles = loadEnvFiles(process.env.ENV_FILE || ".env");
|
||||
|
||||
const LETTER_TO_NUMBER = {
|
||||
"الف": 1,
|
||||
"ب": 2,
|
||||
"ت": 3,
|
||||
"ج": 4,
|
||||
"د": 5,
|
||||
"س": 6,
|
||||
"ص": 7,
|
||||
"ط": 8,
|
||||
"ع": 9,
|
||||
"ق": 10,
|
||||
"ل": 11,
|
||||
"م": 12,
|
||||
"ن": 13,
|
||||
"و": 14,
|
||||
"ه": 15,
|
||||
"ی": 16,
|
||||
"ر": 17,
|
||||
"ک": 18,
|
||||
"ژ": 19,
|
||||
"پ": 20,
|
||||
"ظ": 24,
|
||||
"ض": 25,
|
||||
"ز": 41,
|
||||
"ش": 42,
|
||||
"گ": 43,
|
||||
"ث": 44,
|
||||
D: 45,
|
||||
S: 46,
|
||||
"ح": 47,
|
||||
"ف": 48,
|
||||
};
|
||||
|
||||
const NUMBER_TO_LETTER = Object.fromEntries(
|
||||
Object.entries(LETTER_TO_NUMBER).map(([letter, number]) => [String(number), letter]),
|
||||
);
|
||||
|
||||
const config = {
|
||||
mongoUri: requiredEnv("MONGO_URL", "MONGODB_URI", "DATABASE_URL"),
|
||||
collectionName: process.env.BLAME_COLLECTION || "blameCases",
|
||||
mongoDbName: process.env.MONGO_DB_NAME || "",
|
||||
thirdPartyUrl:
|
||||
process.env.TEJARAT_THIRD_PARTY_URL ||
|
||||
"http://82.99.202.245:3027/block-inquiry-tejarat",
|
||||
carBodyUrl:
|
||||
process.env.TEJARAT_CAR_BODY_URL ||
|
||||
"http://82.99.202.245:3027/block-inquiry-tejarat/badane",
|
||||
personUrl:
|
||||
process.env.TEJARAT_PERSON_URL ||
|
||||
"http://82.99.202.245:3027/personal-inquiry/tejarat-no",
|
||||
thirdPartyToken:
|
||||
process.env.TEJARAT_THIRD_PARTY_TOKEN || process.env.TEJARAT_TOKEN || "",
|
||||
carBodyToken:
|
||||
process.env.TEJARAT_CAR_BODY_TOKEN || process.env.TEJARAT_TOKEN || "",
|
||||
personToken:
|
||||
process.env.TEJARAT_PERSON_TOKEN || process.env.TEJARAT_TOKEN || "",
|
||||
rateLimitPerMinute: Number(process.env.RATE_LIMIT_PER_MINUTE || 5),
|
||||
retryEnabled: String(process.env.RETRY_ENABLED ?? "true").toLowerCase() !== "false",
|
||||
retryCount: Number(process.env.RETRY_COUNT || 3),
|
||||
retryDelayMs: Number(process.env.RETRY_DELAY_MS || 2000),
|
||||
dryRun: String(process.env.DRY_RUN ?? "true").toLowerCase() !== "false",
|
||||
limit: process.env.LIMIT ? Number(process.env.LIMIT) : 0,
|
||||
publicId: process.env.PUBLIC_ID || "",
|
||||
};
|
||||
|
||||
let lastRequestAt = 0;
|
||||
|
||||
main().catch(async (error) => {
|
||||
console.error("[fatal]", error && error.stack ? error.stack : error);
|
||||
await mongoose.disconnect().catch(() => undefined);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
async function main() {
|
||||
validateConfig();
|
||||
|
||||
console.log("script runned successfully");
|
||||
console.log(
|
||||
"[config] envFiles=" +
|
||||
(loadedEnvFiles.length ? loadedEnvFiles.join(",") : "none") +
|
||||
", dbName=" +
|
||||
(config.mongoDbName || "from-url-or-driver-default") +
|
||||
", collection=" +
|
||||
config.collectionName +
|
||||
", dryRun=" +
|
||||
config.dryRun +
|
||||
", rateLimitPerMinute=" +
|
||||
config.rateLimitPerMinute +
|
||||
", retryEnabled=" +
|
||||
config.retryEnabled +
|
||||
", retryCount=" +
|
||||
config.retryCount,
|
||||
);
|
||||
|
||||
await mongoose.connect(config.mongoUri, {
|
||||
autoIndex: false,
|
||||
dbName: config.mongoDbName || undefined,
|
||||
});
|
||||
const collection = mongoose.connection.collection(config.collectionName);
|
||||
|
||||
const query = {
|
||||
type: { $in: ["THIRD_PARTY", "CAR_BODY"] },
|
||||
};
|
||||
if (config.publicId) query.publicId = config.publicId;
|
||||
|
||||
const totalDocs = await collection.countDocuments(query);
|
||||
console.log(`total docs that we have to edit: ${totalDocs}`);
|
||||
|
||||
const cursor = collection
|
||||
.find(query, {
|
||||
projection: {
|
||||
publicId: 1,
|
||||
requestNo: 1,
|
||||
type: 1,
|
||||
parties: 1,
|
||||
inquiries: 1,
|
||||
},
|
||||
})
|
||||
.sort({ createdAt: 1, _id: 1 });
|
||||
|
||||
if (config.limit > 0) cursor.limit(config.limit);
|
||||
|
||||
const summary = {
|
||||
docsSeen: 0,
|
||||
docsChanged: 0,
|
||||
partiesInquired: 0,
|
||||
partiesSkipped: 0,
|
||||
partiesFailed: 0,
|
||||
personInquired: 0,
|
||||
personSkipped: 0,
|
||||
personFailed: 0,
|
||||
};
|
||||
|
||||
for await (const doc of cursor) {
|
||||
summary.docsSeen += 1;
|
||||
const label = doc.publicId || doc.requestNo || String(doc._id);
|
||||
const requestId = String(doc._id);
|
||||
console.log(`currently inquiry for doc with ${label} and requestId ${requestId}`);
|
||||
|
||||
const parties = Array.isArray(doc.parties) ? clone(doc.parties) : [];
|
||||
const inquiries = doc.inquiries && typeof doc.inquiries === "object" ? clone(doc.inquiries) : {};
|
||||
let docChanged = false;
|
||||
let inquiriesChanged = false;
|
||||
|
||||
for (let index = 0; index < parties.length; index += 1) {
|
||||
const party = parties[index];
|
||||
const partyLabel = `${label} parties[${index}] role=${party && party.role ? party.role : "-"}`;
|
||||
|
||||
const input = buildInquiryInputs(doc, party);
|
||||
if (!input.ok) {
|
||||
summary.partiesSkipped += 1;
|
||||
console.warn(`[skip] ${partyLabel}: ${input.reason}`);
|
||||
} else {
|
||||
let nextParty = party;
|
||||
let partyChanged = false;
|
||||
|
||||
for (const request of input.requests) {
|
||||
console.log(
|
||||
`[request] ${partyLabel} type=${request.type} body=${JSON.stringify(request.body)}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const inquiryResponse = await inquiryWithRetry(request.type, request.body);
|
||||
const successful = isInquirySuccessful(request.type, inquiryResponse);
|
||||
console.log(
|
||||
`[response] ${partyLabel} type=${request.type} successful=${successful} body=${JSON.stringify(inquiryResponse)}`,
|
||||
);
|
||||
|
||||
if (!successful) {
|
||||
summary.partiesFailed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextParty = applyInquiryToParty(request.type, nextParty, inquiryResponse, input.plate);
|
||||
summary.partiesInquired += 1;
|
||||
partyChanged = true;
|
||||
} catch (error) {
|
||||
summary.partiesFailed += 1;
|
||||
console.error(`[error] ${partyLabel} type=${request.type}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (partyChanged) {
|
||||
parties[index] = nextParty;
|
||||
docChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
const personInput = buildPersonInquiryInput(party);
|
||||
if (!personInput.ok) {
|
||||
summary.personSkipped += 1;
|
||||
console.warn(`[skip] ${partyLabel} person: ${personInput.reason}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[request] ${partyLabel} type=PERSON body=${JSON.stringify(personInput.body)}`);
|
||||
try {
|
||||
const personResponse = await inquiryWithRetry("PERSON", personInput.body);
|
||||
const successful = isInquirySuccessful("PERSON", personResponse);
|
||||
console.log(
|
||||
`[response] ${partyLabel} type=PERSON successful=${successful} body=${JSON.stringify(personResponse)}`,
|
||||
);
|
||||
|
||||
if (!successful) {
|
||||
summary.personFailed += 1;
|
||||
applyPersonInquiryToCaseInquiries(inquiries, party, index, false, {}, personResponse);
|
||||
inquiriesChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
applyPersonInquiryToCaseInquiries(
|
||||
inquiries,
|
||||
party,
|
||||
index,
|
||||
true,
|
||||
normalizePersonResponse(personResponse),
|
||||
);
|
||||
summary.personInquired += 1;
|
||||
inquiriesChanged = true;
|
||||
} catch (error) {
|
||||
summary.personFailed += 1;
|
||||
applyPersonInquiryToCaseInquiries(inquiries, party, index, false, {}, error);
|
||||
inquiriesChanged = true;
|
||||
console.error(`[error] ${partyLabel} type=PERSON: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!docChanged && !inquiriesChanged) {
|
||||
console.log(`[doc] ${label} no changes`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.dryRun) {
|
||||
console.log(`[dry-run] ${label} would update parties/inquiries`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const updateSet = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (docChanged) updateSet.parties = parties;
|
||||
if (inquiriesChanged) updateSet.inquiries = inquiries;
|
||||
|
||||
const result = await collection.updateOne(
|
||||
{ _id: doc._id },
|
||||
{
|
||||
$set: updateSet,
|
||||
},
|
||||
);
|
||||
summary.docsChanged += result.modifiedCount;
|
||||
console.log(`[update] ${label} matched=${result.matchedCount} modified=${result.modifiedCount}`);
|
||||
}
|
||||
|
||||
await mongoose.disconnect();
|
||||
console.log(`[done] ${JSON.stringify(summary)}`);
|
||||
}
|
||||
|
||||
function buildInquiryInputs(doc, party) {
|
||||
if (!party || typeof party !== "object") return { ok: false, reason: "party is empty" };
|
||||
if (!party.vehicle || typeof party.vehicle !== "object") {
|
||||
return { ok: false, reason: "party.vehicle is missing" };
|
||||
}
|
||||
|
||||
const plate = extractPlate(party);
|
||||
const nationalCode =
|
||||
cleanString(party.person && party.person.nationalCodeOfInsurer) ||
|
||||
cleanString(party.person && party.person.nationalCodeOfDriver);
|
||||
|
||||
if (!plate) return { ok: false, reason: "Plk1/Plk2/Plk3/PlkSrl not found" };
|
||||
if (!nationalCode) return { ok: false, reason: "nationalCodeOfInsurer/nationalCodeOfDriver missing" };
|
||||
|
||||
const serialLetter = NUMBER_TO_LETTER[String(plate.Plk2)] || cleanString(plate.Plk2);
|
||||
if (!serialLetter) return { ok: false, reason: `no Persian letter mapping for Plk2=${plate.Plk2}` };
|
||||
|
||||
if (doc.type === "THIRD_PARTY") {
|
||||
return {
|
||||
ok: true,
|
||||
plate,
|
||||
requests: [buildThirdPartyRequest(plate, serialLetter, nationalCode)],
|
||||
};
|
||||
}
|
||||
|
||||
if (doc.type === "CAR_BODY") {
|
||||
return {
|
||||
ok: true,
|
||||
plate,
|
||||
requests: [
|
||||
buildThirdPartyRequest(plate, serialLetter, nationalCode),
|
||||
buildCarBodyRequest(plate, serialLetter, nationalCode),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: false, reason: `unsupported type=${doc.type}` };
|
||||
}
|
||||
|
||||
function buildThirdPartyRequest(plate, serialLetter, nationalCode) {
|
||||
return {
|
||||
type: "THIRD_PARTY",
|
||||
body: {
|
||||
leftTwoDigits: String(plate.Plk1),
|
||||
serialLetter,
|
||||
threeDigits: String(plate.Plk3),
|
||||
rightTwoDigits: String(plate.PlkSrl),
|
||||
nationalCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildCarBodyRequest(plate, serialLetter, nationalCode) {
|
||||
return {
|
||||
type: "CAR_BODY",
|
||||
body: {
|
||||
part1: toNumber(plate.Plk1),
|
||||
part2: serialLetter,
|
||||
part3: toNumber(plate.Plk3),
|
||||
part4: toNumber(plate.PlkSrl),
|
||||
nationalCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildPersonInquiryInput(party) {
|
||||
if (!party || typeof party !== "object") return { ok: false, reason: "party is empty" };
|
||||
const person = party.person && typeof party.person === "object" ? party.person : null;
|
||||
if (!person) return { ok: false, reason: "party.person is missing" };
|
||||
|
||||
const nationalCode =
|
||||
cleanString(person.nationalCodeOfInsurer) ||
|
||||
cleanString(person.nationalCodeOfDriver);
|
||||
const birthDate = firstPresent(
|
||||
person.insurerBirthday,
|
||||
person.driverBirthday,
|
||||
person.birthday,
|
||||
);
|
||||
const gregorianBirthdate = jalaliToGregorianDate(birthDate);
|
||||
|
||||
if (!nationalCode) {
|
||||
return { ok: false, reason: "nationalCodeOfInsurer/nationalCodeOfDriver missing" };
|
||||
}
|
||||
if (!gregorianBirthdate) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `invalid insurerBirthday/driverBirthday=${cleanString(birthDate)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: {
|
||||
nationalCode,
|
||||
birthdate: gregorianBirthdate,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parsePlateId(plateId) {
|
||||
const value = cleanString(plateId);
|
||||
if (!value) return null;
|
||||
|
||||
const parts = value.split("-").map((part) => normalizePlateNumber(part));
|
||||
if (parts.length !== 4) return null;
|
||||
|
||||
const thirdPartIsLetter = LETTER_TO_NUMBER[parts[2]] !== undefined;
|
||||
const fourthPartIsLetter = LETTER_TO_NUMBER[parts[3]] !== undefined;
|
||||
|
||||
if (thirdPartIsLetter) {
|
||||
return {
|
||||
Plk1: parts[1],
|
||||
Plk2: String(LETTER_TO_NUMBER[parts[2]]),
|
||||
Plk3: parts[3],
|
||||
PlkSrl: parts[0],
|
||||
};
|
||||
}
|
||||
|
||||
if (fourthPartIsLetter) {
|
||||
return {
|
||||
Plk1: parts[2],
|
||||
Plk2: String(LETTER_TO_NUMBER[parts[3]]),
|
||||
Plk3: parts[1],
|
||||
PlkSrl: parts[0],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractPlate(party) {
|
||||
const plateFromPlateId = parsePlateId(party.vehicle && party.vehicle.plateId);
|
||||
if (plateFromPlateId) return plateFromPlateId;
|
||||
|
||||
const candidates = [
|
||||
party.vehicle && party.vehicle.inquiry && party.vehicle.inquiry.mapped,
|
||||
party.vehicle && party.vehicle.inquiry && party.vehicle.inquiry.raw,
|
||||
party.vehicle && party.vehicle.inquiry,
|
||||
party.vehicle,
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const Plk1 = firstPresent(candidate.Plk1, candidate.platePartOne);
|
||||
const Plk2 = firstPresent(candidate.Plk2, candidate.plateLetterid, candidate.plateLetterId);
|
||||
const Plk3 = firstPresent(candidate.Plk3, candidate.platePartThree);
|
||||
const PlkSrl = firstPresent(candidate.PlkSrl, candidate.plkSrl, candidate.plateSerialNumber);
|
||||
|
||||
if (
|
||||
Plk1 !== undefined &&
|
||||
Plk2 !== undefined &&
|
||||
Plk3 !== undefined &&
|
||||
PlkSrl !== undefined
|
||||
) {
|
||||
return {
|
||||
Plk1: normalizePlateNumber(Plk1),
|
||||
Plk2: normalizePlateNumber(Plk2),
|
||||
Plk3: normalizePlateNumber(Plk3),
|
||||
PlkSrl: normalizePlateNumber(PlkSrl),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function inquiryWithRetry(type, body) {
|
||||
const endpoint = getInquiryEndpoint(type);
|
||||
const url = endpoint.url;
|
||||
const token = endpoint.token;
|
||||
const accept = endpoint.accept;
|
||||
let lastError;
|
||||
|
||||
const maxAttempts = config.retryEnabled ? config.retryCount : 1;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
await waitForRateLimit();
|
||||
try {
|
||||
console.log("[http] " + type + " attempt=" + attempt + "/" + maxAttempts);
|
||||
return await postJson(url, token, body, accept);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
console.error(`[retry] ${type} attempt=${attempt} failed: ${error.message}`);
|
||||
if (attempt < maxAttempts) {
|
||||
await sleep(config.retryDelayMs * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function postJson(url, token, body, accept = "application/json") {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept,
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = text;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP ${response.status}: ${JSON.stringify(data)}`);
|
||||
error.status = response.status;
|
||||
error.data = data;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function isInquirySuccessful(type, response) {
|
||||
if (type === "CAR_BODY") return response && response.isSuccess === true && response.data;
|
||||
if (type === "PERSON") return response && response.status === 200 && response.data;
|
||||
return response && response.resultStatus === true;
|
||||
}
|
||||
|
||||
function getInquiryEndpoint(type) {
|
||||
if (type === "CAR_BODY") {
|
||||
return { url: config.carBodyUrl, token: config.carBodyToken, accept: "application/json" };
|
||||
}
|
||||
if (type === "PERSON") {
|
||||
return { url: config.personUrl, token: config.personToken, accept: "*/*" };
|
||||
}
|
||||
return { url: config.thirdPartyUrl, token: config.thirdPartyToken, accept: "application/json" };
|
||||
}
|
||||
|
||||
function applyInquiryToParty(type, party, response, originalPlate) {
|
||||
if (type === "CAR_BODY") return applyCarBodyInquiryToParty(party, response, originalPlate);
|
||||
return applyThirdPartyInquiryToParty(party, response, originalPlate);
|
||||
}
|
||||
|
||||
function applyThirdPartyInquiryToParty(party, response, originalPlate) {
|
||||
const next = clone(party);
|
||||
if (!next.vehicle) next.vehicle = {};
|
||||
if (!next.insurance) next.insurance = {};
|
||||
|
||||
const mapped = normalizeThirdPartyResponse(response, originalPlate);
|
||||
const plateId = buildPlateId(originalPlate);
|
||||
|
||||
next.vehicle.plateId = plateId || next.vehicle.plateId;
|
||||
|
||||
next.vehicle.inquiry = {
|
||||
source: "TEJARAT_BLOCK_INQUIRY",
|
||||
raw: response,
|
||||
mapped,
|
||||
refreshedAt: new Date().toISOString(),
|
||||
};
|
||||
if (party.vehicle && party.vehicle.inquiry && party.vehicle.inquiry.carBody) {
|
||||
next.vehicle.inquiry.carBody = party.vehicle.inquiry.carBody;
|
||||
}
|
||||
|
||||
next.vehicle.name = mapped.vehiclePersianName || mapped.MapTypNam || "اطلاعات این گزینه در استعلام موجود نیست";
|
||||
next.vehicle.type = mapped.persianCarType || mapped.MapUsageName || next.vehicle.type;
|
||||
next.insurance.policyNumber =
|
||||
mapped.LastCompanyDocumentNumber || mapped.insuranceNumber || next.insurance.policyNumber;
|
||||
next.insurance.company = mapped.companyPersianName || next.insurance.company;
|
||||
next.insurance.financialCeiling = mapped.financeCoverage || next.insurance.financialCeiling;
|
||||
next.insurance.startDate = mapped.persianStartDate || next.insurance.startDate;
|
||||
next.insurance.endDate = mapped.persianEndDate || next.insurance.endDate;
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyCarBodyInquiryToParty(party, response, originalPlate) {
|
||||
const next = clone(party);
|
||||
if (!next.vehicle) next.vehicle = {};
|
||||
if (!next.insurance) next.insurance = {};
|
||||
|
||||
const mapped = normalizeCarBodyResponse(response, originalPlate);
|
||||
const plateId = buildPlateId(originalPlate);
|
||||
|
||||
next.vehicle.plateId = plateId || next.vehicle.plateId;
|
||||
if (!next.vehicle.inquiry || typeof next.vehicle.inquiry !== "object") {
|
||||
next.vehicle.inquiry = {};
|
||||
}
|
||||
|
||||
next.vehicle.inquiry.carBody = {
|
||||
source: "TEJARAT_CAR_BODY_INQUIRY",
|
||||
raw: response,
|
||||
mapped,
|
||||
refreshedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
next.vehicle.name = mapped.vehicleSystemTitle || next.vehicle.name;
|
||||
next.vehicle.type = mapped.vehicleGroupTitle || next.vehicle.type;
|
||||
next.insurance.carBodyInsurance = {
|
||||
policyNumber: mapped.policyNumber,
|
||||
companyId: mapped.companyId,
|
||||
companyName: mapped.CompanyName,
|
||||
insurerName: mapped.insurerName,
|
||||
insurerNationalCode: mapped.insurerNationalCode,
|
||||
ownerNationalCode: mapped.ownerNationalCode,
|
||||
chassisNumber: mapped.chassisNumber,
|
||||
vin: mapped.vin,
|
||||
motorNumber: mapped.motorNumber,
|
||||
vehicleGroup: mapped.vehicleGroupTitle,
|
||||
vehicleSystem: mapped.vehicleSystemTitle,
|
||||
startDate: mapped.StartDate,
|
||||
endDate: mapped.EndDate,
|
||||
issueDate: mapped.IssueDate,
|
||||
noLossYearsCount: mapped.noLossYearsCount,
|
||||
lossDocuments: Array.isArray(mapped.lossDocuments) ? mapped.lossDocuments : [],
|
||||
hasEndorsement: mapped.hasEndorsement,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyPersonInquiryToCaseInquiries(inquiries, party, index, has, data, error) {
|
||||
if (!inquiries.person || typeof inquiries.person !== "object") {
|
||||
inquiries.person = {};
|
||||
}
|
||||
|
||||
const existingData =
|
||||
inquiries.person.data && typeof inquiries.person.data === "object" && !Array.isArray(inquiries.person.data)
|
||||
? inquiries.person.data
|
||||
: {};
|
||||
const existingError =
|
||||
inquiries.person.error && typeof inquiries.person.error === "object" && !Array.isArray(inquiries.person.error)
|
||||
? inquiries.person.error
|
||||
: {};
|
||||
const roleKey = party && party.role ? party.role : `party_${index}`;
|
||||
const nextData = { ...existingData };
|
||||
const nextError = { ...existingError };
|
||||
|
||||
if (has) {
|
||||
nextData[roleKey] = data || {};
|
||||
delete nextError[roleKey];
|
||||
} else {
|
||||
nextError[roleKey] = normalizeInquiryError(error);
|
||||
}
|
||||
|
||||
inquiries.person = {
|
||||
has: Object.keys(nextData).length > 0,
|
||||
data: nextData,
|
||||
...(Object.keys(nextError).length > 0 ? { error: nextError } : {}),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePersonResponse(response) {
|
||||
return response && response.data ? response.data : response;
|
||||
}
|
||||
|
||||
function normalizeInquiryError(error) {
|
||||
if (!error) return undefined;
|
||||
return {
|
||||
message: error.message || String(error),
|
||||
status: error.status,
|
||||
data: error.data,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlateId(plate) {
|
||||
if (!plate) return "";
|
||||
const serialLetter = NUMBER_TO_LETTER[String(plate.Plk2)] || cleanString(plate.Plk2);
|
||||
if (!serialLetter) return "";
|
||||
return (
|
||||
cleanString(plate.PlkSrl) +
|
||||
"-" +
|
||||
cleanString(plate.Plk1) +
|
||||
"-" +
|
||||
serialLetter +
|
||||
"-" +
|
||||
cleanString(plate.Plk3)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeThirdPartyResponse(response, originalPlate) {
|
||||
return {
|
||||
...response,
|
||||
Plk1: toNumber(originalPlate.Plk1),
|
||||
Plk2: toNumber(originalPlate.Plk2),
|
||||
Plk3: toNumber(originalPlate.Plk3),
|
||||
PlkSrl: toNumber(originalPlate.PlkSrl),
|
||||
CompanyName: response.companyPersianName,
|
||||
CompanyCode: response.companyId,
|
||||
LastCompanyDocumentNumber: response.lastCompanyInsuranceNumber || response.insuranceNumber,
|
||||
FinancialCvrCptl: response.financeCoverage,
|
||||
IssueDate: response.hIsuDte || response.persianStartDate,
|
||||
SatrtDate: response.persianStartDate,
|
||||
EndDate: response.persianEndDate,
|
||||
MapTypNam: response.vehiclePersianName,
|
||||
MapUsageName: response.MapUsageName || response.persianCarType,
|
||||
UsageField: response.persianCarType,
|
||||
UsageCode: response.usgCod,
|
||||
VehicleSystemCode: response.vehSysCod,
|
||||
CarGroupCode: response.carGrpCod,
|
||||
EdrsJson: Array.isArray(response.edrSes) ? JSON.stringify(response.edrSes) : response.EdrsJson,
|
||||
InsuranceFullName: response.fullname,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCarBodyResponse(response, originalPlate) {
|
||||
const data = response.data || {};
|
||||
return {
|
||||
...data,
|
||||
Plk1: toNumber(originalPlate.Plk1),
|
||||
Plk2: toNumber(originalPlate.Plk2),
|
||||
Plk3: toNumber(originalPlate.Plk3),
|
||||
PlkSrl: toNumber(originalPlate.PlkSrl),
|
||||
policyNumber: data.printNumber,
|
||||
CompanyName: data.companyName,
|
||||
CompanyCode: data.companyId,
|
||||
LastCompanyDocumentNumber: data.printNumber,
|
||||
InsuranceFullName: data.insurerName,
|
||||
IssueDate: data.issueDate,
|
||||
StartDate: data.beginDate,
|
||||
SatrtDate: data.beginDate,
|
||||
EndDate: data.endDate,
|
||||
EngineNumberField: data.motorNumber,
|
||||
MtrNum: data.motorNumber,
|
||||
ChassisNumberField: data.chassisNumber,
|
||||
ShsNum: data.chassisNumber,
|
||||
VinNumberField: data.vin,
|
||||
MapTypNam: data.vehicleGroupTitle,
|
||||
isSuccess: response.isSuccess,
|
||||
statusCode: response.statusCode,
|
||||
message: response.message,
|
||||
};
|
||||
}
|
||||
|
||||
function jalaliToGregorianDate(input) {
|
||||
if (input === null || input === undefined) return null;
|
||||
|
||||
const raw = normalizeDigits(typeof input === "number" ? String(input) : String(input).trim());
|
||||
if (!raw) return null;
|
||||
|
||||
let year = 0;
|
||||
let month = 0;
|
||||
let day = 0;
|
||||
|
||||
const separated = raw.match(/^(\d{4})[\-/](\d{1,2})[\-/](\d{1,2})$/);
|
||||
if (separated) {
|
||||
year = parseInt(separated[1], 10);
|
||||
month = parseInt(separated[2], 10);
|
||||
day = parseInt(separated[3], 10);
|
||||
} else {
|
||||
const digits = raw.replace(/\D/g, "");
|
||||
if (digits.length !== 8) return null;
|
||||
year = parseInt(digits.slice(0, 4), 10);
|
||||
month = parseInt(digits.slice(4, 6), 10);
|
||||
day = parseInt(digits.slice(6, 8), 10);
|
||||
}
|
||||
|
||||
if (!year || !month || !day) return null;
|
||||
|
||||
if (year >= 1900) {
|
||||
const mm = String(month).padStart(2, "0");
|
||||
const dd = String(day).padStart(2, "0");
|
||||
const result = `${year}-${mm}-${dd}`;
|
||||
return isNaN(new Date(result).getTime()) ? null : result;
|
||||
}
|
||||
|
||||
return jalaliPartsToGregorian(year, month, day);
|
||||
}
|
||||
|
||||
function jalaliPartsToGregorian(jYear, jMonth, jDay) {
|
||||
const jy = jYear - 979;
|
||||
const jm = jMonth - 1;
|
||||
const jd = jDay - 1;
|
||||
|
||||
let jDayNo =
|
||||
365 * jy + Math.floor(jy / 33) * 8 + Math.floor(((jy % 33) + 3) / 4);
|
||||
|
||||
const jalaliMonthDays = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29];
|
||||
for (let i = 0; i < jm; i += 1) {
|
||||
jDayNo += jalaliMonthDays[i];
|
||||
}
|
||||
jDayNo += jd;
|
||||
|
||||
let gDayNo = jDayNo + 79;
|
||||
|
||||
let gy = 1600 + 400 * Math.floor(gDayNo / 146097);
|
||||
gDayNo %= 146097;
|
||||
|
||||
let leap = true;
|
||||
if (gDayNo >= 36525) {
|
||||
gDayNo -= 1;
|
||||
gy += 100 * Math.floor(gDayNo / 36524);
|
||||
gDayNo %= 36524;
|
||||
if (gDayNo >= 365) gDayNo += 1;
|
||||
else leap = false;
|
||||
}
|
||||
|
||||
gy += 4 * Math.floor(gDayNo / 1461);
|
||||
gDayNo %= 1461;
|
||||
|
||||
if (gDayNo >= 366) {
|
||||
leap = false;
|
||||
gDayNo -= 1;
|
||||
gy += Math.floor(gDayNo / 365);
|
||||
gDayNo %= 365;
|
||||
}
|
||||
|
||||
const gregorianMonthDays = [
|
||||
31,
|
||||
leap ? 29 : 28,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
30,
|
||||
31,
|
||||
];
|
||||
|
||||
let gm = 0;
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
if (gDayNo < gregorianMonthDays[i]) {
|
||||
gm = i + 1;
|
||||
break;
|
||||
}
|
||||
gDayNo -= gregorianMonthDays[i];
|
||||
}
|
||||
|
||||
const gd = gDayNo + 1;
|
||||
const mm = String(gm).padStart(2, "0");
|
||||
const dd = String(gd).padStart(2, "0");
|
||||
const result = `${gy}-${mm}-${dd}`;
|
||||
|
||||
return isNaN(new Date(result).getTime()) ? null : result;
|
||||
}
|
||||
|
||||
async function waitForRateLimit() {
|
||||
const minDelayMs = Math.ceil(60000 / config.rateLimitPerMinute);
|
||||
const elapsed = Date.now() - lastRequestAt;
|
||||
if (lastRequestAt > 0 && elapsed < minDelayMs) {
|
||||
const waitMs = minDelayMs - elapsed;
|
||||
console.log(`[rate-limit] waiting ${waitMs}ms`);
|
||||
await sleep(waitMs);
|
||||
}
|
||||
lastRequestAt = Date.now();
|
||||
}
|
||||
|
||||
function validateConfig() {
|
||||
if (!config.mongoUri) throw new Error("MONGO_URL is required");
|
||||
if (!Number.isFinite(config.rateLimitPerMinute) || config.rateLimitPerMinute <= 0) {
|
||||
throw new Error("RATE_LIMIT_PER_MINUTE must be a positive number");
|
||||
}
|
||||
if (!Number.isFinite(config.retryCount) || config.retryCount <= 0) {
|
||||
throw new Error("RETRY_COUNT must be a positive number");
|
||||
}
|
||||
if (!config.thirdPartyToken) {
|
||||
throw new Error("TEJARAT_THIRD_PARTY_TOKEN or TEJARAT_TOKEN is required");
|
||||
}
|
||||
if (!config.carBodyToken) {
|
||||
throw new Error("TEJARAT_CAR_BODY_TOKEN or TEJARAT_TOKEN is required");
|
||||
}
|
||||
if (!config.personToken) {
|
||||
throw new Error("TEJARAT_PERSON_TOKEN or TEJARAT_TOKEN is required");
|
||||
}
|
||||
}
|
||||
|
||||
function loadEnvFiles(filePath) {
|
||||
const candidates = path.isAbsolute(filePath)
|
||||
? [filePath]
|
||||
: [
|
||||
path.resolve(process.cwd(), filePath),
|
||||
path.resolve(__dirname, "..", filePath),
|
||||
];
|
||||
|
||||
const loaded = [];
|
||||
for (const candidate of [...new Set(candidates)]) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
loadEnvFile(candidate);
|
||||
loaded.push(candidate);
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
const resolved = path.resolve(process.cwd(), filePath);
|
||||
if (!fs.existsSync(resolved)) return;
|
||||
|
||||
const lines = fs.readFileSync(resolved, "utf8").split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const equalIndex = trimmed.indexOf("=");
|
||||
if (equalIndex === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, equalIndex).trim();
|
||||
let value = trimmed.slice(equalIndex + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (key && process.env[key] === undefined) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function requiredEnv(...names) {
|
||||
for (const name of names) {
|
||||
if (process.env[name]) return process.env[name];
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function firstPresent(...values) {
|
||||
return values.find((value) => value !== undefined && value !== null && value !== "");
|
||||
}
|
||||
|
||||
function normalizePlateNumber(value) {
|
||||
const cleaned = normalizeDigits(cleanString(value));
|
||||
return cleaned === "" ? value : cleaned;
|
||||
}
|
||||
|
||||
function normalizeDigits(value) {
|
||||
return cleanString(value).replace(/./g, (char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
if (code >= 0x06f0 && code <= 0x06f9) return String(code - 0x06f0);
|
||||
if (code >= 0x0660 && code <= 0x0669) return String(code - 0x0660);
|
||||
return char;
|
||||
});
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : value;
|
||||
}
|
||||
|
||||
function cleanString(value) {
|
||||
return value === undefined || value === null ? "" : String(value).trim();
|
||||
}
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
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",
|
||||
|
||||
@@ -51,11 +51,14 @@ export function cloneResendUploadedDocuments(
|
||||
}
|
||||
|
||||
/** How the mobile/web client should collect each resend item (no workflow-step manager). */
|
||||
export type ResendItemInputKind = "document_camera" | "voice" | "video" | "text";
|
||||
export type ResendItemInputKind =
|
||||
| "document_camera"
|
||||
| "voice"
|
||||
| "video"
|
||||
| "text";
|
||||
|
||||
export function getResendItemInputKind(item: string): ResendItemInputKind {
|
||||
if (item === ResendItemType.VOICE) return "voice";
|
||||
if (item === ResendItemType.VIDEO) return "video";
|
||||
if (item === ResendItemType.DESCRIPTION) return "text";
|
||||
return "document_camera";
|
||||
}
|
||||
@@ -80,13 +83,15 @@ export function isResendPartyItemSatisfied(
|
||||
): boolean {
|
||||
const uploaded = row.uploadedDocuments || {};
|
||||
if (item === ResendItemType.DESCRIPTION) {
|
||||
return !!(row.userTextDescription && String(row.userTextDescription).trim());
|
||||
return !!(
|
||||
row.userTextDescription && String(row.userTextDescription).trim()
|
||||
);
|
||||
}
|
||||
if (item === ResendItemType.VOICE) {
|
||||
return !!row.resendVoiceId;
|
||||
}
|
||||
if (item === ResendItemType.VIDEO) {
|
||||
return !!row.resendVideoId;
|
||||
}
|
||||
// if (item === ResendItemType.VIDEO) {
|
||||
// return !!row.resendVideoId;
|
||||
// }
|
||||
return !!uploaded[item];
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ export enum ResendItemType {
|
||||
CAR_CERTIFICATE = "carCertificate",
|
||||
DRIVING_LICENSE = "drivingLicense",
|
||||
CAR_GREEN_CARD = "carGreenCard",
|
||||
PLATE = "plate",
|
||||
CAR_PLATE = "carPlate",
|
||||
CHASSIS_NUMBER = "chassisNumber",
|
||||
// Media evidence
|
||||
VOICE = "voice",
|
||||
VIDEO = "video",
|
||||
|
||||
/** Written / text party description (maps to FIRST_DESCRIPTION / SECOND_DESCRIPTION workflow steps) */
|
||||
DESCRIPTION = "description",
|
||||
|
||||
@@ -35,6 +35,26 @@ export enum ClaimCaseStatus {
|
||||
/** All required factor files are uploaded; damage expert validates factors (`UNDER_REVIEW` @ `EXPERT_COST_EVALUATION`). */
|
||||
EXPERT_VALIDATING_REPAIR_FACTORS = "EXPERT_VALIDATING_REPAIR_FACTORS",
|
||||
|
||||
/**
|
||||
* V4 split flow only. FileMaker has uploaded all initial required documents;
|
||||
* the file is sealed and waiting for a FileReviewer to pick it up (accident fields,
|
||||
* capture, and final blame video). Transitions to SELECTING_OUTER_PARTS when the
|
||||
* FileReviewer calls select-outer-parts after submitting accident fields.
|
||||
*/
|
||||
WAITING_FOR_FILE_REVIEWER = "WAITING_FOR_FILE_REVIEWER",
|
||||
|
||||
/**
|
||||
* V5 split flow only. The claim is fully evaluated and owner has signed;
|
||||
* the FileMaker who created the file must approve before fanavaran submission.
|
||||
*/
|
||||
WAITING_FOR_FILE_MAKER_APPROVAL = "WAITING_FOR_FILE_MAKER_APPROVAL",
|
||||
|
||||
/**
|
||||
* V5 split flow only. FileMaker rejected the completed claim back to FileReviewer
|
||||
* for correction (adjust pricing, re-do expert review, back-and-forth with user).
|
||||
*/
|
||||
FILE_MAKER_REJECTED = "FILE_MAKER_REJECTED",
|
||||
|
||||
// Final states
|
||||
COMPLETED = "COMPLETED",
|
||||
CANCELLED = "CANCELLED",
|
||||
|
||||
@@ -6,4 +6,8 @@ export enum RoleEnum {
|
||||
COMPANY = "company",
|
||||
ADMIN = "admin",
|
||||
USER = "user",
|
||||
FILE_MAKER = "file_maker",
|
||||
FILE_REVIEWER = "file_reviewer",
|
||||
SUPER_ADMIN = "super_admin",
|
||||
CALL_CENTER = "call_center",
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AiService } from "./ai.service";
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
imports: [],
|
||||
providers: [AiService],
|
||||
exports: [AiService],
|
||||
})
|
||||
|
||||
@@ -4,15 +4,13 @@ import {
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import * as FormData from "form-data";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AxiosRequestConfig } from "axios"; // TODO: Change all axios usages to HttpModule
|
||||
|
||||
@Injectable()
|
||||
export class AiService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
private apiKey: string;
|
||||
private accessToken: string = null;
|
||||
|
||||
@@ -20,18 +18,20 @@ export class AiService implements OnModuleInit {
|
||||
private readonly loginOptions: AxiosRequestConfig = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
url: `${process.env.AI_URL_V2}/auth/login`,
|
||||
url: `${this.configService.get<string>("AI_URL_V2")}/auth/login`,
|
||||
data: {
|
||||
username: process.env.AI_USERNAME,
|
||||
password: process.env.AI_PASSWORD,
|
||||
username: this.configService.get<string>("AI_USERNAME"),
|
||||
password: this.configService.get<string>("AI_PASSWORD"),
|
||||
},
|
||||
timeout: 1000, // 30 second timeout
|
||||
timeout: 1000, // TODO: Make this ENV
|
||||
};
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
private get profileOptions(): AxiosRequestConfig {
|
||||
return {
|
||||
method: "GET",
|
||||
url: `${process.env.AI_URL_V2}/auth/profile`,
|
||||
url: `${this.configService.get<string>("AI_URL_V2")}/auth/profile`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
},
|
||||
@@ -50,28 +50,16 @@ export class AiService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
const res = await this.login();
|
||||
if (res?.accessToken) {
|
||||
this.logger.verbose("AI Service Authenticated Successfully.");
|
||||
this.accessToken = res.accessToken;
|
||||
await this.getApiKey();
|
||||
this.logger.log("AI Service initialized and ready.");
|
||||
} else {
|
||||
this.logger.warn(
|
||||
"AI Service Unavailable: Login did not return an access token. Will retry on first request.",
|
||||
);
|
||||
}
|
||||
// if (res?.accessToken) {
|
||||
// this.accessToken = res.accessToken;
|
||||
// await this.getApiKey();
|
||||
// }
|
||||
} catch (error) {
|
||||
// Don't prevent app startup if AI service is temporarily unavailable
|
||||
// The service will attempt to re-authenticate when aiRequestImage is called
|
||||
this.logger.warn(
|
||||
"AI Service Unavailable: Failed during initial login. Will retry on first request.",
|
||||
);
|
||||
this.logger.warn(`Error: ${error.message}`);
|
||||
// Reset tokens so re-authentication will be attempted
|
||||
this.accessToken = null;
|
||||
this.apiKey = null;
|
||||
@@ -79,55 +67,34 @@ export class AiService implements OnModuleInit {
|
||||
}
|
||||
|
||||
private async login() {
|
||||
try {
|
||||
const loginResponse = await axios.request(this.loginOptions);
|
||||
return loginResponse.data;
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.message || err.message || "Unknown error";
|
||||
const statusCode = err.response?.status || 500;
|
||||
this.logger.error(`AI login failed: ${errorMessage} (Status: ${statusCode})`);
|
||||
if (err.response?.data) {
|
||||
this.logger.error(`AI login error details: ${JSON.stringify(err.response.data, null, 2)}`);
|
||||
}
|
||||
throw new HttpException(
|
||||
`Could not authenticate with AI service: ${errorMessage}`,
|
||||
statusCode >= 400 && statusCode < 500 ? statusCode : HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
// const loginResponse = await axios.request(this.loginOptions);
|
||||
// return loginResponse.data;
|
||||
}
|
||||
|
||||
private async getApiKey() {
|
||||
try {
|
||||
const profileResponse = await axios.request(this.profileOptions);
|
||||
this.apiKey = profileResponse.data.apiKey.key;
|
||||
this.logger.log("Successfully retrieved AI gateway API key.");
|
||||
return this.apiKey;
|
||||
} catch (err) {
|
||||
this.logger.error("Failed to retrieve AI API key:", err.message);
|
||||
throw new HttpException(
|
||||
"Could not get API key from AI service",
|
||||
HttpStatus.FAILED_DEPENDENCY,
|
||||
);
|
||||
}
|
||||
// const profileResponse = await axios.request(this.profileOptions);
|
||||
// this.apiKey = profileResponse.data.apiKey.key;
|
||||
// return this.apiKey;
|
||||
}
|
||||
|
||||
public async aiRequestImage(file: { path: string; fileName?: string }): Promise<any> {
|
||||
public async aiRequestImage(file: {
|
||||
path: string;
|
||||
fileName?: string;
|
||||
}): Promise<any> {
|
||||
// Ensure authentication is set up
|
||||
if (!this.accessToken || !this.apiKey) {
|
||||
this.logger.warn("AI service not authenticated, attempting to re-authenticate...");
|
||||
try {
|
||||
const res = await this.login();
|
||||
if (res?.accessToken) {
|
||||
this.accessToken = res.accessToken;
|
||||
await this.getApiKey();
|
||||
} else {
|
||||
throw new HttpException(
|
||||
"AI Service authentication failed",
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
// if (res?.accessToken) {
|
||||
// this.accessToken = res.accessToken;
|
||||
// await this.getApiKey();
|
||||
// } else {
|
||||
// throw new HttpException(
|
||||
// "AI Service authentication failed",
|
||||
// HttpStatus.UNAUTHORIZED,
|
||||
// );
|
||||
// }
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to re-authenticate AI service:", error.message);
|
||||
throw new HttpException(
|
||||
"AI Service authentication failed",
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
@@ -140,100 +107,69 @@ export class AiService implements OnModuleInit {
|
||||
? file.path
|
||||
: join(process.cwd(), file.path.replace(/^\.\//, ""));
|
||||
|
||||
this.logger.log(`Processing AI image request for: ${filePath}`);
|
||||
|
||||
// Check if file exists
|
||||
if (!existsSync(filePath)) {
|
||||
this.logger.error(`File not found at path: ${filePath}`);
|
||||
throw new HttpException(
|
||||
`File not found: ${file.path}`,
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
// const form = new FormData();
|
||||
const fileStream = createReadStream(filePath);
|
||||
|
||||
// Append file with filename if available
|
||||
if (file.fileName) {
|
||||
form.append("images", fileStream, file.fileName);
|
||||
// form.append("images", fileStream, file.fileName);
|
||||
} else {
|
||||
// Extract filename from path if not provided
|
||||
const pathParts = filePath.split("/");
|
||||
const extractedFileName = pathParts[pathParts.length - 1];
|
||||
form.append("images", fileStream, extractedFileName);
|
||||
// form.append("images", fileStream, extractedFileName);
|
||||
}
|
||||
|
||||
try {
|
||||
const requestHeaders = {
|
||||
...this.imageProcessOptions.headers,
|
||||
...form.getHeaders(),
|
||||
// ...form.getHeaders(),
|
||||
};
|
||||
|
||||
this.logger.log(`[STEP 1/4] Sending request to AI service: ${this.imageProcessOptions.url}`);
|
||||
this.logger.log(`[STEP 1/4] File: ${filePath}, Filename: ${file.fileName || 'extracted from path'}`);
|
||||
this.logger.log(`[STEP 1/4] FormData Content-Type: ${form.getHeaders()['content-type']}`);
|
||||
this.logger.log(`[STEP 1/4] Authorization header present: ${!!requestHeaders.Authorization}`);
|
||||
this.logger.log(`[STEP 1/4] Gateway API key present: ${!!this.apiKey}`);
|
||||
this.logger.log(`[STEP 1/4] Request method: POST`);
|
||||
this.logger.log(`[STEP 1/4] FormData field name: "images"`);
|
||||
|
||||
// Get file stats for debugging
|
||||
const fs = require('fs');
|
||||
const fs = require("fs");
|
||||
const stats = fs.statSync(filePath);
|
||||
this.logger.log(`[STEP 1/4] File size: ${stats.size} bytes`);
|
||||
this.logger.log(`[STEP 1/4] File exists: ${existsSync(filePath)}`);
|
||||
|
||||
const response = await axios.request({
|
||||
...this.imageProcessOptions,
|
||||
headers: requestHeaders,
|
||||
data: form,
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
});
|
||||
|
||||
this.logger.log(`[STEP 2/4] Successfully received response from AI service (Status: ${response.status})`);
|
||||
// const response = await axios.request({
|
||||
// ...this.imageProcessOptions,
|
||||
// headers: requestHeaders,
|
||||
// // data: form,
|
||||
// maxContentLength: Infinity,
|
||||
// maxBodyLength: Infinity,
|
||||
// });
|
||||
|
||||
// Validate response structure
|
||||
if (!response.data) {
|
||||
this.logger.error(`[ERROR] AI response is empty or missing data`);
|
||||
throw new HttpException(
|
||||
"AI Service returned empty response",
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
// if (!response.data) {
|
||||
// throw new HttpException(
|
||||
// "AI Service returned empty response",
|
||||
// HttpStatus.BAD_GATEWAY,
|
||||
// );
|
||||
// }
|
||||
|
||||
// Check for error in response first (AI service returns 201 with error in body)
|
||||
if (response.data.error) {
|
||||
this.logger.error(`[ERROR] AI service returned an error in response body`);
|
||||
this.logger.error(`[ERROR] Error message: ${response.data.error}`);
|
||||
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response.data, null, 2)}`);
|
||||
throw new HttpException(
|
||||
`AI Service error: ${response.data.error}`,
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
// // Check for error in response first (AI service returns 201 with error in body)
|
||||
// if (response.data.error) {
|
||||
// throw new HttpException(
|
||||
// `AI Service error: ${response.data.error}`,
|
||||
// HttpStatus.BAD_GATEWAY,
|
||||
// );
|
||||
// }
|
||||
|
||||
// Check for processed image (downloadLink)
|
||||
if (!response.data.downloadLink) {
|
||||
this.logger.error(`[ERROR] AI response missing processed image (downloadLink)`);
|
||||
this.logger.error(`[ERROR] Response structure: ${JSON.stringify(Object.keys(response.data))}`);
|
||||
this.logger.error(`[ERROR] Full response: ${JSON.stringify(response.data, null, 2)}`);
|
||||
throw new HttpException(
|
||||
"AI Service did not return processed image (downloadLink missing)",
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
// // Check for processed image (downloadLink)
|
||||
// if (!response.data.downloadLink) {
|
||||
// throw new HttpException(
|
||||
// "AI Service did not return processed image (downloadLink missing)",
|
||||
// HttpStatus.BAD_GATEWAY,
|
||||
// );
|
||||
// }
|
||||
|
||||
// Check for reports
|
||||
if (!response.data.reports) {
|
||||
this.logger.warn(`[WARNING] AI response missing reports object, but downloadLink exists`);
|
||||
this.logger.warn(`[WARNING] Response keys: ${JSON.stringify(Object.keys(response.data))}`);
|
||||
}
|
||||
|
||||
this.logger.log(`[STEP 3/4] Validated AI response - downloadLink: ${response.data.downloadLink ? 'present' : 'missing'}, reports: ${response.data.reports ? 'present' : 'missing'}`);
|
||||
|
||||
return response.data;
|
||||
// return response.data;
|
||||
} catch (er) {
|
||||
// Determine error source
|
||||
let errorSource = "UNKNOWN";
|
||||
@@ -242,7 +178,10 @@ export class AiService implements OnModuleInit {
|
||||
|
||||
if (er.response) {
|
||||
errorSource = "AI_SERVICE_RESPONSE";
|
||||
errorMessage = er.response?.data?.message || er.message || `HTTP ${er.response.status}`;
|
||||
errorMessage =
|
||||
er.response?.data?.message ||
|
||||
er.message ||
|
||||
`HTTP ${er.response.status}`;
|
||||
errorDetails = er.response?.data
|
||||
? JSON.stringify(er.response.data, null, 2)
|
||||
: `Status: ${er.response.status}, StatusText: ${er.response.statusText}`;
|
||||
@@ -255,14 +194,6 @@ export class AiService implements OnModuleInit {
|
||||
errorMessage = er.message || "Error setting up request";
|
||||
}
|
||||
|
||||
this.logger.error(`[ERROR] AI request failed - Source: ${errorSource}`);
|
||||
this.logger.error(`[ERROR] File path: ${filePath}`);
|
||||
this.logger.error(`[ERROR] Error message: ${errorMessage}`);
|
||||
this.logger.error(`[ERROR] Error details: ${errorDetails}`);
|
||||
if (er.stack) {
|
||||
this.logger.error(`[ERROR] Stack trace: ${er.stack}`);
|
||||
}
|
||||
|
||||
// Re-throw with detailed error information
|
||||
throw new HttpException(
|
||||
`[${errorSource}] ${errorMessage}`,
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { join } from "node:path";
|
||||
import { Module, ValidationPipe } from "@nestjs/common";
|
||||
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
|
||||
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 { ScheduleModule } from "@nestjs/schedule";
|
||||
import { ServeStaticModule } from "@nestjs/serve-static";
|
||||
import * as dotenv from "dotenv";
|
||||
import { CommandModule } from "nestjs-command";
|
||||
import { AiModule } from "./ai/ai.module";
|
||||
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";
|
||||
@@ -25,35 +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";
|
||||
|
||||
dotenv.config();
|
||||
dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
||||
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: [
|
||||
CommandModule,
|
||||
ScheduleModule.forRoot(),
|
||||
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.forRoot(
|
||||
`mongodb://${process.env.MONGO_URL}:${process.env.MONGO_PORT}/`,
|
||||
{
|
||||
dbName: "yara724",
|
||||
autoIndex: true,
|
||||
user: process.env.MONGO_USER,
|
||||
pass: process.env.MONGO_PASS,
|
||||
authMechanism: "SCRAM-SHA-256",
|
||||
tls: true,
|
||||
tlsAllowInvalidCertificates: true,
|
||||
connectionFactory: (connection) => {
|
||||
applyIranFaTimestampPlugin(connection);
|
||||
return connection;
|
||||
},
|
||||
},
|
||||
),
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
ClientModule,
|
||||
@@ -64,12 +59,15 @@ dotenv.config({ path: `.${process.env.NODE_ENV}.env` });
|
||||
SystemSettingsModule,
|
||||
ExpertBlameModule,
|
||||
ClaimRequestManagementModule,
|
||||
FanavaranModule,
|
||||
ExpertClaimModule,
|
||||
CaseExpertReportModule,
|
||||
AiModule,
|
||||
ReportsModule,
|
||||
ExpertInsurerModule,
|
||||
LookupsModule,
|
||||
WorkflowStepManagementModule,
|
||||
SuperAdminModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [
|
||||
|
||||
@@ -20,9 +20,9 @@ import {
|
||||
ApiBearerAuth,
|
||||
} from "@nestjs/swagger";
|
||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||
import { CaptchaAccountService } from "src/auth/auth-services/captcha-account.service";
|
||||
import { CaptchaChallengeService } from "src/captcha/captcha-challenge.service";
|
||||
import { CaptchaResponseDto } from "src/auth/dto/captcha-response.dto";
|
||||
import { GetActorCaptchaQueryDto } from "src/auth/dto/get-actor-captcha.dto";
|
||||
import { GetCaptchaImageQueryDto } from "src/auth/dto/get-captcha-image-query.dto";
|
||||
import {
|
||||
ForgetPasswordSendCodeDto,
|
||||
ForgetPasswordVerifyCodeDto,
|
||||
@@ -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";
|
||||
|
||||
@@ -45,33 +46,29 @@ import { CurrentUser } from "src/decorators/user.decorator";
|
||||
export class ActorAuthController {
|
||||
constructor(
|
||||
private readonly actorAuthService: ActorAuthService,
|
||||
private readonly captchaAccountService: CaptchaAccountService,
|
||||
private readonly captchaChallengeService: CaptchaChallengeService,
|
||||
) {}
|
||||
|
||||
@Get("captcha")
|
||||
@ApiOperation({
|
||||
summary: "Get login captcha for an actor account",
|
||||
summary: "Get a login captcha",
|
||||
description:
|
||||
"Returns an SVG captcha stored on the actor record.\n\n" +
|
||||
"**Viewing the image:** Swagger displays `image` as plain text. To see the captcha:\n" +
|
||||
"- Add `format=raw` and open the request URL in a new browser tab, or\n" +
|
||||
"- Copy the full `image` value from the JSON response and paste it into the browser address bar, or\n" +
|
||||
"- In your app: `<img src={response.image} alt=\"captcha\" />`\n\n" +
|
||||
"Type the characters shown in the image into the `captcha` field on POST /actor/login.",
|
||||
"Issues a new captcha challenge. Returns `captchaId`, `image`, and `expiresAt`. " +
|
||||
"Send `captchaId` and the typed characters as `captcha` on POST /actor/login.\n\n" +
|
||||
"Optional `format=raw` returns image/svg+xml for browser preview (same captchaId is in JSON when omitted).",
|
||||
})
|
||||
@ApiResponse({ status: 200, type: CaptchaResponseDto })
|
||||
async getCaptcha(
|
||||
@Query() query: GetActorCaptchaQueryDto,
|
||||
@Query() query: GetCaptchaImageQueryDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const result = await this.captchaAccountService.issueActorCaptcha(
|
||||
query.username,
|
||||
query.role,
|
||||
);
|
||||
const result = await this.captchaChallengeService.issue();
|
||||
|
||||
if (query.format === "raw") {
|
||||
const base64 = result.image.replace(/^data:image\/svg\+xml;base64,/, "");
|
||||
const svg = Buffer.from(base64, "base64").toString("utf8");
|
||||
const svg = await this.captchaChallengeService.getImageById(
|
||||
result.captchaId,
|
||||
);
|
||||
res.setHeader("X-Captcha-Id", result.captchaId);
|
||||
res.type("image/svg+xml");
|
||||
res.send(svg);
|
||||
return;
|
||||
@@ -80,11 +77,26 @@ export class ActorAuthController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get("captcha/:captchaId/image")
|
||||
@ApiOperation({
|
||||
summary: "View captcha image by id",
|
||||
description: "Returns raw SVG for a previously issued captcha challenge.",
|
||||
})
|
||||
async getCaptchaImage(
|
||||
@Param("captchaId") captchaId: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const svg = await this.captchaChallengeService.getImageById(captchaId);
|
||||
res.type("image/svg+xml");
|
||||
res.send(svg);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the unified actor onboarding flow instead. This endpoint
|
||||
* will be removed in a future release.
|
||||
*/
|
||||
@Post("register/genuine")
|
||||
@UseGuards(SuperAdminGuard)
|
||||
@ApiOperation({
|
||||
deprecated: true,
|
||||
summary: "[DEPRECATED] Genuine actor registration",
|
||||
@@ -101,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",
|
||||
@@ -113,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) {
|
||||
@@ -140,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,
|
||||
@@ -149,11 +165,13 @@ 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",
|
||||
password: "123321",
|
||||
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
captcha: "a7bx2",
|
||||
},
|
||||
},
|
||||
@@ -164,6 +182,7 @@ export class ActorAuthController {
|
||||
role: "expert",
|
||||
username: "blame@gmail.com",
|
||||
password: "123321",
|
||||
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
captcha: "a7bx2",
|
||||
},
|
||||
},
|
||||
@@ -174,6 +193,19 @@ export class ActorAuthController {
|
||||
role: "damage_expert",
|
||||
username: "claim@gmail.com",
|
||||
password: "123321",
|
||||
captchaId: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ export class UserAuthController {
|
||||
@Post("/send-otp")
|
||||
@ApiBody({
|
||||
type: UserLoginDto,
|
||||
description: "user login api -- call this api and send otp",
|
||||
description: "Users can ask for OTP via this API and receive it",
|
||||
})
|
||||
@ApiAcceptedResponse()
|
||||
async sendOtpRq(@Body() body: UserLoginDto) {
|
||||
@@ -30,6 +30,7 @@ export class UserAuthController {
|
||||
linkToken: body.linkToken,
|
||||
linkContext: body.linkContext,
|
||||
});
|
||||
|
||||
if (res) {
|
||||
throw new HttpException(res, HttpStatus.ACCEPTED);
|
||||
}
|
||||
@@ -39,7 +40,8 @@ export class UserAuthController {
|
||||
@UseGuards(LocalUserAuthGuard)
|
||||
@ApiBody({
|
||||
type: UserVerifyOtp,
|
||||
description: "user verify otp -- call this api and get a tokens",
|
||||
description:
|
||||
"Users can send their credentials and get their access token to server",
|
||||
})
|
||||
@ApiAcceptedResponse()
|
||||
async login(@Body() body, @Req() req, @CurrentUser() user) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
RegisterDtoRs,
|
||||
} from "src/auth/dto/actor/register.actor.dto";
|
||||
import { StateListDtoRs } from "src/auth/dto/actor/states.dto";
|
||||
import { CaptchaAccountService } from "src/auth/auth-services/captcha-account.service";
|
||||
import { CaptchaChallengeService } from "src/captcha/captcha-challenge.service";
|
||||
import { ClientDbService } from "src/client/entities/db-service/client.db.service";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { UserType } from "src/Types&Enums/userType.enum";
|
||||
@@ -27,9 +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 { MailService } from "src/utils/mail/mail.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> = {};
|
||||
@@ -50,10 +53,13 @@ export class ActorAuthService {
|
||||
private readonly fieldExpertDbService: FieldExpertDbService,
|
||||
private readonly registrarDbService: RegistrarDbService,
|
||||
private readonly insurerExpertDbService: InsurerExpertDbService,
|
||||
// private readonly mailService: MailService, // Mailer disabled – not used
|
||||
private readonly clientDbService: ClientDbService,
|
||||
private readonly otpService: OtpGeneratorService,
|
||||
private readonly captchaAccountService: CaptchaAccountService,
|
||||
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
|
||||
@@ -65,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)
|
||||
@@ -73,7 +79,10 @@ 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)
|
||||
@@ -81,7 +90,7 @@ export class ActorAuthService {
|
||||
_id: new Types.ObjectId(userId),
|
||||
});
|
||||
else
|
||||
res = await this.fieldExpertDbService.findOne({ email: username });
|
||||
res = await this.fieldExpertDbService.findByLoginIdentifier(username);
|
||||
break;
|
||||
case RoleEnum.REGISTRAR:
|
||||
if (username == null && userId)
|
||||
@@ -93,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;
|
||||
}
|
||||
@@ -114,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;
|
||||
@@ -132,16 +186,17 @@ 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, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
secret: `${process.env.JWT_SECRET}`,
|
||||
expiresIn: "1h",
|
||||
});
|
||||
|
||||
@@ -161,9 +216,7 @@ export class ActorAuthService {
|
||||
throw new UnauthorizedException("user not assigned to this role");
|
||||
}
|
||||
if (!(await this.hashService.compare(pass, user.password))) {
|
||||
throw new UnauthorizedException(
|
||||
"password is incorrect or access Denied",
|
||||
);
|
||||
throw new UnauthorizedException("password is incorrect or access Denied");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
@@ -178,13 +231,11 @@ export class ActorAuthService {
|
||||
if (typeof password !== "string" || !password) {
|
||||
throw new BadRequestException("password is required");
|
||||
}
|
||||
const captchaId =
|
||||
typeof body?.captchaId === "string" ? body.captchaId : undefined;
|
||||
const captcha =
|
||||
typeof body?.captcha === "string" ? body.captcha : undefined;
|
||||
await this.captchaAccountService.verifyActorCaptcha(
|
||||
username,
|
||||
role,
|
||||
captcha,
|
||||
);
|
||||
await this.captchaChallengeService.verify(captchaId, captcha);
|
||||
const actor = await this.validateActor(username, password, role);
|
||||
return this.issueActorTokens(actor);
|
||||
}
|
||||
@@ -229,7 +280,7 @@ export class ActorAuthService {
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
phone: body.phone,
|
||||
mobile:body.mobile,
|
||||
mobile: body.mobile,
|
||||
city: body.city,
|
||||
state: body.state,
|
||||
address: body.address,
|
||||
@@ -449,13 +500,7 @@ export class ActorAuthService {
|
||||
"state",
|
||||
"address",
|
||||
],
|
||||
field_expert: [
|
||||
"firstName",
|
||||
"lastName",
|
||||
"email",
|
||||
"phone",
|
||||
"mobile",
|
||||
],
|
||||
field_expert: ["firstName", "lastName", "email", "phone", "mobile"],
|
||||
registrar: ["email"],
|
||||
};
|
||||
|
||||
@@ -481,7 +526,11 @@ export class ActorAuthService {
|
||||
}
|
||||
|
||||
// fetch user detail (document or plain object)
|
||||
const document = await this.dynamicDbController(role, currentUser.role === "company" ? currentUser.username : null, userId);
|
||||
const document = await this.dynamicDbController(
|
||||
role,
|
||||
currentUser.role === "company" ? currentUser.username : null,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (!document) throw new NotFoundException("Profile not found");
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
CaptchaAuthErrorCode,
|
||||
throwCaptchaAuthError,
|
||||
} from "src/auth/auth-services/captcha-auth.error";
|
||||
import { CaptchaResponseDto } from "src/auth/dto/captcha-response.dto";
|
||||
import { CaptchaService } from "src/captcha/captcha.service";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
|
||||
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
|
||||
import { FieldExpertDbService } from "src/users/entities/db-service/field-expert.db.service";
|
||||
import { InsurerExpertDbService } from "src/users/entities/db-service/insurer-expert.db.service";
|
||||
import { RegistrarDbService } from "src/users/entities/db-service/registrar.db.service";
|
||||
import { HashService } from "src/utils/hash/hash.service";
|
||||
|
||||
type CaptchaRecord = {
|
||||
captcha?: string | null;
|
||||
captchaExpire?: number | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaAccountService {
|
||||
constructor(
|
||||
private readonly captchaService: CaptchaService,
|
||||
private readonly hashService: HashService,
|
||||
private readonly expertDbService: ExpertDbService,
|
||||
private readonly damageExpertDbService: DamageExpertDbService,
|
||||
private readonly fieldExpertDbService: FieldExpertDbService,
|
||||
private readonly registrarDbService: RegistrarDbService,
|
||||
private readonly insurerExpertDbService: InsurerExpertDbService,
|
||||
) {}
|
||||
|
||||
async issueActorCaptcha(
|
||||
username: string,
|
||||
role: RoleEnum,
|
||||
): Promise<CaptchaResponseDto> {
|
||||
const actor = await this.findActor(role, username);
|
||||
if (!actor) {
|
||||
throw new NotFoundException("Actor account not found");
|
||||
}
|
||||
|
||||
const generated = this.captchaService.generate();
|
||||
const captchaHash = await this.hashService.hash(
|
||||
this.captchaService.normalizeAnswer(generated.text),
|
||||
);
|
||||
await this.updateActorCaptcha(role, username, captchaHash, generated.expiresAt);
|
||||
|
||||
return { image: generated.image, expiresAt: generated.expiresAt };
|
||||
}
|
||||
|
||||
async verifyActorCaptcha(
|
||||
username: string,
|
||||
role: RoleEnum,
|
||||
answer: string | undefined,
|
||||
): Promise<void> {
|
||||
const actor = await this.findActor(role, username);
|
||||
if (!actor) {
|
||||
throw new NotFoundException("Actor account not found");
|
||||
}
|
||||
await this.assertCaptcha(actor, answer);
|
||||
await this.updateActorCaptcha(role, username, null, 0);
|
||||
}
|
||||
|
||||
private async assertCaptcha(
|
||||
record: CaptchaRecord | null | undefined,
|
||||
answer: string | undefined,
|
||||
): Promise<void> {
|
||||
if (!answer?.trim()) {
|
||||
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
|
||||
}
|
||||
if (!record?.captcha) {
|
||||
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
|
||||
}
|
||||
const now = Date.now();
|
||||
if (!record.captchaExpire || record.captchaExpire < now) {
|
||||
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_EXPIRED);
|
||||
}
|
||||
const ok = await this.hashService.compare(
|
||||
this.captchaService.normalizeAnswer(answer),
|
||||
record.captcha,
|
||||
);
|
||||
if (!ok) {
|
||||
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
private async findActor(role: RoleEnum, username: string) {
|
||||
switch (role) {
|
||||
case RoleEnum.EXPERT:
|
||||
return this.expertDbService.findOne({ email: username });
|
||||
case RoleEnum.DAMAGE_EXPERT:
|
||||
return this.damageExpertDbService.findOne({ email: username });
|
||||
case RoleEnum.FIELD_EXPERT:
|
||||
return this.fieldExpertDbService.findOne({ email: username });
|
||||
case RoleEnum.REGISTRAR:
|
||||
return this.registrarDbService.findOne({ email: username });
|
||||
case RoleEnum.COMPANY:
|
||||
return this.insurerExpertDbService.findOne({ email: username });
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async updateActorCaptcha(
|
||||
role: RoleEnum,
|
||||
username: string,
|
||||
captcha: string | null,
|
||||
captchaExpire: number,
|
||||
) {
|
||||
const update = { captcha, captchaExpire };
|
||||
switch (role) {
|
||||
case RoleEnum.EXPERT:
|
||||
return this.expertDbService.findOneAndUpdate({ email: username }, update);
|
||||
case RoleEnum.DAMAGE_EXPERT:
|
||||
return this.damageExpertDbService.findOneAndUpdate({ email: username }, update);
|
||||
case RoleEnum.FIELD_EXPERT:
|
||||
return this.fieldExpertDbService.findOneAndUpdate({ email: username }, update);
|
||||
case RoleEnum.REGISTRAR:
|
||||
return this.registrarDbService.findOneAndUpdate({ email: username }, update);
|
||||
case RoleEnum.COMPANY:
|
||||
return this.insurerExpertDbService.updateOne({ email: username }, update);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
|
||||
export enum CaptchaAuthErrorCode {
|
||||
CAPTCHA_REQUIRED = "CAPTCHA_REQUIRED",
|
||||
CAPTCHA_NOT_FOUND = "CAPTCHA_NOT_FOUND",
|
||||
CAPTCHA_EXPIRED = "CAPTCHA_EXPIRED",
|
||||
CAPTCHA_INVALID = "CAPTCHA_INVALID",
|
||||
}
|
||||
@@ -12,6 +13,8 @@ export enum CaptchaAuthErrorCode {
|
||||
const messages: Record<CaptchaAuthErrorCode, string> = {
|
||||
[CaptchaAuthErrorCode.CAPTCHA_REQUIRED]:
|
||||
"Captcha is required. Request a new captcha image first.",
|
||||
[CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND]:
|
||||
"Captcha id was not found. Request a new captcha image.",
|
||||
[CaptchaAuthErrorCode.CAPTCHA_EXPIRED]:
|
||||
"Captcha has expired. Request a new captcha image.",
|
||||
[CaptchaAuthErrorCode.CAPTCHA_INVALID]: "Captcha is invalid.",
|
||||
@@ -25,7 +28,10 @@ export function captchaAuthErrorBody(code: CaptchaAuthErrorCode) {
|
||||
}
|
||||
|
||||
export function throwCaptchaAuthError(code: CaptchaAuthErrorCode): never {
|
||||
if (code === CaptchaAuthErrorCode.CAPTCHA_REQUIRED) {
|
||||
if (
|
||||
code === CaptchaAuthErrorCode.CAPTCHA_REQUIRED ||
|
||||
code === CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND
|
||||
) {
|
||||
throw new BadRequestException(captchaAuthErrorBody(code));
|
||||
}
|
||||
throw new UnauthorizedException(captchaAuthErrorBody(code));
|
||||
|
||||
@@ -34,9 +34,9 @@ export function userAuthErrorBody(code: UserAuthErrorCode) {
|
||||
}
|
||||
|
||||
export function throwUserAuthError(code: UserAuthErrorCode): never {
|
||||
if (code === UserAuthErrorCode.OTP_REQUEST_TOO_SOON) {
|
||||
throw new BadRequestException(userAuthErrorBody(code));
|
||||
}
|
||||
// if (code === UserAuthErrorCode.OTP_REQUEST_TOO_SOON) {
|
||||
// throw new BadRequestException(userAuthErrorBody(code));
|
||||
// }
|
||||
|
||||
if (code === UserAuthErrorCode.LINK_MOBILE_MISMATCH) {
|
||||
throw new ForbiddenException(userAuthErrorBody(code));
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
UserAuthErrorCode,
|
||||
throwUserAuthError,
|
||||
} from "src/auth/auth-services/user-auth-error";
|
||||
import { ClaimCase } from "src/claim-request-management/entites/schema/claim-cases.schema";
|
||||
import { ClaimRequestManagementModel } from "src/claim-request-management/entites/schema/claim-request-management.schema";
|
||||
import { normalizeIranMobile } from "src/helpers/iran-mobile";
|
||||
import { BlameRequest } from "src/request-management/entities/schema/blame-cases.schema";
|
||||
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
|
||||
import { RequestManagementModel } from "src/request-management/entities/schema/request-management.schema";
|
||||
@@ -20,6 +22,8 @@ export class UserLinkAccessService {
|
||||
private readonly blameRequestModel: Model<BlameRequest>,
|
||||
@InjectModel(ClaimRequestManagementModel.name)
|
||||
private readonly claimRequestManagementModel: Model<ClaimRequestManagementModel>,
|
||||
@InjectModel(ClaimCase.name)
|
||||
private readonly claimCaseModel: Model<ClaimCase>,
|
||||
private readonly userDbService: UserDbService,
|
||||
) {}
|
||||
|
||||
@@ -39,10 +43,12 @@ export class UserLinkAccessService {
|
||||
throwUserAuthError(UserAuthErrorCode.LINK_NOT_FOUND);
|
||||
}
|
||||
|
||||
const normalizedMobile = this.normalizePhone(params.mobile);
|
||||
const normalizedMobile = normalizeIranMobile(params.mobile);
|
||||
if (
|
||||
!normalizedMobile ||
|
||||
!allowedMobiles.some((mobile) => this.normalizePhone(mobile) === normalizedMobile)
|
||||
!allowedMobiles.some(
|
||||
(mobile) => normalizeIranMobile(mobile) === normalizedMobile,
|
||||
)
|
||||
) {
|
||||
throwUserAuthError(UserAuthErrorCode.LINK_MOBILE_MISMATCH);
|
||||
}
|
||||
@@ -58,15 +64,18 @@ export class UserLinkAccessService {
|
||||
const context = this.normalizeContext(linkContext);
|
||||
const allowedMobiles = new Set<string>();
|
||||
|
||||
const [legacyRequest, blameRequest, claimRequest] = await Promise.all([
|
||||
const [legacyRequest, blameRequest, legacyClaim, claimCase] =
|
||||
await Promise.all([
|
||||
this.requestManagementModel.findById(id).lean().exec(),
|
||||
this.blameRequestModel.findById(id).lean().exec(),
|
||||
this.claimRequestManagementModel.findById(id).lean().exec(),
|
||||
this.claimCaseModel.findById(id).lean().exec(),
|
||||
]);
|
||||
|
||||
this.addLegacyRequestPhones(allowedMobiles, legacyRequest, context);
|
||||
this.addBlameRequestPhones(allowedMobiles, blameRequest, context);
|
||||
await this.addClaimOwnerPhone(allowedMobiles, claimRequest);
|
||||
await this.addLegacyClaimOwnerPhone(allowedMobiles, legacyClaim);
|
||||
await this.addClaimCaseOwnerPhone(allowedMobiles, claimCase);
|
||||
|
||||
return Array.from(allowedMobiles);
|
||||
}
|
||||
@@ -119,7 +128,7 @@ export class UserLinkAccessService {
|
||||
}
|
||||
}
|
||||
|
||||
private async addClaimOwnerPhone(
|
||||
private async addLegacyClaimOwnerPhone(
|
||||
allowedMobiles: Set<string>,
|
||||
claimRequest: any,
|
||||
) {
|
||||
@@ -146,29 +155,51 @@ export class UserLinkAccessService {
|
||||
_id: new Types.ObjectId(ownerUserIdText),
|
||||
});
|
||||
this.addPhone(allowedMobiles, user?.mobile);
|
||||
this.addPhone(allowedMobiles, user?.username);
|
||||
}
|
||||
}
|
||||
|
||||
/** V2 `claimCases` — token in `/caseClaim?token=...` SMS links. */
|
||||
private async addClaimCaseOwnerPhone(
|
||||
allowedMobiles: Set<string>,
|
||||
claimCase: any,
|
||||
) {
|
||||
if (!claimCase?.owner?.userId) return;
|
||||
|
||||
const ownerUserIdText = String(claimCase.owner.userId);
|
||||
if (claimCase.blameRequestId) {
|
||||
const blameRequest = await this.blameRequestModel
|
||||
.findById(claimCase.blameRequestId)
|
||||
.lean()
|
||||
.exec();
|
||||
const ownerParty = (blameRequest?.parties || []).find(
|
||||
(party: any) =>
|
||||
party?.person?.userId && String(party.person.userId) === ownerUserIdText,
|
||||
);
|
||||
this.addPhone(allowedMobiles, ownerParty?.person?.phoneNumber);
|
||||
}
|
||||
|
||||
if (Types.ObjectId.isValid(ownerUserIdText)) {
|
||||
const user = await this.userDbService.findOne({
|
||||
_id: new Types.ObjectId(ownerUserIdText),
|
||||
});
|
||||
this.addPhone(allowedMobiles, user?.mobile);
|
||||
this.addPhone(allowedMobiles, user?.username);
|
||||
}
|
||||
}
|
||||
|
||||
private addPhone(allowedMobiles: Set<string>, phone?: string) {
|
||||
const normalized = this.normalizePhone(phone);
|
||||
const normalized = normalizeIranMobile(phone);
|
||||
if (normalized) allowedMobiles.add(normalized);
|
||||
}
|
||||
|
||||
private normalizePhone(phone?: string): string | undefined {
|
||||
if (!phone) return undefined;
|
||||
|
||||
const digits = String(phone).replace(/\D/g, "");
|
||||
if (!digits) return undefined;
|
||||
if (digits.startsWith("0098")) return `0${digits.slice(4)}`;
|
||||
if (digits.startsWith("98") && digits.length === 12) {
|
||||
return `0${digits.slice(2)}`;
|
||||
}
|
||||
if (digits.length === 10 && digits.startsWith("9")) return `0${digits}`;
|
||||
return digits;
|
||||
}
|
||||
|
||||
private normalizeContext(linkContext?: string): string | undefined {
|
||||
return linkContext?.trim().toUpperCase();
|
||||
const ctx = linkContext?.trim().toUpperCase();
|
||||
if (!ctx) return undefined;
|
||||
if (ctx === "USER" || ctx === "USER1") return "FIRST";
|
||||
if (ctx === "USER2") return "SECOND";
|
||||
if (ctx === "CASECLAIM" || ctx === "CLAIM") return undefined;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private matchesRoleContext(context: string, role?: string): boolean {
|
||||
|
||||
@@ -7,6 +7,17 @@ import {
|
||||
} from "src/auth/auth-services/user-auth-error";
|
||||
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
|
||||
import { LoginDtoRs } from "src/auth/dto/user/login.dto";
|
||||
import {
|
||||
buildUserLookupByPhone,
|
||||
normalizeIranMobile,
|
||||
} from "src/helpers/iran-mobile";
|
||||
import {
|
||||
computeOtpExpireMs,
|
||||
FAKE_OTP_CODE,
|
||||
isFakeOtpEnabled,
|
||||
isOtpExpiryActive,
|
||||
readOtpExpireMinutesFromEnv,
|
||||
} from "src/helpers/user-otp-expiry";
|
||||
import { OtpGeneratorService } from "src/sms-orchestration/otp-generator.service";
|
||||
import { UserDbService } from "src/users/entities/db-service/user.db.service";
|
||||
import { SmsOrchestrationService } from "src/sms-orchestration/sms-orchestration.service";
|
||||
@@ -17,7 +28,6 @@ export interface LinkBinding {
|
||||
linkContext?: string;
|
||||
}
|
||||
|
||||
// TODO FIX REGISTER TO USER.SERVICE AND AUTH IN THIS MODULE
|
||||
@Injectable()
|
||||
export class UserAuthService {
|
||||
private readonly logger = new Logger(UserAuthService.name);
|
||||
@@ -36,18 +46,21 @@ export class UserAuthService {
|
||||
pass: string,
|
||||
binding: LinkBinding = {},
|
||||
): Promise<any> {
|
||||
const canonicalMobile = normalizeIranMobile(username) ?? username.trim();
|
||||
|
||||
await this.userLinkAccessService.assertMobileAllowed({
|
||||
mobile: username,
|
||||
mobile: canonicalMobile,
|
||||
linkToken: binding.linkToken,
|
||||
linkContext: binding.linkContext,
|
||||
});
|
||||
|
||||
const user = await this.userDbService.findOne({ username });
|
||||
const user = await this.userDbService.findOne(
|
||||
buildUserLookupByPhone(canonicalMobile),
|
||||
);
|
||||
if (!user) throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
|
||||
|
||||
const now = new Date().getTime();
|
||||
if (user.otp == null) throwUserAuthError(UserAuthErrorCode.OTP_REQUIRED);
|
||||
if (user.otpExpire < now) {
|
||||
if (!isOtpExpiryActive(user.otpExpire)) {
|
||||
throwUserAuthError(UserAuthErrorCode.OTP_EXPIRED);
|
||||
}
|
||||
if (await this.hashService.compare(pass, user.otp)) {
|
||||
@@ -57,23 +70,25 @@ export class UserAuthService {
|
||||
}
|
||||
|
||||
async login(user: any) {
|
||||
const userId = String(user._id ?? user.id ?? "");
|
||||
const payload = {
|
||||
username: user.username,
|
||||
sub: user.id,
|
||||
sub: userId,
|
||||
role: "user",
|
||||
};
|
||||
const accToken = this.jwtService.sign(payload, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
secret: `${process.env.JWT_SECRET}`,
|
||||
});
|
||||
await this.userDbService.findOneAndUpdate(
|
||||
{ username: user.username },
|
||||
{
|
||||
tokens: { token: accToken },
|
||||
otp: null,
|
||||
otpExpire: 0,
|
||||
},
|
||||
);
|
||||
return {
|
||||
userId: user._id,
|
||||
userId,
|
||||
access_token: accToken,
|
||||
};
|
||||
}
|
||||
@@ -82,29 +97,32 @@ export class UserAuthService {
|
||||
mobile: string,
|
||||
binding: LinkBinding = {},
|
||||
): Promise<LoginDtoRs> {
|
||||
const canonicalMobile = normalizeIranMobile(mobile) ?? mobile.trim();
|
||||
if (!canonicalMobile) {
|
||||
throwUserAuthError(UserAuthErrorCode.USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
await this.userLinkAccessService.assertMobileAllowed({
|
||||
mobile,
|
||||
mobile: canonicalMobile,
|
||||
linkToken: binding.linkToken,
|
||||
linkContext: binding.linkContext,
|
||||
});
|
||||
|
||||
const userExist = await this.userDbService.findOne({
|
||||
mobile,
|
||||
});
|
||||
const otp = this.otpCreator.create();
|
||||
const userExist = await this.userDbService.findOne(
|
||||
buildUserLookupByPhone(canonicalMobile),
|
||||
);
|
||||
const otp = this.createOtpForRequest();
|
||||
const hashOtp = await this.hashService.hash(otp);
|
||||
const rawExpireMinutes = Number(process.env.EXP_OTP_TIME ?? "2");
|
||||
const expireMinutes =
|
||||
Number.isFinite(rawExpireMinutes) && rawExpireMinutes > 0
|
||||
? rawExpireMinutes
|
||||
: 2;
|
||||
const otpExpire = Date.now() + expireMinutes * 60 * 1000;
|
||||
const expireMinutes = readOtpExpireMinutesFromEnv();
|
||||
const nowMs = Date.now();
|
||||
const otpExpire = computeOtpExpireMs(expireMinutes, nowMs);
|
||||
|
||||
if (!userExist) {
|
||||
await this.smsSender(otp, mobile);
|
||||
/// create otp request
|
||||
await this.smsSender(otp, canonicalMobile);
|
||||
// console.log(`OTP for ${canonicalMobile}: ${otp}`);
|
||||
const newUser = await this.userDbService.createUser({
|
||||
mobile,
|
||||
username: mobile,
|
||||
mobile: canonicalMobile,
|
||||
username: canonicalMobile,
|
||||
otp: hashOtp,
|
||||
tokens: {
|
||||
token: "",
|
||||
@@ -122,35 +140,50 @@ export class UserAuthService {
|
||||
});
|
||||
return new LoginDtoRs(newUser);
|
||||
}
|
||||
if (userExist) {
|
||||
if (userExist.otpExpire > new Date(new Date().getTime()).getTime()) {
|
||||
throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON);
|
||||
|
||||
if (isOtpExpiryActive(userExist.otpExpire, nowMs)) {
|
||||
// throwUserAuthError(UserAuthErrorCode.OTP_REQUEST_TOO_SOON);
|
||||
return new LoginDtoRs(userExist, "OTP Still valid");
|
||||
}
|
||||
await this.smsSender(otp, mobile);
|
||||
const updateTokens = await this.userDbService.findOneAndUpdate(
|
||||
{
|
||||
username: userExist.username,
|
||||
},
|
||||
|
||||
await this.smsSender(otp, canonicalMobile);
|
||||
// console.log(`OTP for ${canonicalMobile}: ${otp}`);
|
||||
await this.userDbService.findOneAndUpdate(
|
||||
buildUserLookupByPhone(canonicalMobile),
|
||||
{
|
||||
otp: hashOtp,
|
||||
otpExpire,
|
||||
mobile: canonicalMobile,
|
||||
username: userExist.username || canonicalMobile,
|
||||
},
|
||||
);
|
||||
if (updateTokens) return new LoginDtoRs(userExist);
|
||||
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,
|
||||
process.env.AUTH_SMS_TEMPLATE,
|
||||
);
|
||||
if (!ok) {
|
||||
throw new HttpException(
|
||||
"auth sms send failed",
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
throw new HttpException("auth sms send failed", HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
this.logger.log(
|
||||
`Auth OTP SMS accepted by provider phone=${mobile} otp=${otp}`,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { JwtModule, JwtService } from "@nestjs/jwt";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
import { LocalStrategy } from "src/auth/stratregys/local.strategy";
|
||||
import { LocalActorStrategy } from "src/auth/stratregys/local-actor.strategy";
|
||||
import { LocalActorAuthGuard } from "src/auth/guards/actor-local.guard";
|
||||
import { LocalUserAuthGuard } from "src/auth/guards/user-local.guard";
|
||||
import { ActorAuthController } from "src/auth/auth-controllers/actor/actor.auth.controller";
|
||||
import { UserAuthController } from "src/auth/auth-controllers/user/user.auth.controller";
|
||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||
import { UserLinkAccessService } from "src/auth/auth-services/user-link-access.service";
|
||||
import { CaptchaAccountService } from "src/auth/auth-services/captcha-account.service";
|
||||
import {
|
||||
ClaimCase,
|
||||
ClaimCaseSchema,
|
||||
} from "src/claim-request-management/entites/schema/claim-cases.schema";
|
||||
import {
|
||||
ClaimRequestManagementModel,
|
||||
ClaimRequestManagementSchema,
|
||||
@@ -25,18 +27,22 @@ import {
|
||||
} from "src/request-management/entities/schema/request-management.schema";
|
||||
import { UsersModule } from "src/users/users.module";
|
||||
import { HashModule } from "src/utils/hash/hash.module";
|
||||
// import { MailModule } from "src/utils/mail/mail.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()
|
||||
@Module({
|
||||
imports: [
|
||||
// MailModule, // Mailer disabled – not used
|
||||
UsersModule,
|
||||
ClientModule,
|
||||
HashModule,
|
||||
CaptchaModule,
|
||||
PassportModule,
|
||||
SmsOrchestrationModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: RequestManagementModel.name, schema: RequestManagementSchema },
|
||||
@@ -45,23 +51,32 @@ import { CaptchaModule } from "src/captcha/captcha.module";
|
||||
name: ClaimRequestManagementModel.name,
|
||||
schema: ClaimRequestManagementSchema,
|
||||
},
|
||||
{ name: ClaimCase.name, schema: ClaimCaseSchema },
|
||||
{ name: SuperAdminModel.name, schema: SuperAdminSchema },
|
||||
]),
|
||||
JwtModule.register({
|
||||
signOptions: { expiresIn: "1h" },
|
||||
signOptions: { expiresIn: "1h" }, // TODO: MAKE IT ENV
|
||||
global: true,
|
||||
secret: `${process.env.SECRET}`,
|
||||
secret: `${process.env.JWT_SECRET}`,
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
UserAuthService,
|
||||
UserLinkAccessService,
|
||||
CaptchaAccountService,
|
||||
ActorAuthService,
|
||||
LocalStrategy,
|
||||
LocalActorStrategy,
|
||||
JwtService,
|
||||
LocalActorAuthGuard,
|
||||
LocalUserAuthGuard,
|
||||
SuperAdminDbService,
|
||||
],
|
||||
exports: [
|
||||
UserAuthService,
|
||||
ActorAuthService,
|
||||
JwtService,
|
||||
LocalActorAuthGuard,
|
||||
LocalUserAuthGuard,
|
||||
SuperAdminDbService,
|
||||
],
|
||||
exports: [LocalStrategy, UserAuthService, ActorAuthService, JwtService],
|
||||
controllers: [UserAuthController, ActorAuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,20 +1,50 @@
|
||||
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;
|
||||
|
||||
@ApiProperty({
|
||||
example: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
description: "Captcha id from GET /actor/captcha.",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
captchaId: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "a7bx2",
|
||||
description: "Captcha text from GET /actor/captcha (same username + role).",
|
||||
description: "Characters shown in the captcha image.",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class CaptchaResponseDto {
|
||||
@ApiProperty({
|
||||
description: "Captcha challenge id — send back with POST /actor/login.",
|
||||
example: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
})
|
||||
captchaId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Sample text",
|
||||
example: "sb20xe"
|
||||
})
|
||||
text: string
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"SVG captcha as a data URI — use as `<img src={image} />` in the frontend. " +
|
||||
"Swagger shows this as text only; paste the full string into a browser tab, " +
|
||||
"or call GET /actor/captcha?format=raw to view the image directly.",
|
||||
"SVG captcha as a data URI — use as `<img src={image} />` in the frontend.",
|
||||
example: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iLi4u",
|
||||
})
|
||||
image: string;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEnum, IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
|
||||
export class GetActorCaptchaQueryDto {
|
||||
@ApiProperty({
|
||||
example: "expert@gmail.com",
|
||||
description: "Actor email / username",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(128)
|
||||
username: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: RoleEnum,
|
||||
example: RoleEnum.EXPERT,
|
||||
description: "Actor role",
|
||||
})
|
||||
@IsEnum(RoleEnum)
|
||||
role: RoleEnum;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ["json", "raw"],
|
||||
default: "json",
|
||||
description:
|
||||
"Use `raw` to open the captcha directly in the browser (image/svg+xml). " +
|
||||
"Default `json` returns `{ image, expiresAt }` for the frontend.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["json", "raw"])
|
||||
format?: "json" | "raw";
|
||||
}
|
||||
15
src/auth/dto/get-captcha-image-query.dto.ts
Normal file
15
src/auth/dto/get-captcha-image-query.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsOptional } from "class-validator";
|
||||
|
||||
export class GetCaptchaImageQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: ["json", "raw"],
|
||||
default: "json",
|
||||
description:
|
||||
"On GET /actor/captcha, use `raw` to return image/svg+xml (for browser preview). " +
|
||||
"The JSON response includes `captchaId` either way.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(["json", "raw"])
|
||||
format?: "json" | "raw";
|
||||
}
|
||||
@@ -35,7 +35,9 @@ export class UserLoginDto {
|
||||
}
|
||||
|
||||
export class LoginDtoRs extends UserModel {
|
||||
@ApiProperty({ type: "string" })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "09226187419",
|
||||
type: "string",
|
||||
@@ -61,8 +63,10 @@ export class LoginDtoRs extends UserModel {
|
||||
username: string;
|
||||
mobile: string;
|
||||
nationalCode: string;
|
||||
constructor(loginData) {
|
||||
|
||||
constructor(loginData, message: string = "") {
|
||||
super();
|
||||
this.mobile = loginData.mobile;
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||
import { RoleEnum } from "src/Types&Enums/role.enum";
|
||||
|
||||
@Injectable()
|
||||
export class LocalActorAuthGuard extends AuthGuard("actor") {
|
||||
export class LocalActorAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly actorAuthService: ActorAuthService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
const path = request.url;
|
||||
|
||||
if (!token) {
|
||||
if (this.isActorLoginRequest(request)) {
|
||||
@@ -30,14 +27,13 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
|
||||
request.user = loginData;
|
||||
request.identity = loginData;
|
||||
return true;
|
||||
} else {
|
||||
throw new UnauthorizedException("Token not found");
|
||||
}
|
||||
throw new UnauthorizedException("Token not found");
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
secret: `${process.env.JWT_SECRET}`,
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -47,6 +43,9 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
|
||||
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");
|
||||
@@ -72,9 +71,10 @@ export class LocalActorAuthGuard extends AuthGuard("actor") {
|
||||
return path === "/actor/login" || path.endsWith("/actor/login");
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
//@ts-ignore
|
||||
const [type, token] = request.headers.authorization?.split(" ") ?? [];
|
||||
private extractTokenFromHeader(request: {
|
||||
headers?: { authorization?: string };
|
||||
}): string | undefined {
|
||||
const [type, token] = request.headers?.authorization?.split(" ") ?? [];
|
||||
return type === "Bearer" ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export class ClaimAccessGuard implements CanActivate {
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
secret: `${process.env.JWT_SECRET}`,
|
||||
});
|
||||
|
||||
// Allow users to pass through (they will be checked by service methods)
|
||||
@@ -81,7 +81,10 @@ export class ClaimAccessGuard implements CanActivate {
|
||||
|
||||
throw new UnauthorizedException("Invalid role");
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenException || error instanceof UnauthorizedException) {
|
||||
if (
|
||||
error instanceof ForbiddenException ||
|
||||
error instanceof UnauthorizedException
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new UnauthorizedException();
|
||||
@@ -124,4 +127,3 @@ export class ClaimAccessGuard implements CanActivate {
|
||||
return type === "Bearer" ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,22 +25,27 @@ export class GlobalGuard implements CanActivate {
|
||||
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
throw new UnauthorizedException("Missing Bearer token");
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export class SettingsJwtGuard implements CanActivate {
|
||||
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: `${process.env.SECRET}`,
|
||||
secret: `${process.env.JWT_SECRET}`,
|
||||
});
|
||||
(request as any).user = payload;
|
||||
(request as any).identity = payload;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
UserAuthErrorCode,
|
||||
throwUserAuthError,
|
||||
@@ -7,13 +10,12 @@ import {
|
||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||
|
||||
@Injectable()
|
||||
export class LocalUserAuthGuard extends AuthGuard("local") {
|
||||
constructor(private readonly userAuthService: UserAuthService) {
|
||||
super();
|
||||
}
|
||||
export class LocalUserAuthGuard implements CanActivate {
|
||||
constructor(private readonly userAuthService: UserAuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const { username, password, linkToken, linkContext } = request.body;
|
||||
const { username, password, linkToken, linkContext } = request.body ?? {};
|
||||
const isValidUser = await this.userAuthService.validateUser(
|
||||
username,
|
||||
password,
|
||||
@@ -22,7 +24,7 @@ export class LocalUserAuthGuard extends AuthGuard("local") {
|
||||
if (!isValidUser) {
|
||||
throwUserAuthError(UserAuthErrorCode.OTP_INVALID);
|
||||
}
|
||||
request["user"] = isValidUser;
|
||||
request.user = isValidUser;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { Strategy } from "passport-local";
|
||||
import { ActorAuthService } from "src/auth/auth-services/actor.auth.service";
|
||||
|
||||
@Injectable()
|
||||
export class LocalActorStrategy extends PassportStrategy(Strategy, "actor") {
|
||||
constructor(private readonly actorAuthService: ActorAuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
// async validate(username, password): Promise<any> {
|
||||
// const user = await this.actorAuthService.validateActor(
|
||||
// username,
|
||||
// password,
|
||||
// );
|
||||
// if (!user) {
|
||||
// throw new UnauthorizedException("user not found");
|
||||
// }
|
||||
// return user;
|
||||
// }
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { Strategy } from "passport-local";
|
||||
import {
|
||||
UserAuthErrorCode,
|
||||
throwUserAuthError,
|
||||
} from "src/auth/auth-services/user-auth-error";
|
||||
import { UserAuthService } from "src/auth/auth-services/user.auth.service";
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private readonly userAuthService: UserAuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(username: string, password: string): Promise<any> {
|
||||
const user = await this.userAuthService.validateUser(username, password);
|
||||
if (!user) {
|
||||
throwUserAuthError(UserAuthErrorCode.OTP_INVALID);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
112
src/captcha/captcha-challenge.service.ts
Normal file
112
src/captcha/captcha-challenge.service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
CaptchaAuthErrorCode,
|
||||
throwCaptchaAuthError,
|
||||
} from "src/auth/auth-services/captcha-auth.error";
|
||||
import { CaptchaResponseDto } from "src/auth/dto/captcha-response.dto";
|
||||
import { CaptchaService } from "src/captcha/captcha.service";
|
||||
import { CaptchaChallengeDbService } from "src/captcha/entities/db-service/captcha-challenge.db.service";
|
||||
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,
|
||||
private readonly hashService: HashService,
|
||||
private readonly captchaChallengeDbService: CaptchaChallengeDbService,
|
||||
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> {
|
||||
const generated = this.captchaService.generate();
|
||||
const captchaId = randomUUID();
|
||||
|
||||
// Always hash and persist the answer — the env check was the root bug
|
||||
const answerHash = await this.hashService.hash(
|
||||
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 + 120_000),
|
||||
usedAt: null,
|
||||
});
|
||||
|
||||
return {
|
||||
captchaId,
|
||||
image: generated.image,
|
||||
expiresAt: generated.expiresAt,
|
||||
...(this.isDev && { text: generated.text }),
|
||||
};
|
||||
}
|
||||
|
||||
async getImageById(captchaId: string): Promise<string> {
|
||||
const challenge =
|
||||
await this.captchaChallengeDbService.findByCaptchaId(captchaId);
|
||||
if (!challenge) {
|
||||
throw new NotFoundException("Captcha not found");
|
||||
}
|
||||
return this.decodeImage(challenge.image);
|
||||
}
|
||||
|
||||
async verify(
|
||||
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);
|
||||
}
|
||||
if (!answer?.trim()) {
|
||||
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_REQUIRED);
|
||||
}
|
||||
|
||||
const challenge = await this.captchaChallengeDbService.findByCaptchaId(
|
||||
captchaId.trim(),
|
||||
);
|
||||
if (!challenge) {
|
||||
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_NOT_FOUND);
|
||||
}
|
||||
if (challenge.usedAt) {
|
||||
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_INVALID);
|
||||
}
|
||||
if (challenge.expiresAt < Date.now()) {
|
||||
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_EXPIRED);
|
||||
}
|
||||
|
||||
const ok = await this.hashService.compare(
|
||||
this.captchaService.normalizeAnswer(answer),
|
||||
challenge.answerHash,
|
||||
);
|
||||
if (!ok) {
|
||||
throwCaptchaAuthError(CaptchaAuthErrorCode.CAPTCHA_INVALID);
|
||||
}
|
||||
|
||||
await this.captchaChallengeDbService.markUsed(challenge.captchaId);
|
||||
}
|
||||
|
||||
private decodeImage(imageDataUri: string): string {
|
||||
const base64 = imageDataUri.replace(/^data:image\/svg\+xml;base64,/, "");
|
||||
return Buffer.from(base64, "base64").toString("utf8");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,28 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MongooseModule } from "@nestjs/mongoose";
|
||||
import { HashModule } from "src/utils/hash/hash.module";
|
||||
import { CaptchaChallengeService } from "./captcha-challenge.service";
|
||||
import { CaptchaService } from "./captcha.service";
|
||||
import { CaptchaChallengeDbService } from "./entities/db-service/captcha-challenge.db.service";
|
||||
import {
|
||||
CaptchaChallenge,
|
||||
CaptchaChallengeSchema,
|
||||
} from "./entities/schema/captcha-challenge.schema";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
providers: [CaptchaService],
|
||||
exports: [CaptchaService],
|
||||
imports: [
|
||||
ConfigModule,
|
||||
HashModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: CaptchaChallenge.name, schema: CaptchaChallengeSchema },
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
CaptchaService,
|
||||
CaptchaChallengeDbService,
|
||||
CaptchaChallengeService,
|
||||
],
|
||||
exports: [CaptchaChallengeService],
|
||||
})
|
||||
export class CaptchaModule {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import * as svgCaptcha from "svg-captcha";
|
||||
|
||||
export interface GeneratedCaptcha {
|
||||
@@ -12,9 +13,9 @@ export class CaptchaService {
|
||||
generate(): GeneratedCaptcha {
|
||||
const captcha = svgCaptcha.create({
|
||||
size: 5,
|
||||
ignoreChars: "0oO1ilI",
|
||||
noise: 3,
|
||||
color: true,
|
||||
ignoreChars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
noise: 1,
|
||||
color: false,
|
||||
background: "#f8fafc",
|
||||
width: 160,
|
||||
height: 56,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectModel } from "@nestjs/mongoose";
|
||||
import { Model } from "mongoose";
|
||||
import {
|
||||
CaptchaChallenge,
|
||||
CaptchaChallengeDocument,
|
||||
} from "../schema/captcha-challenge.schema";
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaChallengeDbService {
|
||||
constructor(
|
||||
@InjectModel(CaptchaChallenge.name)
|
||||
private readonly captchaChallengeModel: Model<CaptchaChallengeDocument>,
|
||||
) {}
|
||||
|
||||
create(data: CaptchaChallenge): Promise<CaptchaChallengeDocument> {
|
||||
return this.captchaChallengeModel.create(data);
|
||||
}
|
||||
|
||||
findByCaptchaId(
|
||||
captchaId: string,
|
||||
): Promise<CaptchaChallengeDocument | null> {
|
||||
return this.captchaChallengeModel.findOne({ captchaId });
|
||||
}
|
||||
|
||||
markUsed(captchaId: string): Promise<CaptchaChallengeDocument | null> {
|
||||
return this.captchaChallengeModel.findOneAndUpdate(
|
||||
{ captchaId, usedAt: null },
|
||||
{ $set: { usedAt: new Date() } },
|
||||
{ new: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
32
src/captcha/entities/schema/captcha-challenge.schema.ts
Normal file
32
src/captcha/entities/schema/captcha-challenge.schema.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
|
||||
import { HydratedDocument } from "mongoose";
|
||||
|
||||
@Schema({
|
||||
collection: "captcha-challenges",
|
||||
timestamps: true,
|
||||
versionKey: false,
|
||||
})
|
||||
export class CaptchaChallenge {
|
||||
@Prop({ required: true, unique: true, index: true })
|
||||
captchaId: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
answerHash: string;
|
||||
|
||||
@Prop({ required: true })
|
||||
image: string;
|
||||
|
||||
@Prop({ required: true, index: true })
|
||||
expiresAt: number;
|
||||
|
||||
/** Mongo TTL — document removed shortly after this time. */
|
||||
@Prop({ required: true, expires: 0 })
|
||||
expireAt: Date;
|
||||
|
||||
@Prop({ default: null })
|
||||
usedAt?: Date | null;
|
||||
}
|
||||
|
||||
export type CaptchaChallengeDocument = HydratedDocument<CaptchaChallenge>;
|
||||
export const CaptchaChallengeSchema =
|
||||
SchemaFactory.createForClass(CaptchaChallenge);
|
||||
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,34 +126,32 @@ export class ClaimRequestManagementController {
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ deprecated: true })
|
||||
// @ApiOperation({ deprecated: true })
|
||||
@Get("required-documents-status/:claimRequestID")
|
||||
@ApiParam({ name: "claimRequestID" })
|
||||
async getRequiredDocumentsStatus(
|
||||
@Param("claimRequestID") requestId: string,
|
||||
) {
|
||||
// @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 },
|
||||
@@ -171,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,
|
||||
@@ -197,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 },
|
||||
@@ -225,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,
|
||||
@@ -248,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 },
|
||||
@@ -270,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,
|
||||
@@ -284,25 +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" })
|
||||
myRequests(
|
||||
@Param("claimRequestId") requestId: string,
|
||||
@CurrentUser() user,
|
||||
) {
|
||||
// @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: {
|
||||
@@ -319,34 +316,34 @@ 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,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user,
|
||||
) {
|
||||
return await this.claimRequestManagementService.submitUserReply(
|
||||
requestId,
|
||||
body,
|
||||
file,
|
||||
user,
|
||||
);
|
||||
// return await this.claimRequestManagementService.submitUserReply(
|
||||
// requestId,
|
||||
// body,
|
||||
// file,
|
||||
// user,
|
||||
// );
|
||||
}
|
||||
|
||||
@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,
|
||||
@@ -357,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({
|
||||
@@ -408,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,
|
||||
@@ -424,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({
|
||||
@@ -467,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,
|
||||
@@ -484,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,11 +10,16 @@ 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";
|
||||
import { ClaimCaseDbService } from "./entites/db-service/claim-case.db.service";
|
||||
import { ClaimCase, ClaimCaseSchema } from "./entites/schema/claim-cases.schema";
|
||||
import {
|
||||
ClaimCase,
|
||||
ClaimCaseSchema,
|
||||
} from "./entites/schema/claim-cases.schema";
|
||||
import { ClaimSignDbService } from "./entites/db-service/claim-sign.db.service";
|
||||
import { DamageImageDbService } from "./entites/db-service/damage-image.db.service";
|
||||
import { ClaimFactorsImageDbService } from "./entites/db-service/factor-image.db.service";
|
||||
@@ -47,9 +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 { FanavaranAuditModule } from "src/fanavaran/fanavaran-audit.module";
|
||||
import { FanavaranLookupModule } from "src/fanavaran/fanavaran-lookup.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: createHttpModuleOptions,
|
||||
}),
|
||||
FanavaranAuditModule,
|
||||
FanavaranLookupModule,
|
||||
PublicIdModule,
|
||||
UsersModule,
|
||||
RequestManagementModule,
|
||||
@@ -92,6 +109,8 @@ import { MediaPolicyModule } from "src/media-policy/media-policy.module";
|
||||
ClaimRequestManagementController,
|
||||
ClaimRequestManagementV2Controller,
|
||||
RegistrarClaimV1Controller,
|
||||
ExpertInitiatedClaimMirrorController,
|
||||
RegistrarClaimMirrorController,
|
||||
],
|
||||
exports: [
|
||||
ClaimRequestManagementService,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,12 +13,21 @@ 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 "path";
|
||||
import { extname } from "node:path";
|
||||
import { Types } from "mongoose";
|
||||
import { GlobalGuard } from "src/auth/guards/global.guard";
|
||||
import { RolesGuard } from "src/auth/guards/role.guard";
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -67,8 +67,24 @@ export class ExpertResendDetailsV2Dto {
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
resendDocuments?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [Object] })
|
||||
resendCarParts?: Array<{ key?: string; label_fa?: string; label_en?: string }>;
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Damaged parts the expert asked to re-capture (same shape as damagedParts: id, name, side, label_fa, catalogKey, captured, url).",
|
||||
type: [Object],
|
||||
})
|
||||
resendCarParts?: Array<{
|
||||
id?: number | null;
|
||||
name?: string;
|
||||
side?: string;
|
||||
label_fa?: string;
|
||||
label_en?: string;
|
||||
catalogKey?: string;
|
||||
key?: string;
|
||||
captured?: boolean;
|
||||
url?: string;
|
||||
path?: string;
|
||||
fileName?: string;
|
||||
}>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Set when the owner satisfied the resend request' })
|
||||
fulfilledAt?: Date;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -66,6 +66,13 @@ export class ClaimWorkflow {
|
||||
|
||||
@Prop({ type: ClaimPreLockQueueSnapshotSchema })
|
||||
preLockQueueSnapshot?: ClaimPreLockQueueSnapshot;
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
assignedForReviewBy?: ClaimActorLock;
|
||||
}
|
||||
export const ClaimWorkflowSchema = SchemaFactory.createForClass(ClaimWorkflow);
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ import { HydratedDocument, Schema as MongooseSchema, Types } from "mongoose";
|
||||
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
|
||||
import { ClaimStatus } from "src/Types&Enums/claim-request-management/claimStatus.enum";
|
||||
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
|
||||
import { HistoryEvent, HistoryEventSchema } from "src/request-management/entities/schema/historyEvent.type";
|
||||
import {
|
||||
HistoryEvent,
|
||||
HistoryEventSchema,
|
||||
} from "src/request-management/entities/schema/historyEvent.type";
|
||||
import {
|
||||
ClaimDamageSelection,
|
||||
ClaimDamageSelectionSchema,
|
||||
@@ -25,8 +28,15 @@ import {
|
||||
ClaimCaseSnapshot,
|
||||
ClaimCaseSnapshotSchema,
|
||||
} from "./claim-case.snapshot.schema";
|
||||
import { ClaimWorkflow, ClaimWorkflowSchema } from "./claim-case.workflow.schema";
|
||||
import {
|
||||
ClaimWorkflow,
|
||||
ClaimWorkflowSchema,
|
||||
} from "./claim-case.workflow.schema";
|
||||
import { UserClaimRating } from "./claim-request-management.schema";
|
||||
import {
|
||||
CaseInquiries,
|
||||
CaseInquiriesSchema,
|
||||
} from "src/common/schema/case-inquiries.schema";
|
||||
|
||||
@Schema({ _id: false })
|
||||
export class RequiredDocumentRef {
|
||||
@@ -45,7 +55,66 @@ export class RequiredDocumentRef {
|
||||
@Prop({ type: Date })
|
||||
uploadedAt?: Date;
|
||||
}
|
||||
export const RequiredDocumentRefSchema = SchemaFactory.createForClass(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",
|
||||
@@ -67,7 +136,12 @@ export class ClaimCase {
|
||||
/**
|
||||
* Overall case status (user flow + expert flow progression)
|
||||
*/
|
||||
@Prop({ required: true, type: String, enum: ClaimCaseStatus, default: ClaimCaseStatus.CREATED })
|
||||
@Prop({
|
||||
required: true,
|
||||
type: String,
|
||||
enum: ClaimCaseStatus,
|
||||
default: ClaimCaseStatus.CREATED,
|
||||
})
|
||||
status: ClaimCaseStatus;
|
||||
|
||||
/**
|
||||
@@ -92,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;
|
||||
|
||||
@@ -110,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;
|
||||
|
||||
@@ -119,6 +218,9 @@ export class ClaimCase {
|
||||
@Prop({ type: ClaimEvaluationSchema, default: () => ({}) })
|
||||
evaluation?: ClaimEvaluation;
|
||||
|
||||
@Prop({ type: CaseInquiriesSchema, default: () => ({}) })
|
||||
inquiries?: CaseInquiries;
|
||||
|
||||
/**
|
||||
* Optional “read-optimized” copy of fields from blame/request side.
|
||||
* Source of truth remains `blameRequestId`.
|
||||
@@ -149,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.
|
||||
@@ -159,5 +275,4 @@ export class ClaimCase {
|
||||
|
||||
export type ClaimCaseDocument = HydratedDocument<ClaimCase>;
|
||||
export const ClaimCaseSchema = SchemaFactory.createForClass(ClaimCase);
|
||||
|
||||
|
||||
ClaimCaseSchema.index({ status: 1, "workflow.locked": 1 });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prop, Schema } from "@nestjs/mongoose";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
@Schema({ versionKey: false, _id: false })
|
||||
export class ImageRequiredModel {
|
||||
@@ -20,7 +20,7 @@ export class ImageRequiredModel {
|
||||
constructor(claimFile: any[]) {
|
||||
this.aroundTheCar.forEach((a) => {
|
||||
Object.assign(a, {
|
||||
partId: uuidv4(),
|
||||
partId: randomUUID(),
|
||||
imageId: null,
|
||||
aiReport: {},
|
||||
upload: false,
|
||||
@@ -28,7 +28,7 @@ export class ImageRequiredModel {
|
||||
});
|
||||
this.selectPartOfCar = claimFile.map((c, idx) =>
|
||||
Object.assign(c, {
|
||||
partId: uuidv4(),
|
||||
partId: randomUUID(),
|
||||
aiReport: {},
|
||||
imageId: null,
|
||||
upload: false,
|
||||
|
||||
@@ -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,20 +17,38 @@ 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
|
||||
* `settings.carBodyAccidentMaxAgeDays` value yet. Keep it conservative so the
|
||||
* gate is enforced even for older / unconfigured clients.
|
||||
*/
|
||||
export const DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS = 7;
|
||||
export const DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS = 5;
|
||||
|
||||
/**
|
||||
* Media kinds we enforce upload-size bounds for. Each kind has its own
|
||||
* default window (see {@link DEFAULT_MEDIA_LIMITS}) and an optional
|
||||
* per-client override under `settings.media.<kind>`.
|
||||
*/
|
||||
export type MediaKind = "video" | "image" | "voice";
|
||||
export type MediaKind =
|
||||
| "video"
|
||||
| "image"
|
||||
| "voice"
|
||||
| "chassisNumber"
|
||||
| "carPlate";
|
||||
|
||||
export interface MediaLimits {
|
||||
/** Inclusive lower bound. `0` allows any size from zero up. */
|
||||
@@ -47,7 +65,7 @@ export interface MediaLimits {
|
||||
* receive. Per-client `maxBytes` cannot legitimately go above its route's
|
||||
* multer ceiling, so the defaults below are kept at-or-below those.
|
||||
*/
|
||||
export const DEFAULT_MEDIA_MIN_BYTES = 20 * 1024; // 100KB
|
||||
export const DEFAULT_MEDIA_MIN_BYTES = 10; // 10 Byte
|
||||
export const DEFAULT_MEDIA_MAX_BYTES = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
export const DEFAULT_MEDIA_LIMITS: Record<MediaKind, MediaLimits> = {
|
||||
@@ -63,6 +81,14 @@ export const DEFAULT_MEDIA_LIMITS: Record<MediaKind, MediaLimits> = {
|
||||
minBytes: DEFAULT_MEDIA_MIN_BYTES,
|
||||
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
carPlate: {
|
||||
minBytes: DEFAULT_MEDIA_MIN_BYTES,
|
||||
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
chassisNumber: {
|
||||
minBytes: DEFAULT_MEDIA_MIN_BYTES,
|
||||
maxBytes: DEFAULT_MEDIA_MAX_BYTES,
|
||||
},
|
||||
};
|
||||
|
||||
/** Highest multer `fileSize` used on any route for each kind (policy cannot exceed this). */
|
||||
@@ -70,6 +96,8 @@ export const MEDIA_ROUTE_MAX_BYTES: Record<MediaKind, number> = {
|
||||
video: DEFAULT_MEDIA_MAX_BYTES,
|
||||
image: DEFAULT_MEDIA_MAX_BYTES,
|
||||
voice: DEFAULT_MEDIA_MAX_BYTES,
|
||||
carPlate: DEFAULT_MEDIA_MAX_BYTES,
|
||||
chassisNumber: DEFAULT_MEDIA_MAX_BYTES,
|
||||
};
|
||||
|
||||
export const CAR_BODY_ACCIDENT_MAX_AGE_DAYS_MIN = 1;
|
||||
@@ -79,24 +107,42 @@ 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,
|
||||
|
||||
clientName: {
|
||||
persian: client.clientName.persian,
|
||||
english: client.clientName.english || null,
|
||||
persian:
|
||||
typeof client.clientName === "string"
|
||||
? client.clientName
|
||||
: client.clientName?.persian,
|
||||
|
||||
english:
|
||||
typeof client.clientName === "string"
|
||||
? null
|
||||
: (client.clientName?.english ?? null),
|
||||
},
|
||||
property: {
|
||||
smsApiKey: client.property.smsApiKey || null,
|
||||
},
|
||||
useExpertMode: client.useExpertMode || null,
|
||||
|
||||
...(smsApiKey ? { property: { smsApiKey } } : {}),
|
||||
|
||||
useExpertMode: client.useExpertMode ?? null,
|
||||
});
|
||||
if (newClient) return new ClientDtoRs(newClient);
|
||||
else throw new GoneException("database not connected");
|
||||
|
||||
if (!newClient) {
|
||||
throw new GoneException("database not connected");
|
||||
}
|
||||
|
||||
return new ClientDtoRs(newClient);
|
||||
} catch (er) {
|
||||
throw new BadGatewayException(er.errors);
|
||||
console.error("ADD CLIENT ERROR:", er);
|
||||
throw er;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,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));
|
||||
@@ -185,13 +263,19 @@ export class ClientService {
|
||||
_id: new Types.ObjectId(idString),
|
||||
});
|
||||
const configured = client?.settings?.carBodyAccidentMaxAgeDays;
|
||||
if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) {
|
||||
if (
|
||||
typeof configured === "number" &&
|
||||
Number.isFinite(configured) &&
|
||||
configured > 0
|
||||
) {
|
||||
return configured;
|
||||
}
|
||||
return DEFAULT_CAR_BODY_ACCIDENT_MAX_AGE_DAYS;
|
||||
}
|
||||
|
||||
private resolveClientObjectId(clientKey: string | Types.ObjectId): Types.ObjectId {
|
||||
private resolveClientObjectId(
|
||||
clientKey: string | Types.ObjectId,
|
||||
): Types.ObjectId {
|
||||
const raw = String(clientKey ?? "").trim();
|
||||
if (!Types.ObjectId.isValid(raw)) {
|
||||
throw new BadRequestException("Invalid client key");
|
||||
@@ -293,7 +377,8 @@ export class ClientService {
|
||||
const $set: Record<string, unknown> = {};
|
||||
|
||||
if (body.carBodyAccidentMaxAgeDays !== undefined) {
|
||||
$set["settings.carBodyAccidentMaxAgeDays"] = body.carBodyAccidentMaxAgeDays;
|
||||
$set["settings.carBodyAccidentMaxAgeDays"] =
|
||||
body.carBodyAccidentMaxAgeDays;
|
||||
}
|
||||
|
||||
if (body.media) {
|
||||
@@ -324,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;
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsIn, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { Types } from "mongoose";
|
||||
|
||||
@@ -15,10 +22,10 @@ class ClientName {
|
||||
english: string;
|
||||
}
|
||||
class Property {
|
||||
@ApiProperty({})
|
||||
@ApiPropertyOptional({})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user